diff --git a/.secrets.baseline b/.secrets.baseline index 046b9045..dd518c23 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2026-07-03T11:02:31Z", + "generated_at": "2026-09-21T10:12:49Z", "plugins_used": [ { "name": "AWSKeyDetector" diff --git a/bin/mas-devops-feature-status-update b/bin/mas-devops-feature-status-update new file mode 100755 index 00000000..2d13a4b4 --- /dev/null +++ b/bin/mas-devops-feature-status-update @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 + +# ***************************************************************************** +# Copyright (c) 2025 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** +""" +mas-devops-feature-status-update — Write MAS feature status records to the +DevOps MongoDB (mas_devops database). + +Collection routing +────────────────── + --instance-id supplied → mas_devops.instance_level_config + One document per (subscription_id × + account × region × cluster × instance). + The feature entry is embedded in + instance_level_features[]. + + --instance-id omitted → mas_devops.cluster_level_config + One document per (account × region × + cluster). + The feature entry is embedded in + cluster_level_features[]. + +Sub-commands +──────────── + + status-update + Upsert a feature status entry. + Collections (with JSON Schema validators) and indexes are created + automatically on the first call if they are absent (idempotent). + + Example — instance-level, ACTIVE (single IP): + mas-devops-feature-status-update status-update \\ + --region us-east-2 \\ + --instance-id inst02 \\ + --account fyre-noble10-dev \\ + --cluster noble10 \\ + --subscription-id sub-id01 \\ + --type allow-list \\ + --feature-details '{"ips": ["2405:201:d000:9062::/64"]}' \\ + --status ACTIVE \\ + --status-details '{"message": "Allow list is active.", "request_configuration": "2405:201:d000:9062::/64"}' \\ + --deployment-start 2026-09-11T11:48:42+00:00 \\ + --deployment-end 2026-09-11T11:53:10+00:00 + + Example — instance-level, ACTIVE (multiple IPs): + mas-devops-feature-status-update status-update \\ + --region us-east-2 \\ + --instance-id inst02 \\ + --account fyre-noble10-dev \\ + --cluster noble10 \\ + --subscription-id sub-id01 \\ + --type allow-list \\ + --feature-details '{"ips": ["1.2.3.4/32", "2405:201:d000:9062::/64"]}' \\ + --status ACTIVE \\ + --status-details '{"message": "Allow list is active.", "request_configuration": "1.2.3.4/32, 2405:201:d000:9062::/64"}' \\ + --deployment-start 2026-09-11T11:48:42+00:00 \\ + --deployment-end 2026-09-11T11:53:10+00:00 + + Example — instance-level, ERROR (using --status-details-file to avoid shell quoting issues): + cat > /tmp/status-details.json <<'EOF' + { + "message": "sample error message", + "error_code": 401, + "error_source": { + "gitops_version": "8.6.0", + "filename": "cis_ip_allowlist.yml", + "log_file": "/var/log/gitops/run-001.log", + "stacktrace": "Traceback (most recent call last): ..." + }, + "request_configuration": "2405:201:d000:9060::/64" + } + EOF + mas-devops-feature-status-update status-update \\ + --region us-east-2 \\ + --instance-id inst02 \\ + --account fyre-noble10-dev \\ + --cluster noble10 \\ + --subscription-id sub-id01 \\ + --type allow-list \\ + --feature-details '{"ips": ["2405:201:d000:9060::/64"]}' \\ + --status ERROR \\ + --status-details-file /tmp/status-details.json \\ + --deployment-start 2026-09-11T11:48:42+00:00 \\ + --deployment-end 2026-09-11T11:53:10+00:00 + + get + Fetch and pretty-print a feature status entry by ObjectId or by + criteria fields (region, account, cluster[, instance_id], type). + + Example — by ObjectId: + mas-devops-feature-status-update get --id 6ab0e70ee6d3a31faa808547 + + Example — instance-level by criteria: + mas-devops-feature-status-update get \\ + --region us-east-2 \\ + --instance-id inst02 \\ + --account fyre-noble10-dev \\ + --cluster noble10 \\ + --subscription-id sub-id01 \\ + --type allow-list + + Example — cluster-level by criteria (no --instance-id): + mas-devops-feature-status-update get \\ + --region us-east-2 \\ + --account fyre-noble10-dev \\ + --cluster noble10 \\ + --type allow-list + +Environment variables +───────────────────── + DEVOPS_MONGO_URI (required) Full MongoDB connection URI. All options — including + TLS settings — are passed as query parameters in the URI and are + handled directly by pymongo. + + With TLS enabled (production): + export DEVOPS_MONGO_URI="mongodb://user:pass@host1:port1,host2:port2/admin?tls=true" # pragma: allowlist secret + + With TLS disabled or self-signed certs (local / dev): + export DEVOPS_MONGO_URI="mongodb://user:pass@localhost:27017/admin?tls=false&tlsAllowInvalidCertificates=true" # pragma: allowlist secret + +One-time initialisation +─────────────────────── + No separate setup step is required. The first call to status-update + automatically creates both collections (with JSON Schema validators) and + all required indexes if they are absent. The operation is idempotent — + subsequent calls are no-ops when everything already exists. +""" + +import argparse +import json +import logging +import os +import sys +from datetime import datetime, timezone +from typing import Optional + +# --------------------------------------------------------------------------- +# Env-var name used to supply the MongoDB connection URI +# --------------------------------------------------------------------------- + +_ENV_DEVOPS_MONGO_URI = "DEVOPS_MONGO_URI" + + +# --------------------------------------------------------------------------- +# Argument-parsing helpers +# --------------------------------------------------------------------------- + + +def _parse_json_relaxed(raw: str) -> dict: + """Parse a JSON string, accepting bare keys and single quotes (shell-friendly).""" + try: + return json.loads(raw) + except json.JSONDecodeError: + import re + + relaxed = re.sub(r"'([^']*)'", r'"\1"', raw) + relaxed = re.sub(r"([{,\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", r'\1"\2":', relaxed) + relaxed = re.sub(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", r'\1"\2":', relaxed) + try: + return json.loads(relaxed) + except json.JSONDecodeError: + raise ValueError(f"Could not parse value as JSON.\n" f" Input : {raw!r}\n" f' Hint : use double-quoted keys, e.g. {{"url": "mongodb://..."}}') + + +def _parse_json_arg(value: str, arg_name: str) -> dict: + try: + result = _parse_json_relaxed(value) + except ValueError as exc: + print(f"ERROR: --{arg_name}: {exc}", file=sys.stderr) + sys.exit(1) + if not isinstance(result, dict): + print(f"ERROR: --{arg_name} must be a JSON object (got {type(result).__name__})", file=sys.stderr) + sys.exit(1) + return result + + +def _read_file_arg(path: str, arg_name: str) -> str: + """Read and return the contents of *path* for use as a CLI argument value. + + Raises SystemExit(1) if the file cannot be read. + """ + try: + with open(path) as fh: + return fh.read() + except OSError as exc: + print(f"ERROR: --{arg_name}: cannot read file '{path}': {exc}", file=sys.stderr) + sys.exit(1) + + +def _parse_isodate(value: Optional[str], arg_name: str) -> Optional[datetime]: + """Parse an ISO-8601 datetime, stripping MongoDB ISODate() wrappers.""" + if value is None: + return None + import re + + m = re.match(r"ISODate\(['\"](.+?)['\"]\)", value.strip()) + if m: + value = m.group(1) + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except ValueError: + print(f"ERROR: --{arg_name} must be an ISO-8601 datetime string, got: {value!r}", file=sys.stderr) + sys.exit(1) + + +def _resolve_db() -> str: + """Return the MongoDB connection URI from the DEVOPS_MONGO_URI environment variable. + + Raises SystemExit(1) if the variable is unset or empty. + """ + uri = os.getenv(_ENV_DEVOPS_MONGO_URI, "") + if not uri: + print( + f"ERROR: {_ENV_DEVOPS_MONGO_URI} environment variable is required.\n" + f" Set it to a full MongoDB connection URI, e.g.:\n" + f' export {_ENV_DEVOPS_MONGO_URI}="mongodb://user:pass@localhost:27017/admin' # pragma: allowlist secret + f'?tls=false&tlsAllowInvalidCertificates=true"', + file=sys.stderr, + ) + sys.exit(1) + return uri + + +# --------------------------------------------------------------------------- +# Sub-command: get +# --------------------------------------------------------------------------- + + +def cmd_get(args) -> int: + """Fetch and pretty-print a feature status entry by ObjectId or by criteria fields.""" + from mas.devops.feature_status import ( + INSTANCE_LEVEL, + get_cluster_feature_by_criteria, + get_feature_level, + get_feature_status_by_id, + get_instance_feature_by_criteria, + ) + + mongo_url = _resolve_db() + + try: + if args.id: + doc = get_feature_status_by_id(mongo_url, args.id) + not_found_msg = f"No document found with ID: {args.id}" + else: + # Criteria mode — region, account, cluster, type are always required. + missing = [ + f + for f, v in [ + ("--account", args.account), + ("--cluster", args.cluster), + ("--type", args.type), + ] + if v is None + ] + if missing: + print(f"ERROR: the following arguments are required when using --region: {', '.join(missing)}", file=sys.stderr) + return 1 + + # Resolve collection routing from the feature type map. + try: + level = get_feature_level(args.type) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + if level == INSTANCE_LEVEL: + if not args.instance_id: + print(f"ERROR: --instance-id is required for instance-level feature type '{args.type}'", file=sys.stderr) + return 1 + if not args.subscription_id: + print("ERROR: --subscription-id is required for instance-level feature types", file=sys.stderr) + return 1 + doc = get_instance_feature_by_criteria( + mongo_url, + region=args.region, + instance_id=args.instance_id, + account=args.account, + cluster=args.cluster, + subscription_id=args.subscription_id, + feature_type=args.type, + ) + not_found_msg = ( + f"No '{args.type}' feature entry found for " + f"region={args.region} " + f"instance_id={args.instance_id} account={args.account} " + f"cluster={args.cluster} subscription_id={args.subscription_id}" + ) + else: + if args.instance_id: + print(f"ERROR: --instance-id must not be supplied for cluster-level feature type '{args.type}'", file=sys.stderr) + return 1 + doc = get_cluster_feature_by_criteria( + mongo_url, + region=args.region, + account=args.account, + cluster=args.cluster, + feature_type=args.type, + ) + not_found_msg = f"No '{args.type}' feature entry found for " f"region={args.region} " f"account={args.account} cluster={args.cluster}" + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + except Exception as exc: + print(f"ERROR: Could not connect to MongoDB: {exc}", file=sys.stderr) + return 1 + + if doc is None: + print(not_found_msg, file=sys.stderr) + return 1 + + print(json.dumps(doc, indent=2, default=str)) + return 0 + + +# --------------------------------------------------------------------------- +# Sub-command: status-update +# --------------------------------------------------------------------------- + + +def cmd_status_update(args) -> int: + """Upsert a feature status entry into MongoDB. + + Collection routing is determined by FEATURE_LEVEL_MAP: + INSTANCE_LEVEL → instance_level_config (--instance-id and --subscription-id required) + otherwise → cluster_level_config (--instance-id and --subscription-id must be absent) + + Required indexes are created automatically if they do not already exist. + """ + from mas.devops.feature_status import ( + INSTANCE_LEVEL, + VALID_STATUSES, + _redact_url, + create_indexes, + get_feature_level, + upsert_cluster_feature, + upsert_instance_feature, + validate_feature_details, + validate_status_details, + ) + + # Validate status enum + if args.status not in VALID_STATUSES: + print(f"ERROR: --status must be one of {sorted(VALID_STATUSES)}, got '{args.status}'", file=sys.stderr) + return 1 + + # Resolve collection routing from the feature type map (fail fast before any I/O). + try: + level = get_feature_level(args.type) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + # Validate --instance-id / --subscription-id consistency with the feature type's level. + if level == INSTANCE_LEVEL and not args.instance_id: + print(f"ERROR: --instance-id is required for instance-level feature type '{args.type}'", file=sys.stderr) + return 1 + if level == INSTANCE_LEVEL and not args.subscription_id: + print(f"ERROR: --subscription-id is required for instance-level feature type '{args.type}'", file=sys.stderr) + return 1 + if level != INSTANCE_LEVEL and args.instance_id: + print(f"ERROR: --instance-id must not be supplied for cluster-level feature type '{args.type}'", file=sys.stderr) + return 1 + + # Parse JSON arguments — --status-details-file takes precedence over --status-details + feature_details = _parse_json_arg(args.feature_details, "feature-details") + if args.status_details_file: + status_details_raw = _read_file_arg(args.status_details_file, "status-details-file") + status_details = _parse_json_arg(status_details_raw, "status-details-file") + else: + status_details = _parse_json_arg(args.status_details, "status-details") + + # Type-specific validation + try: + validate_feature_details(args.type, feature_details) + validate_status_details(args.type, args.status, status_details) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + # Parse datetime arguments + deployment_start = _parse_isodate(args.deployment_start, "deployment-start") + deployment_end = _parse_isodate(args.deployment_end, "deployment-end") + created_at = _parse_isodate(args.created_at, "created-at") + updated_at = _parse_isodate(args.updated_at, "updated-at") + + # Resolve DB URI from environment + mongo_url = _resolve_db() + + # Build the console label from the resolved level. + if level == INSTANCE_LEVEL: + collection_label = "instance_level_config" + target_label = f"account={args.account} cluster={args.cluster} " f"instance={args.instance_id} type={args.type} status={args.status}" + else: + collection_label = "cluster_level_config" + target_label = f"account={args.account} cluster={args.cluster} " f"type={args.type} status={args.status}" + + print(f"Writing feature status to mas_devops.{collection_label}: {target_label}") + print(f" MongoDB: {_redact_url(mongo_url)}") + + # Ensure required indexes exist on both collections (idempotent — no-op when already present). + try: + create_indexes(mongo_url) + except Exception as exc: + print(f"ERROR: Could not initialise collection indexes: {exc}", file=sys.stderr) + return 1 + + try: + if level == INSTANCE_LEVEL: + doc_id = upsert_instance_feature( + mongo_url, + subscription_id=args.subscription_id, + region=args.region, + account=args.account, + cluster=args.cluster, + instance=args.instance_id, + feature_type=args.type, + feature_details=feature_details, + status=args.status, + status_details=status_details, + deployment_start=deployment_start, + deployment_end=deployment_end, + created_at=created_at, + updated_at=updated_at, + ) + else: + doc_id = upsert_cluster_feature( + mongo_url, + region=args.region, + account=args.account, + cluster=args.cluster, + feature_type=args.type, + feature_details=feature_details, + status=args.status, + status_details=status_details, + deployment_start=deployment_start, + deployment_end=deployment_end, + created_at=created_at, + updated_at=updated_at, + ) + print(f"Feature status written successfully. Document ID: {doc_id}") + return 0 + except ValueError as exc: + print(f"ERROR: Validation failed — {exc}", file=sys.stderr) + return 1 + except Exception as exc: + print(f"ERROR: Failed to write feature status to MongoDB: {exc}", file=sys.stderr) + return 1 + + +# --------------------------------------------------------------------------- +# Argument parser +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="mas-devops-feature-status-update", + description="Write MAS feature status records to the DevOps MongoDB (mas_devops).", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--log-level", + required=False, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="WARNING", + help="Python logging level (default: WARNING)", + ) + + subparsers = parser.add_subparsers(dest="command", metavar="") + subparsers.required = True + + # ── status-update ───────────────────────────────────────────────────────── + su = subparsers.add_parser( + "status-update", + help=( + "Upsert a feature status entry. " "Routes to instance_level_config when --instance-id is supplied, " "or cluster_level_config when it is omitted." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + identity = su.add_argument_group("identity") + identity.add_argument("--region", required=True, help="AWS region (e.g. us-east-2)") + identity.add_argument( + "--instance-id", + required=False, + default=None, + dest="instance_id", + help=("MAS instance ID (e.g. inst02). " "When supplied, writes to instance_level_config. " "When omitted, writes to cluster_level_config."), + ) + identity.add_argument("--account", required=True, help="GitOps account name (e.g. fyre-noble10-dev)") + identity.add_argument("--cluster", required=True, help="GitOps cluster name (e.g. noble10)") + identity.add_argument( + "--subscription-id", + required=False, + default=None, + dest="subscription_id", + help="Subscription ID. Required for instance-level feature types; must be omitted for cluster-level types.", + ) + + feature = su.add_argument_group("feature") + feature.add_argument( + "--type", + required=True, + help="Feature type (e.g. allow-list). Drives feature_details validation.", + ) + feature.add_argument( + "--feature-details", + required=True, + dest="feature_details", + metavar="JSON", + help="JSON object with type-specific fields. allow-list requires 'ips'.", + ) + + status = su.add_argument_group("status") + status.add_argument( + "--status", + required=True, + choices=["REQUESTED", "IN_PROGRESS", "ACTIVE", "ERROR"], + help="Feature lifecycle status.", + ) + status_details_group = status.add_mutually_exclusive_group(required=True) + status_details_group.add_argument( + "--status-details", + default=None, + dest="status_details", + metavar="JSON", + help=( + "JSON object describing the outcome. " + "ACTIVE/REQUESTED/IN_PROGRESS: {message, request_configuration}. " + "ERROR: {message, error_code, error_source, request_configuration}. " + "request_configuration may be a single CIDR or a comma/space-separated list. " + "Mutually exclusive with --status-details-file." + ), + ) + status_details_group.add_argument( + "--status-details-file", + default=None, + dest="status_details_file", + metavar="FILE", + help=( + "Path to a JSON file containing the status-details object. " + "Useful for ERROR payloads whose message or stacktrace contains quotes " + "that would break inline shell quoting. " + "Mutually exclusive with --status-details." + ), + ) + + timestamps = su.add_argument_group("timestamps (all optional, default: now)") + timestamps.add_argument("--deployment-start", required=False, default=None, dest="deployment_start", metavar="ISO-8601") + timestamps.add_argument("--deployment-end", required=False, default=None, dest="deployment_end", metavar="ISO-8601") + timestamps.add_argument( + "--created-at", + required=False, + default=None, + dest="created_at", + metavar="ISO-8601", + help=( + "Timestamp recorded as the entry's creation time. " + "Only applied when the feature entry does not yet exist; " + "ignored on updates to preserve the original value." + ), + ) + timestamps.add_argument("--updated-at", required=False, default=None, dest="updated_at", metavar="ISO-8601") + + # ── get ─────────────────────────────────────────────────────────────────── + get = subparsers.add_parser( + "get", + help="Fetch and print a feature status entry by ObjectId or by criteria fields.", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "Retrieve a feature status entry using either its ObjectId (--id) or a\n" + "combination of criteria fields.\n\n" + "When --instance-id is supplied the lookup targets instance_level_config\n" + "and returns the matching entry from instance_level_features[].\n" + "When --instance-id is omitted the lookup targets cluster_level_config\n" + "and returns the matching entry from cluster_level_features[].\n\n" + "Examples:\n" + " # by ObjectId\n" + " mas-devops-feature-status-update get --id 6ab0e70ee6d3a31faa808547\n\n" + " # instance-level by criteria\n" + " mas-devops-feature-status-update get \\\n" + " --region us-east-2 \\\n" + " --instance-id inst02 \\\n" + " --account fyre-noble10-dev \\\n" + " --cluster noble10 \\\n" + " --subscription-id sub-id01 \\\n" + " --type allow-list\n\n" + " # cluster-level by criteria (no --instance-id)\n" + " mas-devops-feature-status-update get \\\n" + " --region us-east-2 \\\n" + " --account fyre-noble10-dev \\\n" + " --cluster noble10 \\\n" + " --type allow-list" + ), + ) + get_lookup = get.add_mutually_exclusive_group(required=True) + get_lookup.add_argument( + "--id", + default=None, + metavar="OBJECT_ID", + help="24-character hex ObjectId of the document (e.g. 6ab0e70ee6d3a31faa808547).", + ) + get_lookup.add_argument( + "--region", + default=None, + metavar="REGION", + help="AWS region (e.g. us-east-2). Use together with the other criteria flags.", + ) + get_criteria = get.add_argument_group("criteria (required when --region is used)") + get_criteria.add_argument( + "--instance-id", + dest="instance_id", + default=None, + metavar="INSTANCE_ID", + help="MAS instance ID. When supplied routes to instance_level_config; when omitted routes to cluster_level_config.", + ) + get_criteria.add_argument("--account", default=None, metavar="ACCOUNT", help="GitOps account name (e.g. fyre-noble10-dev)") + get_criteria.add_argument("--cluster", default=None, metavar="CLUSTER", help="GitOps cluster name (e.g. noble10)") + get_criteria.add_argument( + "--subscription-id", dest="subscription_id", default=None, metavar="SUBSCRIPTION_ID", help="Subscription ID (required when --instance-id is used)" + ) + get_criteria.add_argument("--type", default=None, metavar="TYPE", help="Feature type (e.g. allow-list)") + + return parser + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = build_parser() + args = parser.parse_args() + + log_level = getattr(logging, args.log_level) + logging.basicConfig(format="%(levelname)s %(name)s: %(message)s") + logging.getLogger("mas.devops.feature_status").setLevel(log_level) + + if args.command == "status-update": + sys.exit(cmd_status_update(args)) + elif args.command == "get": + sys.exit(cmd_get(args)) + else: + parser.print_help() + sys.exit(1) diff --git a/bin/mas-devops-feature-status-update.md b/bin/mas-devops-feature-status-update.md new file mode 100644 index 00000000..d1a8a961 --- /dev/null +++ b/bin/mas-devops-feature-status-update.md @@ -0,0 +1,925 @@ +# mas-devops-feature-status-update + +Writes MAS feature status records to the DevOps MongoDB (`mas_devops` database). +Routes writes to `instance_level_config` (instance-scoped) or `cluster_level_config` +(cluster-scoped) depending on whether `--instance-id` is supplied. + +## Database behaviour + +| Scenario | `mas_devops` database | Collections | Other collections | Data | +|---|---|---|---|---| +| `mas_devops` missing | ✅ Created automatically on first write | ✅ Created with validator + indexes | Not touched | N/A — fresh start | +| `mas_devops` exists | Not modified | Not modified | Not touched | N/A | +| Collections missing | N/A | ✅ Created with validator + indexes | Not touched | N/A — fresh start | +| Collections exist | Not modified | Not modified (validator + indexes unchanged) | Not touched | Existing docs updated in-place; `created_at` preserved; unrelated feature entries in array unchanged | + +## Prerequisites + +- Python 3 +- `pymongo` — `pip install pymongo` +- MongoDB 6+ (for local development — see [Local MongoDB](#local-mongodb)) + +## Local MongoDB + +Two options to run a local MongoDB instance for development and testing. + +### Option A — Docker (recommended) + +```bash +# Start a MongoDB 7 container, data persisted in a named volume +docker run -d \ + --name mongodb-local \ + -p 27017:27017 \ + -v mongodb-local-data:/data/db \ + mongo:7 + +# Verify it is running +docker ps --filter name=mongodb-local + +# Stop / restart +docker stop mongodb-local +docker start mongodb-local + +# Remove container and volume (destroys all data) +docker rm -f mongodb-local +docker volume rm mongodb-local-data +``` + +Connection URL: `mongodb://localhost:27017` + +### Option B — Homebrew (macOS) + +```bash +# Install +brew tap mongodb/brew +brew install mongodb-community + +# Start as a background service (auto-restarts on login) +brew services start mongodb-community + +# Or run in the foreground (current terminal only) +mongod --config /opt/homebrew/etc/mongod.conf + +# Stop +brew services stop mongodb-community +``` + +Connection URL: `mongodb://localhost:27017` + +--- + +### Initialize the `mas_devops` database + +No separate step is required. Once MongoDB is running and `DEVOPS_MONGO_URI` +is exported, the first `status-update` call automatically creates both +collections (with JSON Schema validators) and all required indexes. + +Verify the collections were created after the first write: + +```bash +mongosh "mongodb://localhost:27017/feature_dashboard" --eval "db.getCollectionNames()" +# Expected: [ 'cluster_level_config', 'instance_level_config' ] +``` + +### Set the connection URI + +Set `DEVOPS_MONGO_URI` — all commands read it automatically. Credentials and TLS options are embedded directly in the URI, matching the convention used across all other DevOps pipeline scripts: + +```bash +export DEVOPS_MONGO_URI='mongodb://user:password@host1:port1,host2:port2/admin?tls=true&tlsAllowInvalidCertificates=true' # pragma: allowlist secret +``` + +No separate verification step is needed — the first `status-update` call will +create required indexes automatically if they are absent. + +--- + +## Installation + +### From the package (recommended) + +Install the `mas-devops` package and the script is placed on `$PATH` automatically: + +```bash +# Install from PyPI +pip install mas-devops + +# Or install from source (editable) +git clone https://github.com/ibm-mas/python-devops.git +cd python-devops +pip install -e . +``` + +Once installed, run the script directly: + +```bash +mas-devops-feature-status-update [options] +``` + +### Run directly from source (without installing) + +```bash +# From the repository root +python bin/mas-devops-feature-status-update [options] + +# Or make the script executable and run it +chmod +x bin/mas-devops-feature-status-update +./bin/mas-devops-feature-status-update [options] +``` + +### Built-in help + +```bash +# Top-level help +mas-devops-feature-status-update --help + +# Sub-command help +mas-devops-feature-status-update status-update --help +mas-devops-feature-status-update get --help +``` + +--- + +## Sub-commands + +### `status-update` + +Upserts a feature status document. +Upsert key: `(region, instance_id, account, cluster, type)` — an existing document is updated in-place; a new document is inserted if no match is found. + +Required collection indexes (`instance_config_level`, `cluster_config_level`) are created automatically on the first call if they are absent — no separate setup step is needed. + +**Idempotency:** Safe to call multiple times with the same arguments. The underlying `find_one_and_update` with `upsert=True` guarantees that re-running with the same identity key produces the same final document state. `created_at` is set only on the first insert (`$setOnInsert`); subsequent calls update `updated_at` and all mutable fields without creating duplicate documents. + +**Identity options** + +| Flag | Required | Description | +|------|----------|-------------| +| `--region` | Yes | AWS region (e.g. `us-east-2`) | +| `--instance-id` | Instance-level types | MAS instance ID (e.g. `inst02`). When supplied routes to `instance_level_config`; when omitted routes to `cluster_level_config`. | +| `--account` | Yes | GitOps account name (e.g. `fyre-noble10-dev`) | +| `--cluster` | Yes | GitOps cluster name (e.g. `noble10`) | +| `--subscription-id` | Instance-level types | Subscription ID. Required when `--instance-id` is supplied; must be omitted for cluster-level feature types. | + +**Feature options** *(all required)* + +| Flag | Description | +|------|-------------| +| `--type` | Feature type (e.g. `allow-list`) | +| `--feature-details JSON` | Type-specific JSON payload. `allow-list` requires an `ips` array. | + +**Status options** *(all required)* + +| Flag | Description | +|------|-------------| +| `--status` | One of `REQUESTED`, `IN_PROGRESS`, `ACTIVE`, `ERROR` | +| `--status-details JSON` | JSON object describing the outcome (see schema below). Mutually exclusive with `--status-details-file`. | +| `--status-details-file FILE` | Path to a JSON file containing the status-details object. Use this for `ERROR` payloads whose `message` or `stacktrace` contains quote characters that would break inline shell interpolation. Mutually exclusive with `--status-details`. | + +**Timestamp options** *(all optional, default: current UTC time)* + +| Flag | Description | +|------|-------------| +| `--deployment-start ISO-8601` | Start of the deployment | +| `--deployment-end ISO-8601` | End of the deployment | +| `--created-at ISO-8601` | Overrides `created_at` on document insert only | +| `--updated-at ISO-8601` | Overrides `updated_at` | + +The MongoDB connection URI is read from the `DEVOPS_MONGO_URI` environment variable — no connection flags are needed on the command line. + +**`--status-details` schema** + +`request_configuration` is a free-form string describing what was requested — it may be a single CIDR, a comma-separated list, or a space-separated list. The validator does not enforce format. + +*REQUESTED* — pipeline has received the request but processing has not yet started. +```json +{ + "message": "Allow list request received.", + "request_configuration": "2405:201:d000:9062::/64" +} +``` + +*IN_PROGRESS* — pipeline is actively deploying the feature. +```json +{ + "message": "Allow list deployment in progress.", + "request_configuration": "2405:201:d000:9062::/64" +} +``` + +*ACTIVE* — deployment completed successfully. Multiple IPs may be comma-separated. +```json +{ + "message": "Allow list is active.", + "request_configuration": "1.2.3.4/32, 2405:201:d000:9062::/64" +} +``` + +*ERROR* — deployment failed. `error_source` fields are all optional except that the object itself is required. Use `--status-details-file` when `message` or `stacktrace` may contain quote characters. +```json +{ + "message": "sample error message", + "error_code": 401, + "error_source": { + "gitops_version": "8.6.0", + "filename": "cis_ip_allowlist.yml", + "log_file": "/var/log/gitops/run-001.log", + "stacktrace": "Traceback (most recent call last): ..." + }, + "request_configuration": "2405:201:d000:9060::/64" +} +``` + +**Examples** + +```bash +# REQUESTED status — record that a request has been received +mas-devops-feature-status-update status-update \ + --region us-east-2 \ + --instance-id inst02 \ + --account fyre-noble10-dev \ + --cluster noble10 \ + --subscription-id sub-id01 \ + --type allow-list \ + --feature-details '{"ips": ["2405:201:d000:9062::/64"]}' \ + --status REQUESTED \ + --status-details '{"message": "Allow list request received.", "request_configuration": "2405:201:d000:9062::/64"}' \ + --deployment-start 2026-09-11T11:48:42+00:00 + +# IN_PROGRESS status — record that deployment has started +mas-devops-feature-status-update status-update \ + --region us-east-2 \ + --instance-id inst02 \ + --account fyre-noble10-dev \ + --cluster noble10 \ + --subscription-id sub-id01 \ + --type allow-list \ + --feature-details '{"ips": ["2405:201:d000:9062::/64"]}' \ + --status IN_PROGRESS \ + --status-details '{"message": "Allow list deployment in progress.", "request_configuration": "2405:201:d000:9062::/64"}' \ + --deployment-start 2026-09-11T11:48:42+00:00 + +# ACTIVE status — single IP +mas-devops-feature-status-update status-update \ + --region us-east-2 \ + --instance-id inst02 \ + --account fyre-noble10-dev \ + --cluster noble10 \ + --subscription-id sub-id01 \ + --type allow-list \ + --feature-details '{"ips": ["2405:201:d000:9062::/64"]}' \ + --status ACTIVE \ + --status-details '{"message": "Allow list is active.", "request_configuration": "2405:201:d000:9062::/64"}' \ + --deployment-start 2026-09-11T11:48:42+00:00 \ + --deployment-end 2026-09-11T11:53:10+00:00 + +# ACTIVE status — multiple IPs (request_configuration is comma-separated) +mas-devops-feature-status-update status-update \ + --region us-east-2 \ + --instance-id inst02 \ + --account fyre-noble10-dev \ + --cluster noble10 \ + --subscription-id sub-id01 \ + --type allow-list \ + --feature-details '{"ips": ["1.2.3.4/32", "2405:201:d000:9062::/64"]}' \ + --status ACTIVE \ + --status-details '{"message": "Allow list is active.", "request_configuration": "1.2.3.4/32, 2405:201:d000:9062::/64"}' \ + --deployment-start 2026-09-11T11:48:42+00:00 \ + --deployment-end 2026-09-11T11:53:10+00:00 + +# ERROR status — use --status-details-file to avoid shell quoting issues with error text +cat > /tmp/status-details.json <<'EOF' +{ + "message": "sample error message", + "error_code": 401, + "error_source": { + "gitops_version": "8.6.0", + "filename": "cis_ip_allowlist.yml", + "log_file": "/var/log/gitops/run-001.log", + "stacktrace": "Traceback (most recent call last): ..." + }, + "request_configuration": "2405:201:d000:9060::/64" +} +EOF +mas-devops-feature-status-update status-update \ + --region us-east-2 \ + --instance-id inst02 \ + --account fyre-noble10-dev \ + --cluster noble10 \ + --subscription-id sub-id01 \ + --type allow-list \ + --feature-details '{"ips": ["2405:201:d000:9060::/64"]}' \ + --status ERROR \ + --status-details-file /tmp/status-details.json \ + --deployment-start 2026-09-11T11:48:42+00:00 \ + --deployment-end 2026-09-11T11:53:10+00:00 +``` + +--- + +### `get` + +Fetches a single feature status document and prints it as formatted JSON. + +Two mutually exclusive lookup modes are supported — exactly one must be provided: + +- **`--id`** — look up by ObjectId (the value printed by `status-update` on success). +- **`--region` + criteria flags** — look up by the document's identifying fields. + +**Idempotency:** Read-only. Safe to call any number of times with no side effects. + +**Arguments** + +| Argument | Required | Description | +|----------|----------|-------------| +| `--id OBJECT_ID` | One of `--id` / `--region` | 24-character hex ObjectId | +| `--region REGION` | One of `--id` / `--region` | AWS region (e.g. `us-east-2`). Enables criteria-based lookup | +| `--instance-id INSTANCE_ID` | Yes (criteria mode) | MAS instance ID (e.g. `inst02`) | +| `--account ACCOUNT` | Yes (criteria mode) | GitOps account name (e.g. `fyre-noble10-dev`) | +| `--cluster CLUSTER` | Yes (criteria mode) | GitOps cluster name (e.g. `noble10`) | +| `--subscription-id SUBSCRIPTION_ID` | Yes (criteria mode) | Subscription ID | +| `--type TYPE` | Yes (criteria mode) | Feature type (e.g. `allow-list`) | + +The MongoDB connection URI is read from `DEVOPS_MONGO_URI` — no connection flags are needed. + +**Example — by ObjectId** + +```bash +mas-devops-feature-status-update get \ + --id 6ab0e70ee6d3a31faa808547 +``` + +**Example — by criteria** + +```bash +mas-devops-feature-status-update get \ + --region us-east-2 \ + --instance-id inst02 \ + --account fyre-noble10-dev \ + --cluster noble10 \ + --subscription-id sub-id01 \ + --type allow-list +``` + +**Sample output** + +```json +{ + "_id": "", + "schema_version": 1, + "region": "us-east-2", + "instance_id": "inst02", + "account": "fyre-noble10-dev", + "cluster": "noble10", + "subscription_id": "sub-id01", + "type": "allow-list", + "feature_details": { "ips": ["2405:201:d000:9062::/64"] }, + "status": "ACTIVE", + "status_details": { "message": "Allow list is active.", "request_configuration": "2405:201:d000:9062::/64" }, + "deployment_start": "2026-09-11 11:48:42+00:00", + "deployment_end": "2026-09-11 11:53:10+00:00", + "created_at": "2026-09-11 11:48:42+00:00", + "updated_at": "2026-09-11 11:48:42+00:00" +} +``` + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DEVOPS_MONGO_URI` | Yes | Full MongoDB connection URI with embedded credentials and TLS options. | + +```bash +export DEVOPS_MONGO_URI='mongodb://user:password@host1:port1,host2:port2/admin?tls=true&tlsAllowInvalidCertificates=true' # pragma: allowlist secret +``` + +--- + +## Database Setup + +The `feature_dashboard` MongoDB database must be initialised before this tool can write records. It holds two collections: + +| Collection | Cardinality | +|---|---| +| `cluster_level_config` | One document per `account × region × cluster` | +| `instance_level_config` | One document per `subscription_id × account × region × cluster × instance` | + +### Initialize + +No separate step is required. The first `status-update` call automatically +creates both collections with strict JSON Schema validators and all required +indexes. The operation is idempotent — subsequent calls are no-ops. + +### Clear data (keep schema & indexes) + +```js +use feature_dashboard +db.cluster_level_config.deleteMany({}) +db.instance_level_config.deleteMany({}) +``` + +### Drop collections (removes schema & indexes) + +```js +use feature_dashboard +db.cluster_level_config.drop() +db.instance_level_config.drop() +``` + +### Drop collections (removes schema & indexes) with auth +``` +mongosh "mongodb://mas_devops_user:mas_devops_password@localhost:27017/mas_devops?authSource=mas_devops&tls=false&tlsAllowInvalidCertificates=true" \ # pragma: allowlist secret + --eval " +db.cluster_level_config.drop() +db.instance_level_config.drop() +print('collections dropped') +" +``` + +> **Note:** `drop()` removes the collection, all documents, and all indexes. They are recreated automatically on the next `status-update` call. + +### Indexes created automatically on first write + +**`cluster_level_config`** + +| Index name | Fields | Unique | +|---|---|---| +| `ux_cluster_level_config_account_region_cluster` | `account, region, cluster` | ✓ | +| `ix_cluster_level_config_account` | `account` | | + +**`instance_level_config`** + +| Index name | Fields | Unique | +|---|---|---| +| `ux_instance_level_config_sub_account_region_cluster_instance` | `subscription_id, account, region, cluster, instance` | ✓ | +| `ix_instance_level_config_sub_account_region_cluster` | `subscription_id, account, region, cluster` | | +| `ix_instance_level_config_feature_status` | `instance_level_features.status` | | +| `ix_instance_level_config_error_code` | `instance_level_features.status_details.error_code` (sparse) | | + +### Validation behaviour + +Both collections enforce: + +```js +validationLevel: "strict" // enforced on inserts AND updates +validationAction: "error" // rejects non-conforming writes outright +``` + +`additionalProperties: false` is set on every top-level and nested object (except `cluster_level_features[]` items, which allow extension fields). + +--- + +## MongoDB Document Schema + +### `mas_devops.instance_level_config` + +One document per `(subscription_id × account × region × cluster × instance)`. +Feature entries are embedded in the `instance_level_features[]` array. + +```json +{ + "_id": "", + "subscription_id": "sub-id01", + "account": "fyre-noble10-dev", + "region": "us-east-2", + "cluster": "noble10", + "instance": "inst02", + "instance_level_features": [ + { + "type": "allow-list", + "feature_details": { "ips": ["2405:201:d000:9062::/64"] }, + "status": "ACTIVE", + "status_details": { "message": "Allow list is active.", "request_configuration": "2405:201:d000:9062::/64" }, + "deployment_start": "", + "deployment_end": "", + "source": "ansible_devops", + "created_at": "", + "updated_at": "" + } + ], + "created_at": "", + "updated_at": "" +} +``` + +### `mas_devops.cluster_level_config` + +One document per `(account × region × cluster)`. +Feature entries are embedded in the `cluster_level_features[]` array. + +```json +{ + "_id": "", + "account": "fyre-noble10-dev", + "region": "us-east-2", + "cluster": "noble10", + "cluster_level_features": [ + { + "type": "", + "feature_details": {}, + "status": "ACTIVE", + "status_details": {}, + "deployment_start": "", + "deployment_end": "", + "source": "ansible_devops", + "created_at": "", + "updated_at": "" + } + ], + "created_at": "", + "updated_at": "" +} +``` + +--- + +## Global Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--log-level` | `WARNING` | Python logging level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | + +--- + +## Ansible Integration + +See the full sample playbook at [`playbooks/feature-status-update.yml`](../playbooks/feature-status-update.yml). + +### Minimal task — `status-update` + +```yaml +- name: Upsert feature status (ACTIVE) + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update status-update + --region {{ mas_region }} + --instance-id {{ mas_instance_id }} + --account {{ mas_account }} + --cluster {{ mas_cluster }} + --subscription-id {{ mas_subscription_id }} + --type allow-list + --feature-details {{ '{"ips": ["2405:201:d000:9062::/64"]}' | quote }} + --status ACTIVE + --status-details {{ '{"message": "Allow list is active.", "request_configuration": "2405:201:d000:9062::/64"}' | quote }} + register: status_update_result + changed_when: "'written successfully' in status_update_result.stdout" +``` + +For `ERROR` status, write the payload to a file first to avoid shell quoting problems with error text: + +```yaml +- name: Write ERROR status-details to file + ansible.builtin.copy: + dest: /tmp/mas-status-details.json + content: | + { + "message": "{{ _error_msg | replace('\\', '\\\\') | replace('"', '\\"') }}", + "error_code": {{ _error_code }}, + "error_source": { + "gitops_version": "{{ lookup('env', 'GITOPS_VERSION') | default('', true) }}", + "filename": "{{ _error_filename }}", + "log_file": "{{ lookup('env', 'JUNIT_OUTPUT_DIR') | default('/var/log/gitops', true) }}/run.log", + "stacktrace": "{{ _error_msg | replace('\\', '\\\\') | replace('"', '\\"') }}" + }, + "request_configuration": "{{ _request_configuration }}" + } + +- name: Upsert feature status (ERROR) + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update status-update + --region {{ mas_region }} + --instance-id {{ mas_instance_id }} + --account {{ mas_account }} + --cluster {{ mas_cluster }} + --subscription-id {{ mas_subscription_id }} + --type allow-list + --feature-details {{ ('{"ips": ' + _normalised_ips | to_json + '}') | quote }} + --status ERROR + --status-details-file /tmp/mas-status-details.json + --deployment-start {{ _deployment_start }} + --deployment-end {{ _deployment_end }} + register: status_update_result + changed_when: "'written successfully' in status_update_result.stdout" +``` + +### Minimal task — `get` + +**By ObjectId** — extract the document ID from `status-update` output and fetch the written document: + +```yaml +- name: Extract document ID + ansible.builtin.set_fact: + mas_document_id: >- + {{ status_update_result.stdout + | regex_search('Document ID: ([a-f0-9]{24})', '\1') + | first }} + +- name: Fetch feature status document by ID + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update get + --id {{ mas_document_id }} + register: get_result + changed_when: false + +- name: Display document + ansible.builtin.debug: + msg: "{{ get_result.stdout | from_json }}" +``` + +**By criteria** — look up the document without needing to capture an ObjectId first: + +```yaml +- name: Fetch feature status document by criteria + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update get + --region {{ mas_region }} + --instance-id {{ mas_instance_id }} + --account {{ mas_account }} + --cluster {{ mas_cluster }} + --subscription-id {{ mas_subscription_id }} + --type allow-list + register: get_result + changed_when: false + +- name: Display document + ansible.builtin.debug: + msg: "{{ get_result.stdout | from_json }}" +``` + +### Using `DEVOPS_MONGO_URI` + +Set `DEVOPS_MONGO_URI` once (e.g. in `group_vars/all.yml` or a `block` `environment:`). All sub-commands read it automatically — no connection flag is required on any task: + +```yaml +- name: Feature status tasks + environment: + DEVOPS_MONGO_URI: "mongodb://{{ mas_mongo_user }}:{{ mas_mongo_password }}@{{ mas_mongo_host }}:{{ mas_mongo_port }}/admin?tls=true&tlsAllowInvalidCertificates=true" # pragma: allowlist secret + block: + - name: status-update + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update status-update + --region us-east-2 + --instance-id inst02 + --account fyre-noble10-dev + --cluster noble10 + --subscription-id sub-id01 + --type allow-list + --feature-details '{"ips": ["2405:201:d000:9062::/64"]}' + --status ACTIVE + --status-details '{"message": "Allow list is active.", "request_configuration": "2405:201:d000:9062::/64"}' +``` + +--- + +## MongoDB Query Reference + +Every query is a standalone `mongosh` command — replace `mongodb://localhost:27017` with your connection URL. + +--- + +### `instance_level_config` queries + +#### Fetch a specific instance document (full) + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.findOne({ + subscription_id: "sub-id01", + account: "fyre-noble10-dev", + region: "us-east-2", + cluster: "noble10", + instance: "inst02" + })' +``` + +#### Fetch just the feature entries for an instance + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.findOne( + { + subscription_id: "sub-id01", + account: "fyre-noble10-dev", + region: "us-east-2", + cluster: "noble10", + instance: "inst02" + }, + { _id: 0, instance_level_features: 1 } + )' +``` + +#### Fetch a single feature entry for an instance (`$elemMatch`) + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.findOne( + { + subscription_id: "sub-id01", + account: "fyre-noble10-dev", + region: "us-east-2", + cluster: "noble10", + instance: "inst02" + }, + { _id: 0, instance_level_features: { $elemMatch: { type: "allow-list" } } } + )' +``` + +#### All instances for an account + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { account: "fyre-noble10-dev" }, + { _id: 0, region: 1, cluster: 1, instance: 1, subscription_id: 1 } + ).sort({ cluster: 1, instance: 1 }).pretty()' +``` + +#### All instances in a cluster + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { account: "fyre-noble10-dev", region: "us-east-2", cluster: "noble10" }, + { _id: 0, instance: 1, subscription_id: 1 } + ).pretty()' +``` + +#### All instances that have an ACTIVE allow-list feature + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { + "instance_level_features": { + $elemMatch: { type: "allow-list", status: "ACTIVE" } + } + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } + ).pretty()' +``` + +#### All instances with an IN_PROGRESS or REQUESTED feature + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { + "instance_level_features.status": { $in: ["REQUESTED", "IN_PROGRESS"] } + }, + { _id: 0, account: 1, cluster: 1, instance: 1, + instance_level_features: { + $elemMatch: { status: { $in: ["REQUESTED", "IN_PROGRESS"] } } + } + } + ).sort({ updated_at: 1 }).pretty()' +``` + +#### All instances with an ERROR feature + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { "instance_level_features.status": "ERROR" }, + { _id: 0, account: 1, cluster: 1, instance: 1, + instance_level_features: { $elemMatch: { status: "ERROR" } } + } + ).pretty()' +``` + +#### ERROR features with a specific error code + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { + "instance_level_features": { + $elemMatch: { status: "ERROR", "status_details.error_code": 401 } + } + }, + { _id: 0, account: 1, cluster: 1, instance: 1, + instance_level_features: { + $elemMatch: { status: "ERROR", "status_details.error_code": 401 } + } + } + ).pretty()' +``` + +#### Instances whose allow-list contains a specific IP/CIDR + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { + "instance_level_features": { + $elemMatch: { + type: "allow-list", + "feature_details.ips": "2405:201:d000:9062::/64" + } + } + }, + { _id: 0, account: 1, cluster: 1, instance: 1 } + ).pretty()' +``` + +#### Documents updated in the last 24 hours + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.find( + { updated_at: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } } + ).sort({ updated_at: -1 }).pretty()' +``` + +#### Most recently updated instance documents (last 10) + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.instance_level_config.find().sort({ updated_at: -1 }).limit(10).pretty()' +``` + +#### Count instances per cluster + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.aggregate([ + { $group: { _id: { account: "$account", region: "$region", cluster: "$cluster" }, + count: { $sum: 1 } } }, + { $sort: { "_id.account": 1, "_id.cluster": 1 } } + ])' +``` + +#### Count feature entries grouped by status (across all instances) + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.instance_level_config.aggregate([ + { $unwind: "$instance_level_features" }, + { $group: { _id: "$instance_level_features.status", count: { $sum: 1 } } }, + { $sort: { count: -1 } } + ])' +``` + +#### Distinct accounts + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.instance_level_config.distinct("account")' +``` + +#### Distinct clusters for a region + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.instance_level_config.distinct("cluster", { region: "us-east-2" })' +``` + +--- + +### `cluster_level_config` queries + +#### Fetch a specific cluster document + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.cluster_level_config.findOne({ + account: "fyre-noble10-dev", + region: "us-east-2", + cluster: "noble10" + })' +``` + +#### All clusters for an account + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.cluster_level_config.find( + { account: "fyre-noble10-dev" }, + { _id: 0, region: 1, cluster: 1 } + ).sort({ region: 1, cluster: 1 }).pretty()' +``` + +#### Clusters that have at least one cluster-level feature + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.cluster_level_config.find( + { "cluster_level_features.0": { $exists: true } }, + { _id: 0, account: 1, region: 1, cluster: 1 } + ).pretty()' +``` + +#### Count cluster documents per account + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.cluster_level_config.aggregate([ + { $group: { _id: "$account", count: { $sum: 1 } } }, + { $sort: { count: -1 } } + ])' +``` diff --git a/setup.py b/setup.py index bb007830..6e4df9fe 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ def get_version(rel_path): "boto3", # Apache Software License "slack_sdk", # MIT License "packaging", # Apache Software License + "pymongo", # Apache Software License "cryptography", # Apache Software License — used by mas.devops.github for GHE App JWT auth ], extras_require={ @@ -94,5 +95,6 @@ def get_version(rel_path): "bin/mas-devops-saas-job-cleaner", "bin/mas-devops-notify-slack", "bin/mas-devops-apply-preinstall-rbac-for-saas", + "bin/mas-devops-feature-status-update", ], ) diff --git a/src/mas/devops/feature_status.py b/src/mas/devops/feature_status.py new file mode 100644 index 00000000..ac6fabfc --- /dev/null +++ b/src/mas/devops/feature_status.py @@ -0,0 +1,778 @@ +# ***************************************************************************** +# Copyright (c) 2025 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** +""" +feature_status.py — Write feature status records into the DevOps MongoDB. + +Database: mas_devops +Collections: + instance_level_config — one document per (subscription_id × + account × region × cluster × instance). + Feature entries are embedded in instance_level_features[]. + Used when --instance-id is supplied. + + cluster_level_config — one document per (account × region × cluster). + Feature entries are embedded in cluster_level_features[]. + Used when --instance-id is omitted. + +Document schema — instance_level_config top-level: + + { + "_id": , + "subscription_id": str, + "account": str, + "region": str, + "cluster": str, + "instance": str, + "instance_level_features": [ + { + "type": str, # e.g. "allow-list" + "feature_details": dict, # type-specific payload + "status": str, # REQUESTED | IN_PROGRESS | ACTIVE | ERROR + "status_details": dict, # message, error_code, error_source, … + "deployment_start": str, # ISO-8601 + "deployment_end": str|None, # ISO-8601 + "source": str, # "ansible_devops" + "created_at": datetime, + "updated_at": datetime, + }, + … + ], + "created_at": datetime, + "updated_at": datetime, + } + +Document schema — cluster_level_config top-level: + + { + "_id": , + "account": str, + "region": str, + "cluster": str, + "cluster_level_features": [ + { + "type": str, + "feature_details": dict, + "status": str, + "status_details": dict, + "deployment_start": str, + "deployment_end": str|None, + "source": str, + "created_at": datetime, + "updated_at": datetime, + }, + … + ], + "created_at": datetime, + "updated_at": datetime, + } +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Optional + +from pymongo import MongoClient # type: ignore + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DATABASE = "mas_devops" +COLLECTION_INSTANCE = "instance_level_config" +COLLECTION_CLUSTER = "cluster_level_config" + +# Source tag written by this CLI tool into every feature entry. +FEATURE_SOURCE = "ansible_devops" + +# Status enum values +STATUS_REQUESTED = "REQUESTED" +STATUS_IN_PROGRESS = "IN_PROGRESS" +STATUS_ACTIVE = "ACTIVE" +STATUS_ERROR = "ERROR" + +VALID_STATUSES = {STATUS_REQUESTED, STATUS_IN_PROGRESS, STATUS_ACTIVE, STATUS_ERROR} + +# Feature-level constants +INSTANCE_LEVEL = "INSTANCE_LEVEL" +CLUSTER_LEVEL = "CLUSTER_LEVEL" + +# --------------------------------------------------------------------------- +# Feature type → level map +# +# Declares which collection a feature type belongs to. Add new feature types +# here; the routing logic in the CLI and the library functions will pick it up +# automatically. +# +# INSTANCE_LEVEL → mas_devops.instance_level_config (requires --instance-id) +# CLUSTER_LEVEL → mas_devops.cluster_level_config (no --instance-id needed) +# --------------------------------------------------------------------------- + +FEATURE_LEVEL_MAP: dict[str, str] = { + "allow-list": INSTANCE_LEVEL, +} + + +def get_feature_level(feature_type: str) -> str: + """Return the level constant (INSTANCE_LEVEL or CLUSTER_LEVEL) for *feature_type*. + + Raises: + ValueError: if *feature_type* is not registered in FEATURE_LEVEL_MAP. + """ + level = FEATURE_LEVEL_MAP.get(feature_type) + if level is None: + known = sorted(FEATURE_LEVEL_MAP.keys()) + raise ValueError(f"Unknown feature type '{feature_type}'. " f"Known types: {known}. " f"Add it to FEATURE_LEVEL_MAP in feature_status.py.") + return level + + +# --------------------------------------------------------------------------- +# Per-type feature_details validators +# --------------------------------------------------------------------------- + +_FEATURE_DETAILS_REQUIRED_FIELDS: dict[str, set[str]] = { + "allow-list": {"ips"}, +} + + +def validate_feature_details(feature_type: str, feature_details: dict) -> None: + """Validate that *feature_details* contains the required keys for *feature_type*. + + Raises: + ValueError: if required keys are missing or feature_details is not a dict. + """ + if not isinstance(feature_details, dict): + raise ValueError(f"feature_details must be a JSON object, got {type(feature_details).__name__}") + + required = _FEATURE_DETAILS_REQUIRED_FIELDS.get(feature_type) + if required is None: + logger.warning("No feature_details validation rules defined for type '%s'", feature_type) + return + + missing = required - set(feature_details.keys()) + if missing: + raise ValueError(f"feature_details is missing required field(s) for type '{feature_type}': {sorted(missing)}") + + +# --------------------------------------------------------------------------- +# Per-type status_details validators +# --------------------------------------------------------------------------- + +_STATUS_DETAILS_REQUIRED_FIELDS: dict[str, dict[str, set[str]]] = { + "allow-list": { + "ACTIVE": {"message", "request_configuration"}, + "ERROR": {"message", "error_code", "error_source", "request_configuration"}, + } +} + + +def validate_status_details(feature_type: str, status: str, status_details: dict) -> None: + """Validate that *status_details* contains the required keys for *feature_type* and *status*. + + Raises: + ValueError: if required keys are missing or status_details is not a dict. + """ + if not isinstance(status_details, dict): + raise ValueError(f"status_details must be a JSON object, got {type(status_details).__name__}") + + required = _STATUS_DETAILS_REQUIRED_FIELDS.get(feature_type, {}).get(status) + if required is None: + logger.warning( + "No status_details validation rules defined for type='%s' status='%s'", + feature_type, + status, + ) + return + + missing = required - set(status_details.keys()) + if missing: + raise ValueError(f"status_details is missing required field(s) for " f"type='{feature_type}' status='{status}': {sorted(missing)}") + + +# --------------------------------------------------------------------------- +# MongoDB helpers +# --------------------------------------------------------------------------- + +# JSON Schema validators — mirrors the schemas previously in mongodb_schemas/*.js, +# kept here so no external JS files or mongosh are required for bootstrapping. + +_VALIDATOR_CLUSTER = { + "$jsonSchema": { + "bsonType": "object", + "required": ["_id", "account", "region", "cluster", "cluster_level_features", "created_at", "updated_at"], + "additionalProperties": False, + "properties": { + "_id": {"bsonType": "objectId"}, + "account": {"bsonType": "string"}, + "region": {"bsonType": "string"}, + "cluster": {"bsonType": "string"}, + "cluster_level_features": {"bsonType": "array", "minItems": 0, "items": {"bsonType": "object"}}, + "created_at": {"bsonType": "date"}, + "updated_at": {"bsonType": "date"}, + }, + } +} + +_VALIDATOR_INSTANCE = { + "$jsonSchema": { + "bsonType": "object", + "required": [ + "_id", + "subscription_id", + "account", + "region", + "cluster", + "instance", + "instance_level_features", + "created_at", + "updated_at", + ], + "additionalProperties": False, + "properties": { + "_id": {"bsonType": "objectId"}, + "subscription_id": {"bsonType": "string"}, + "account": {"bsonType": "string"}, + "region": {"bsonType": "string"}, + "cluster": {"bsonType": "string"}, + "instance": {"bsonType": "string"}, + "instance_level_features": { + "bsonType": "array", + "minItems": 0, + "items": { + "bsonType": "object", + "required": ["type", "feature_details", "status", "source", "created_at", "updated_at"], + "additionalProperties": False, + "properties": { + "type": {"bsonType": "string", "enum": ["allow-list"]}, + "feature_details": { + "bsonType": "object", + "required": ["ips"], + "additionalProperties": False, + "properties": { + "ips": { + "bsonType": "array", + "minItems": 1, + "items": {"bsonType": "string"}, + } + }, + }, + "status": { + "bsonType": "string", + "enum": ["REQUESTED", "IN_PROGRESS", "ACTIVE", "ERROR"], + }, + "status_details": { + "bsonType": "object", + "additionalProperties": False, + "properties": { + "message": {"bsonType": "string"}, + "error_code": {"bsonType": "int"}, + "error_source": { + "bsonType": "object", + "additionalProperties": False, + "properties": { + "gitops_version": {"bsonType": "string"}, + "filename": {"bsonType": "string"}, + "line_no": {"bsonType": "int"}, + "log_file": {"bsonType": "string"}, + "stacktrace": {"bsonType": "string"}, + }, + }, + "request_configuration": {"bsonType": "string"}, + }, + }, + "deployment_start": {"bsonType": "string"}, + "deployment_end": {"bsonType": "string"}, + "source": { + "bsonType": "string", + "enum": ["ansible_devops", "github_webhook", "cluster_poll", "admin_ui"], + }, + "created_at": {"bsonType": "date"}, + "updated_at": {"bsonType": "date"}, + }, + }, + }, + "created_at": {"bsonType": "date"}, + "updated_at": {"bsonType": "date"}, + }, + } +} + + +def bootstrap_collections(mongo_url: str) -> None: + """Create both collections with JSON Schema validators (idempotent). + + Safe to call against a database where the collections already exist — + ``CollectionInvalid`` is caught and logged as INFO so repeated calls + are no-ops. Called automatically by ``create_indexes()`` before every + write operation. + """ + from pymongo.errors import CollectionInvalid # type: ignore + + client = MongoClient(mongo_url) + try: + db = client[DATABASE] + for name, validator in ( + (COLLECTION_CLUSTER, _VALIDATOR_CLUSTER), + (COLLECTION_INSTANCE, _VALIDATOR_INSTANCE), + ): + try: + db.create_collection( + name, + validator=validator, + validationLevel="strict", + validationAction="error", + ) + logger.info("Collection '%s' created with JSON Schema validator.", name) + except CollectionInvalid: + logger.info("Collection '%s' already exists — skipping creation.", name) + finally: + client.close() + + +def create_indexes(mongo_url: str) -> None: + """Bootstrap collections (idempotent) then create all required indexes.""" + from pymongo import ASCENDING # type: ignore + + bootstrap_collections(mongo_url) + + client = MongoClient(mongo_url) + try: + db = client[DATABASE] + + # ── instance_level_config ──────────────────────────────────────────── + inst = db[COLLECTION_INSTANCE] + + inst.create_index( + [ + ("subscription_id", ASCENDING), + ("account", ASCENDING), + ("region", ASCENDING), + ("cluster", ASCENDING), + ("instance", ASCENDING), + ], + unique=True, + name="ux_instance_level_config_sub_account_region_cluster_instance", + ) + logger.info("Index 'ux_instance_level_config_sub_account_region_cluster_instance' ensured on %s.%s", DATABASE, COLLECTION_INSTANCE) + + inst.create_index( + [ + ("subscription_id", ASCENDING), + ("account", ASCENDING), + ("region", ASCENDING), + ("cluster", ASCENDING), + ], + name="ix_instance_level_config_sub_account_region_cluster", + ) + logger.info("Index 'ix_instance_level_config_sub_account_region_cluster' ensured on %s.%s", DATABASE, COLLECTION_INSTANCE) + + inst.create_index( + [("instance_level_features.status", ASCENDING)], + name="ix_instance_level_config_feature_status", + ) + logger.info("Index 'ix_instance_level_config_feature_status' ensured on %s.%s", DATABASE, COLLECTION_INSTANCE) + + inst.create_index( + [("instance_level_features.status_details.error_code", ASCENDING)], + sparse=True, + name="ix_instance_level_config_error_code", + ) + logger.info("Index 'ix_instance_level_config_error_code' ensured on %s.%s", DATABASE, COLLECTION_INSTANCE) + + # ── cluster_level_config ───────────────────────────────────────────── + clst = db[COLLECTION_CLUSTER] + + clst.create_index( + [ + ("account", ASCENDING), + ("region", ASCENDING), + ("cluster", ASCENDING), + ], + unique=True, + name="ux_cluster_level_config_account_region_cluster", + ) + logger.info("Index 'ux_cluster_level_config_account_region_cluster' ensured on %s.%s", DATABASE, COLLECTION_CLUSTER) + + clst.create_index( + [("account", ASCENDING)], + name="ix_cluster_level_config_account", + ) + logger.info("Index 'ix_cluster_level_config_account' ensured on %s.%s", DATABASE, COLLECTION_CLUSTER) + + finally: + client.close() + + +# --------------------------------------------------------------------------- +# Write helpers — shared feature-entry builder +# --------------------------------------------------------------------------- + + +def _build_feature_entry( + feature_type: str, + feature_details: dict, + status: str, + status_details: dict, + deployment_start: Optional[datetime], + deployment_end: Optional[datetime], + now: datetime, + created_at: Optional[datetime], + updated_at: Optional[datetime], +) -> dict: + """Build a single feature entry dict for embedding in the features array.""" + entry = { + "type": feature_type, + "feature_details": feature_details, + "status": status, + "status_details": status_details, + "deployment_start": (deployment_start or now).isoformat(), + "source": FEATURE_SOURCE, + "created_at": created_at or now, + "updated_at": updated_at or now, + } + # deployment_end is omitted entirely when not yet known (REQUESTED/IN_PROGRESS). + # Writing null would violate the JSON schema (bsonType: "string"). + if deployment_end is not None: + entry["deployment_end"] = deployment_end.isoformat() + return entry + + +# --------------------------------------------------------------------------- +# instance_level_config — upsert +# --------------------------------------------------------------------------- + + +def upsert_instance_feature( + mongo_url: str, + *, + subscription_id: str, + region: str, + account: str, + cluster: str, + instance: str, + feature_type: str, + feature_details: dict, + status: str, + status_details: dict, + deployment_start: Optional[datetime] = None, + deployment_end: Optional[datetime] = None, + created_at: Optional[datetime] = None, + updated_at: Optional[datetime] = None, +) -> str: + """Upsert a feature entry inside instance_level_config. + + The parent document is identified by + (subscription_id, account, region, cluster, instance). + If a feature entry with the same *type* already exists it is updated + in-place via a single atomic find_one_and_update with arrayFilters; + otherwise the entry is appended (with parent upsert if needed). + + Returns the parent document _id as a string. + """ + from pymongo import ReturnDocument # type: ignore + + if status not in VALID_STATUSES: + raise ValueError(f"Invalid status '{status}'. Must be one of {sorted(VALID_STATUSES)}") + validate_feature_details(feature_type, feature_details) + + now = datetime.now(timezone.utc) + entry = _build_feature_entry(feature_type, feature_details, status, status_details, deployment_start, deployment_end, now, created_at, updated_at) + + parent_filter = { + "subscription_id": subscription_id, + "account": account, + "region": region, + "cluster": cluster, + "instance": instance, + } + + client = MongoClient(mongo_url) + try: + collection = client[DATABASE][COLLECTION_INSTANCE] + + # Step 1 — attempt an atomic in-place update of an existing feature entry. + # Matches only when the parent document AND a feature entry with this type exist. + result = collection.find_one_and_update( + {**parent_filter, "instance_level_features.type": feature_type}, + { + "$set": { + "updated_at": updated_at or now, + **{f"instance_level_features.$[elem].{k}": v for k, v in entry.items() if k != "created_at"}, + } + }, + array_filters=[{"elem.type": feature_type}], + return_document=ReturnDocument.AFTER, + ) + + if result is None: + # No existing feature entry for this type — warn if --created-at would be discarded + # on a subsequent call, then append (upsert parent if it doesn't exist yet). + if created_at is not None: + logger.warning( + "--created-at is only applied on the initial insert of a feature entry " + "(type=%s). It is ignored when updating an existing entry to preserve " + "the original created_at.", + feature_type, + ) + result = collection.find_one_and_update( + parent_filter, + { + "$setOnInsert": { + **parent_filter, + "created_at": created_at or now, + }, + "$push": {"instance_level_features": entry}, + "$set": {"updated_at": updated_at or now}, + }, + upsert=True, + return_document=ReturnDocument.AFTER, + ) + else: + if created_at is not None: + logger.warning( + "--created-at is ignored when updating an existing feature entry " "(type=%s). The original created_at is preserved.", + feature_type, + ) + + doc_id = str(result["_id"]) + logger.info( + "Instance feature upserted [%s / %s / %s] status=%s id=%s", + account, + instance, + feature_type, + status, + doc_id, + ) + return doc_id + finally: + client.close() + + +# --------------------------------------------------------------------------- +# cluster_level_config — upsert +# --------------------------------------------------------------------------- + + +def upsert_cluster_feature( + mongo_url: str, + *, + region: str, + account: str, + cluster: str, + feature_type: str, + feature_details: dict, + status: str, + status_details: dict, + deployment_start: Optional[datetime] = None, + deployment_end: Optional[datetime] = None, + created_at: Optional[datetime] = None, + updated_at: Optional[datetime] = None, +) -> str: + """Upsert a feature entry inside cluster_level_config. + + The parent document is identified by (account, region, cluster). + Same atomic two-step pattern as upsert_instance_feature. + + Returns the parent document _id as a string. + """ + from pymongo import ReturnDocument # type: ignore + + if status not in VALID_STATUSES: + raise ValueError(f"Invalid status '{status}'. Must be one of {sorted(VALID_STATUSES)}") + validate_feature_details(feature_type, feature_details) + + now = datetime.now(timezone.utc) + entry = _build_feature_entry(feature_type, feature_details, status, status_details, deployment_start, deployment_end, now, created_at, updated_at) + + parent_filter = { + "account": account, + "region": region, + "cluster": cluster, + } + + client = MongoClient(mongo_url) + try: + collection = client[DATABASE][COLLECTION_CLUSTER] + + # Step 1 — attempt an atomic in-place update of an existing feature entry. + # Matches only when the parent document AND a feature entry with this type exist. + result = collection.find_one_and_update( + {**parent_filter, "cluster_level_features.type": feature_type}, + { + "$set": { + "updated_at": updated_at or now, + **{f"cluster_level_features.$[elem].{k}": v for k, v in entry.items() if k != "created_at"}, + } + }, + array_filters=[{"elem.type": feature_type}], + return_document=ReturnDocument.AFTER, + ) + + if result is None: + # No existing feature entry for this type — append (upsert parent if needed). + if created_at is not None: + logger.warning( + "--created-at is only applied on the initial insert of a feature entry " + "(type=%s). It is ignored when updating an existing entry to preserve " + "the original created_at.", + feature_type, + ) + result = collection.find_one_and_update( + parent_filter, + { + "$setOnInsert": { + **parent_filter, + "created_at": created_at or now, + }, + "$push": {"cluster_level_features": entry}, + "$set": {"updated_at": updated_at or now}, + }, + upsert=True, + return_document=ReturnDocument.AFTER, + ) + else: + if created_at is not None: + logger.warning( + "--created-at is ignored when updating an existing feature entry " "(type=%s). The original created_at is preserved.", + feature_type, + ) + + doc_id = str(result["_id"]) + logger.info( + "Cluster feature upserted [%s / %s / %s] status=%s id=%s", + account, + cluster, + feature_type, + status, + doc_id, + ) + return doc_id + finally: + client.close() + + +# --------------------------------------------------------------------------- +# get helpers +# --------------------------------------------------------------------------- + + +def get_feature_status_by_id(mongo_url: str, doc_id: str) -> Optional[dict]: + """Fetch a parent document by its ObjectId from either collection. + + Tries instance_level_config first, then cluster_level_config. + + Returns: + dict with ``_id`` serialised to a string, or None if not found in either collection. + + Raises: + ValueError: if *doc_id* is not a valid 24-character hex ObjectId. + """ + try: + from bson import ObjectId + from bson.errors import InvalidId + except ImportError as exc: # pragma: no cover + raise ImportError("pymongo is required. Install it with: pip install pymongo") from exc + + try: + oid = ObjectId(doc_id) + except InvalidId: + raise ValueError(f"'{doc_id}' is not a valid ObjectId (expected a 24-character hex string)") + + client = MongoClient(mongo_url) + try: + for col_name in (COLLECTION_INSTANCE, COLLECTION_CLUSTER): + doc = client[DATABASE][col_name].find_one({"_id": oid}) + if doc is not None: + doc["_id"] = str(doc["_id"]) + return doc + return None + finally: + client.close() + + +def get_instance_feature_by_criteria( + mongo_url: str, + *, + region: str, + instance_id: str, + account: str, + cluster: str, + subscription_id: str, + feature_type: str, +) -> Optional[dict]: + """Fetch the feature entry for *feature_type* from instance_level_config. + + Returns the matching feature entry dict (not the full parent document), + or None if the parent or the feature entry does not exist. + """ + client = MongoClient(mongo_url) + try: + filter_doc = { + "subscription_id": subscription_id, + "account": account, + "region": region, + "cluster": cluster, + "instance": instance_id, + } + doc = client[DATABASE][COLLECTION_INSTANCE].find_one(filter_doc) + if doc is None: + return None + for entry in doc.get("instance_level_features", []): + if entry.get("type") == feature_type: + return entry + return None + finally: + client.close() + + +def get_cluster_feature_by_criteria( + mongo_url: str, + *, + region: str, + account: str, + cluster: str, + feature_type: str, +) -> Optional[dict]: + """Fetch the feature entry for *feature_type* from cluster_level_config. + + Returns the matching feature entry dict (not the full parent document), + or None if the parent or the feature entry does not exist. + """ + client = MongoClient(mongo_url) + try: + filter_doc = { + "account": account, + "region": region, + "cluster": cluster, + } + doc = client[DATABASE][COLLECTION_CLUSTER].find_one(filter_doc) + if doc is None: + return None + for entry in doc.get("cluster_level_features", []): + if entry.get("type") == feature_type: + return entry + return None + finally: + client.close() + + +# --------------------------------------------------------------------------- +# URL redaction helper (keeps passwords out of logs) +# --------------------------------------------------------------------------- + + +def _redact_url(url: str) -> str: + """Replace the password component of a MongoDB connection URI with *****.""" + import re + + return re.sub(r"(mongodb(?:\+srv)?://[^:]+:)[^@]+(@)", r"\1*****\2", url)