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..97887013 --- /dev/null +++ b/bin/mas-devops-feature-status-update @@ -0,0 +1,484 @@ +#!/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.feature_status collection). + +Sub-commands +──────────── + + prep + Verify MongoDB connectivity and confirm that the required indexes + (instance_config_level, cluster_config_level) exist on the collection. + Pass --create-indexes to create them when absent. + + Example: + mas-devops-feature-status-update prep \\ + --db-details '{"url": "mongodb://:27017", "credentials": {"username": ""}}' #pragma: allowlist secret + + mas-devops-feature-status-update prep --db-url mongodb://host:27017 --create-indexes + + + status-update + Upsert a feature status document. + Upsert key: (region, instance_id, account, cluster, type). + + Example — ACTIVE: + 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 — ERROR: + 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 ERROR \\ + --status-details '{ + "message": "sample error message", + "error_code": 401, + "error_source": { + "gitops_version": "8.6.0", + "filename": "cis_ip_allowlist.yml", + "line_no": 148, + "log_file": "/var/log/gitops/run-001.log", + "stacktrace": "Traceback (most recent call last): ..." + }, + "request_configuration": "2405:201:d000:9060::/64" + }' + +Environment variables +───────────────────── + MAS_FEATURE_STATUS_DB_URL MongoDB connection URL + MAS_FEATURE_STATUS_DB_CREDENTIALS JSON object: username, password, + authSource, tls (all optional) +""" + +import argparse +import json +import logging +import os +import sys +from datetime import datetime, timezone +from typing import Optional + +# --------------------------------------------------------------------------- +# Env-var names used to persist / retrieve DB details between invocations +# --------------------------------------------------------------------------- + +_ENV_DB_URL = "MAS_FEATURE_STATUS_DB_URL" +_ENV_DB_CREDENTIALS = "MAS_FEATURE_STATUS_DB_CREDENTIALS" # pragma: allowlist secret + + +# --------------------------------------------------------------------------- +# 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 _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(args) -> tuple: + """Return (mongo_url, credentials) resolving from CLI args then env vars. + + Precedence: + 1. --db-details JSON + 2. --db-url + 3. MAS_FEATURE_STATUS_DB_URL / MAS_FEATURE_STATUS_DB_CREDENTIALS + """ + db_details = getattr(args, "db_details", None) + db_url_arg = getattr(args, "db_url", None) + + if db_details: + details = _parse_json_arg(db_details, "db-details") + url = details.get("url") or details.get("mongo_url") + if not url: + print("ERROR: --db-details must contain a 'url' key", file=sys.stderr) + sys.exit(1) + credentials = details.get("credentials") or None + return url, credentials + + if db_url_arg: + return db_url_arg, None + + env_url = os.environ.get(_ENV_DB_URL, "") + if env_url: + creds_raw = os.environ.get(_ENV_DB_CREDENTIALS, "") + credentials = json.loads(creds_raw) if creds_raw else None + return env_url, credentials + + print( + "ERROR: MongoDB connection details are required.\n" + " Provide one of:\n" + f' --db-details \'{{"url": "mongodb://..."}}\'\n' + f" --db-url \n" + f" {_ENV_DB_URL} environment variable", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Sub-command: prep +# --------------------------------------------------------------------------- + + +def cmd_prep(args) -> int: + """Verify connection and confirm required indexes exist.""" + from mas.devops.feature_status import ( + _redact_url, + create_indexes, + verify_connection_and_indexes, + ) + + mongo_url, credentials = _resolve_db(args) + print(f"Connecting to: {_redact_url(mongo_url)}") + + try: + warnings = verify_connection_and_indexes(mongo_url, credentials) + except Exception as exc: + print(f"ERROR: Could not connect to MongoDB: {exc}", file=sys.stderr) + return 1 + + if warnings: + for w in warnings: + print(f"WARNING: {w}") + if args.create_indexes: + print("Creating missing indexes …") + try: + create_indexes(mongo_url, credentials) + print("Indexes created successfully.") + except Exception as exc: + print(f"ERROR: Failed to create indexes: {exc}", file=sys.stderr) + return 1 + else: + print( + "\nTip: re-run with --create-indexes to create missing indexes automatically.", + file=sys.stderr, + ) + return 1 + else: + print("All required indexes are present.") + + print("\n# To reuse these DB details in subsequent calls, export:") + print(f"# export {_ENV_DB_URL}='{mongo_url}'") + if credentials: + print(f"# export {_ENV_DB_CREDENTIALS}='{json.dumps(credentials)}'") + + return 0 + + +# --------------------------------------------------------------------------- +# Sub-command: get +# --------------------------------------------------------------------------- + + +def cmd_get(args) -> int: + """Fetch and pretty-print a feature status document by ObjectId.""" + from mas.devops.feature_status import get_feature_status_by_id + + mongo_url, credentials = _resolve_db(args) + + try: + doc = get_feature_status_by_id(mongo_url, args.id, credentials) + 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(f"No document found with ID: {args.id}", 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 document into MongoDB.""" + from mas.devops.feature_status import ( + VALID_STATUSES, + _redact_url, + upsert_feature_status, + validate_feature_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 + + # Parse JSON arguments + feature_details = _parse_json_arg(args.feature_details, "feature-details") + status_details = _parse_json_arg(args.status_details, "status-details") + + # Type-specific feature_details validation + try: + validate_feature_details(args.type, feature_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 details + mongo_url, credentials = _resolve_db(args) + + print(f"Writing feature status: account={args.account} cluster={args.cluster} " f"instance={args.instance_id} type={args.type} status={args.status}") + print(f" MongoDB: {_redact_url(mongo_url)}") + + try: + doc_id = upsert_feature_status( + 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, + 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, + credentials=credentials, # pragma: allowlist secret + ) + 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 _add_db_args(parser: argparse.ArgumentParser) -> None: + """Add the shared --db-details / --db-url arguments to a sub-parser.""" + g = parser.add_argument_group("database connection") + g.add_argument( + "--db-details", + required=False, + default=None, + metavar="JSON", + help=( + 'JSON object with "url" and optional "credentials" keys. ' + 'Example: \'{"url": "mongodb://host:27017", "credentials": {"username": "u", "password": "p"}}\'' # pragma: allowlist secret + ), + ) + g.add_argument( + "--db-url", + required=False, + default=None, + metavar="URL", + help=f"MongoDB connection URL (alternative to --db-details). Can also be set via {_ENV_DB_URL}.", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="mas-devops-feature-status-update", + description="Write MAS feature status records to the DevOps MongoDB.", + 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 + + # ── prep ────────────────────────────────────────────────────────────────── + prep = subparsers.add_parser( + "prep", + help="Verify MongoDB connection and check/create required indexes.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _add_db_args(prep) + prep.add_argument( + "--create-indexes", + action="store_true", + default=False, + help="Create missing indexes automatically instead of failing with a warning.", + ) + + # ── status-update ───────────────────────────────────────────────────────── + su = subparsers.add_parser( + "status-update", + help="Upsert a feature status document into mas_devops.feature_status.", + 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=True, dest="instance_id", help="MAS instance ID (e.g. inst02)") + 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=True, dest="subscription_id", help="Subscription ID") + + 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.add_argument( + "--status-details", + required=True, + dest="status_details", + metavar="JSON", + help=( + "JSON object describing the outcome. " + "ACTIVE: {message, request_configuration}. " + "ERROR: {message, error_code, error_source, request_configuration}." + ), + ) + + 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="Used only on document insert.") + timestamps.add_argument("--updated-at", required=False, default=None, dest="updated_at", metavar="ISO-8601") + + _add_db_args(su) + + # ── get ─────────────────────────────────────────────────────────────────── + get = subparsers.add_parser( + "get", + help="Fetch and print a feature status document by its ObjectId.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + get.add_argument( + "id", + metavar="OBJECT_ID", + help="24-character hex ObjectId of the document (e.g. 6ab0e70ee6d3a31faa808547).", + ) + _add_db_args(get) + + 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 == "prep": + sys.exit(cmd_prep(args)) + elif 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..fad10038 --- /dev/null +++ b/bin/mas-devops-feature-status-update.md @@ -0,0 +1,843 @@ +# mas-devops-feature-status-update + +Writes MAS feature status records to the DevOps MongoDB (`mas_devops.feature_status` collection). + +## 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 `feature_dashboard` database + +Once MongoDB is running, initialize the schema and indexes from the repository root: + +```bash +mongosh "mongodb://localhost:27017/feature_dashboard" mongodb_schemas/init_db.js +``` + +Verify the collections were created: + +```bash +mongosh "mongodb://localhost:27017/feature_dashboard" --eval "db.getCollectionNames()" +# Expected: [ 'cluster_level_config', 'instance_level_config' ] +``` + +### Export the connection URL + +Export `MAS_FEATURE_STATUS_DB_URL` so every subsequent command picks it up automatically without needing `--db-url` or `--db-details`: + +```bash +export MAS_FEATURE_STATUS_DB_URL='mongodb://localhost:27017' +``` + +Then verify connectivity and indexes: + +```bash +mas-devops-feature-status-update prep --create-indexes +``` + +--- + +## 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 prep --help +mas-devops-feature-status-update status-update --help +mas-devops-feature-status-update get --help +``` + +--- + +## Sub-commands + +### `prep` + +Verifies MongoDB connectivity and confirms that the required indexes exist on the collection. + +| Index name | Fields | +|------------------------|-----------------------------------------| +| `instance_config_level` | `region` + `instance_id` + `account` | +| `cluster_config_level` | `region` + `cluster` + `account` | + +Pass `--create-indexes` to create missing indexes automatically instead of exiting with an error. + +**Options** + +| Flag | Required | Description | +|------|----------|-------------| +| `--db-details JSON` | No† | JSON object with `url` and optional `credentials` keys | +| `--db-url URL` | No† | MongoDB connection URL (alternative to `--db-details`) | +| `--create-indexes` | No | Create missing indexes automatically | + +† At least one of `--db-details`, `--db-url`, or the `MAS_FEATURE_STATUS_DB_URL` environment variable is required. + +**Examples** + +```bash +# Verify using a db-details JSON blob (local MongoDB, no auth) +mas-devops-feature-status-update prep \ + --db-details '{"url": "mongodb://localhost:27017"}' + +# Verify using a db-details JSON blob (with credentials) +mas-devops-feature-status-update prep \ + --db-details '{"url": "mongodb://localhost:27017", "credentials": {"username": "user", "password": "pass -- pragma: allowlist secret", "authSource": "admin"}}' + +# Verify and auto-create missing indexes +mas-devops-feature-status-update prep \ + --db-url mongodb://localhost:27017 \ + --create-indexes +``` + +After a successful `prep` run the command prints the `export` statements needed to reuse the connection details in subsequent `status-update` calls. + +--- + +### `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. + +**Identity options** *(all required)* + +| Flag | Description | +|------|-------------| +| `--region` | AWS region (e.g. `us-east-2`) | +| `--instance-id` | MAS instance ID (e.g. `inst02`) | +| `--account` | GitOps account name (e.g. `fyre-noble10-dev`) | +| `--cluster` | GitOps cluster name (e.g. `noble10`) | +| `--subscription-id` | Subscription ID | + +**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) | + +**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` | + +**Database connection options** *(one required)* + +| Flag | Description | +|------|-------------| +| `--db-details JSON` | JSON object with `url` and optional `credentials` keys | +| `--db-url URL` | MongoDB connection URL | + +**`--status-details` schema** + +*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. +```json +{ + "message": "Allow list is active.", + "request_configuration": "2405:201:d000:9062::/64" +} +``` + +*ERROR* — deployment failed. +```json +{ + "message": "sample error message", + "error_code": 401, + "error_source": { + "gitops_version": "8.6.0", + "filename": "cis_ip_allowlist.yml", + "line_no": 148, + "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 — record successful completion +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 + +# ERROR status — record a failed deployment +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 ERROR \ + --status-details '{ + "message": "sample error message", + "error_code": 401, + "error_source": { + "gitops_version": "8.6.0", + "filename": "cis_ip_allowlist.yml", + "line_no": 148, + "log_file": "/var/log/gitops/run-001.log", + "stacktrace": "Traceback (most recent call last): ..." + }, + "request_configuration": "2405:201:d000:9060::/64" + }' \ + --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 by its ObjectId and prints it as formatted JSON. + +**Arguments** + +| Argument | Required | Description | +|----------|----------|-------------| +| `OBJECT_ID` | Yes | 24-character hex ObjectId (printed by `status-update` on success) | +| `--db-details JSON` | No† | JSON object with `url` and optional `credentials` keys | +| `--db-url URL` | No† | MongoDB connection URL | + +† At least one of `--db-details`, `--db-url`, or the `MAS_FEATURE_STATUS_DB_URL` environment variable is required. + +**Example** + +```bash +mas-devops-feature-status-update get 6ab0e70ee6d3a31faa808547 +``` + +**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 + +Setting these avoids repeating `--db-details` / `--db-url` on every call. + +| Variable | Description | +|----------|-------------| +| `MAS_FEATURE_STATUS_DB_URL` | MongoDB connection URL | +| `MAS_FEATURE_STATUS_DB_CREDENTIALS` | JSON object with optional `username`, `password`, `authSource`, `tls` keys | + +**Precedence** (highest to lowest): `--db-details` → `--db-url` → environment variables. + +```bash +export MAS_FEATURE_STATUS_DB_URL='mongodb://user:pass@host:27017' #pragma: allowlist secret +export MAS_FEATURE_STATUS_DB_CREDENTIALS='{"username": "u", "password": "p"}' #pragma: allowlist secret + +mas-devops-feature-status-update status-update \ + --region us-east-2 \ + ... +``` + +--- + +## 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 `tenant_id × account × region × cluster` | +| `instance_level_config` | One document per `tenant_id × subscription_id × account × region × cluster × instance` | + +### Initialize + +Run `init_db.js` (which loads both schema files) against your MongoDB host: + +```bash +# mongosh (≥ 1.x, recommended) +mongosh "mongodb://:27017/feature_dashboard" mongodb_schemas/init_db.js + +# Legacy mongo shell +mongo "mongodb://:27017/feature_dashboard" mongodb_schemas/init_db.js +``` + +Or initialize each collection individually: + +```bash +mongosh "mongodb://:27017/feature_dashboard" mongodb_schemas/cluster_level_config.js +mongosh "mongodb://:27017/feature_dashboard" mongodb_schemas/instance_level_config.js +``` + +### 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() +``` + +> **Note:** `drop()` removes the collection, all documents, and all indexes. Re-run `init_db.js` to recreate them. + +### Indexes created by `init_db.js` + +**`cluster_level_config`** + +| Index name | Fields | Unique | +|---|---|---| +| `ux_cluster_level_config_tenant_account_region_cluster` | `tenant_id, account, region, cluster` | ✓ | +| `ix_cluster_level_config_tenant_account` | `tenant_id, account` | | + +**`instance_level_config`** + +| Index name | Fields | Unique | +|---|---|---| +| `ux_instance_level_config_tenant_sub_account_region_cluster_instance` | `tenant_id, subscription_id, account, region, cluster, instance` | ✓ | +| `ix_instance_level_config_tenant_sub_account_region_cluster` | `tenant_id, 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 + +Collection: `mas_devops.feature_status` + +```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": "...", "request_configuration": "..." }, + "deployment_start": "", + "deployment_end": "", + "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 — `prep` + +Verify connectivity before any write. Use `--create-indexes` on first run. + +```yaml +- name: Verify MongoDB connectivity and indexes + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update prep + --db-url {{ mas_mongo_url }} + --create-indexes + register: prep_result + changed_when: "'Creating missing indexes' in prep_result.stdout" +``` + +### Minimal task — `status-update` + +```yaml +- name: Upsert feature status (ACTIVE) + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update status-update + --db-url {{ mas_mongo_url }} + --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" +``` + +### Minimal task — `get` + +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 + ansible.builtin.command: + cmd: >- + mas-devops-feature-status-update get + --db-url {{ mas_mongo_url }} + {{ mas_document_id }} + register: get_result + changed_when: false + +- name: Display document + ansible.builtin.debug: + msg: "{{ get_result.stdout | from_json }}" +``` + +### Using environment variables instead of `--db-url` + +Set `MAS_FEATURE_STATUS_DB_URL` once (e.g. in `group_vars/all.yml` or a `block` `environment:`) to avoid repeating the flag on every task: + +```yaml +- name: Feature status tasks + environment: + MAS_FEATURE_STATUS_DB_URL: "mongodb://localhost:27017" + block: + - name: prep + ansible.builtin.command: + cmd: mas-devops-feature-status-update prep --create-indexes + + - 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. + +--- + +### By document ID + +```bash +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.findOne({ _id: ObjectId("") })' +``` + +--- + +### By identity fields + +```bash +# Full identity match (region + instance + account + cluster + type) +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.findOne({ + region: "us-east-2", + instance_id: "inst02", + account: "fyre-noble10-dev", + cluster: "noble10", + type: "allow-list" + })' + +# All documents for a specific instance +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { account: "fyre-noble10-dev", instance_id: "inst02" } + ).sort({ updated_at: -1 }).pretty()' + +# All documents for a cluster (all instances within it) +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { account: "fyre-noble10-dev", cluster: "noble10" } + ).sort({ updated_at: -1 }).pretty()' + +# All documents for an account across all clusters +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { account: "fyre-noble10-dev" } + ).sort({ cluster: 1, instance_id: 1 }).pretty()' + +# All documents for a region +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { region: "us-east-2" } + ).sort({ account: 1, cluster: 1 }).pretty()' + +# All documents for a subscription ID +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { subscription_id: "sub-id01" } + ).sort({ updated_at: -1 }).pretty()' +``` + +--- + +### By status + +```bash +# All documents in a specific status +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.find({ status: "ACTIVE" }).pretty()' + +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.find({ status: "ERROR" }).pretty()' + +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.find({ status: "IN_PROGRESS" }).pretty()' + +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.find({ status: "REQUESTED" }).pretty()' + +# Multiple statuses at once +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { status: { $in: ["REQUESTED", "IN_PROGRESS"] } } + ).sort({ updated_at: 1 }).pretty()' + +# Count documents grouped by status +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.aggregate([ + { $group: { _id: "$status", count: { $sum: 1 } } }, + { $sort: { count: -1 } } + ])' +``` + +--- + +### By feature type and payload + +```bash +# All allow-list documents +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.find({ type: "allow-list" }).pretty()' + +# ACTIVE allow-list entries for a specific IP/CIDR +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + type: "allow-list", + status: "ACTIVE", + "feature_details.ips": "2405:201:d000:9062::/64" + }).pretty()' + +# Any allow-list document whose IP array contains a given prefix (regex) +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + type: "allow-list", + "feature_details.ips": { $regex: "^2405:201:" } + }).pretty()' +``` + +--- + +### By error details + +```bash +# All ERROR documents with a specific HTTP error code +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + status: "ERROR", + "status_details.error_code": 401 + }).pretty()' + +# ERROR documents mentioning a keyword in the message (case-insensitive) +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + status: "ERROR", + "status_details.message": { $regex: "timeout", $options: "i" } + }).pretty()' + +# ERROR documents from a specific GitOps version +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + status: "ERROR", + "status_details.error_source.gitops_version": "8.6.0" + }).pretty()' +``` + +--- + +### By time + +```bash +# Documents updated in the last 24 hours +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + updated_at: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } + }).sort({ updated_at: -1 }).pretty()' + +# Documents created in a specific date range +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + created_at: { + $gte: new Date("2026-09-01T00:00:00Z"), + $lte: new Date("2026-09-30T23:59:59Z") + } + }).sort({ created_at: -1 }).pretty()' + +# Deployments that took longer than 5 minutes +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find({ + deployment_start: { $exists: true }, + deployment_end: { $exists: true }, + $expr: { + $gte: [ + { $dateDiff: { + startDate: { $dateFromString: { dateString: "$deployment_start" } }, + endDate: { $dateFromString: { dateString: "$deployment_end" } }, + unit: "minute" + }}, + 5 + ] + } + }).pretty()' + +# Most recently updated documents (last 10) +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.find().sort({ updated_at: -1 }).limit(10).pretty()' +``` + +--- + +### Projection — select specific fields only + +```bash +# Identity + status summary (no feature payload) +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { account: "fyre-noble10-dev" }, + { region: 1, instance_id: 1, cluster: 1, subscription_id: 1, + type: 1, status: 1, updated_at: 1, _id: 0 } + ).sort({ updated_at: -1 }).pretty()' + +# Status and timestamps only +mongosh "mongodb://localhost:27017/mas_devops" --eval ' + db.feature_status.find( + { cluster: "noble10" }, + { status: 1, deployment_start: 1, deployment_end: 1, updated_at: 1, _id: 0 } + ).pretty()' +``` + +--- + +### Counting and diagnostics + +```bash +# Total document count +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.countDocuments()' + +# Count for a specific account + status +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.countDocuments({ account: "fyre-noble10-dev", status: "ACTIVE" })' + +# All distinct accounts +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.distinct("account")' + +# All distinct clusters for a region +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.distinct("cluster", { region: "us-east-2" })' + +# All distinct statuses present +mongosh "mongodb://localhost:27017/mas_devops" --eval \ + 'db.feature_status.distinct("status")' +``` diff --git a/mongodb_schemas/README.md b/mongodb_schemas/README.md new file mode 100644 index 00000000..59e31fd6 --- /dev/null +++ b/mongodb_schemas/README.md @@ -0,0 +1,528 @@ +# MongoDB Schemas — `feature_dashboard` + +MongoDB validator scripts for the `feature_dashboard` database. +Extracted from [`allowlisting-tdd.md`](../allowlisting-tdd.md) §7. + +## Collections + +The original `allowlisting_config` collection has been split into two flat collections — one per level of the hierarchy — to enable targeted writes and precise index coverage. + +| File | Collection | Cardinality | Description | +|---|---|---|---| +| [`cluster_level_config.js`](cluster_level_config.js) | `cluster_level_config` | 1 doc per `(tenant_id × account × region × cluster)` | Cluster-scoped feature entries (e.g. `dro`). | +| [`instance_level_config.js`](instance_level_config.js) | `instance_level_config` | 1 doc per `(tenant_id × account × region × cluster × instance)` | Instance-scoped IP/CIDR allowlisting entries with per-feature deployment lifecycle state. | +| [`init_db.js`](init_db.js) | *(all)* | — | Bootstrap runner — initialises both collections and all indexes. | + +### Key fields per collection + +**`cluster_level_config`** + +| Field | Type | Notes | +|---|---|---| +| `tenant_id` | string | Multi-tenancy isolation key | +| `account` | string | Account from cluster polling | +| `region` | string | e.g. `us-east-1` | +| `cluster` | string | Cluster name from polling | +| `cluster_level_features[]` | array | One element per cluster-level feature (e.g. `dro`) | + +**`instance_level_config`** + +| Field | Type | Notes | +|---|---|---| +| `tenant_id` | string | Multi-tenancy isolation key | +| `account` | string | Account from cluster polling | +| `region` | string | e.g. `us-east-1` | +| `cluster` | string | Cluster name from polling | +| `instance` | string | Specific instance within the cluster | +| `instance_level_features[]` | array | One element per feature — holds `allow_lists.ips[]`, `source`, `cluster_poll_status`, timestamps, audit fields | + +## Files + +| File | Collection | Description | +|---|---|---| +| [`cluster_level_config.js`](cluster_level_config.js) | `cluster_level_config` | Cluster-scoped feature entries. | +| [`instance_level_config.js`](instance_level_config.js) | `instance_level_config` | Instance-scoped IP/CIDR allowlisting entries. | +| [`init_db.js`](init_db.js) | *(all)* | Bootstrap runner — initialises all collections and indexes. | + +--- + +## Query reference + +All queries assume `use feature_dashboard` has been run first. Replace +``, ``, ``, ``, ``, and +`` with real values. + +--- + +### `cluster_level_config` queries + +#### List all cluster documents + +```js +db.cluster_level_config.find().pretty() +``` + +#### List all clusters for a tenant + +```js +db.cluster_level_config.find( + { tenant_id: "" }, + { _id: 0, account: 1, region: 1, cluster: 1 } +).pretty() +``` + +#### List all clusters for an account + +```js +db.cluster_level_config.find( + { tenant_id: "", account: "" }, + { _id: 0, region: 1, cluster: 1 } +).pretty() +``` + +#### List all clusters in a region + +```js +db.cluster_level_config.find( + { tenant_id: "", account: "", region: "" }, + { _id: 0, cluster: 1 } +).pretty() +``` + +#### Fetch a single cluster document + +```js +db.cluster_level_config.findOne({ + tenant_id: "", + account: "", + region: "", + cluster: "" +}) +``` + +#### List cluster-level features for a specific cluster + +```js +db.cluster_level_config.findOne( + { tenant_id: "", account: "", region: "", cluster: "" }, + { _id: 0, cluster_level_features: 1 } +) +``` + +#### Find all clusters that have a specific cluster-level feature enabled + +```js +db.cluster_level_config.find( + { + tenant_id: "", + "cluster_level_features.feature_key": "" + }, + { _id: 0, account: 1, region: 1, cluster: 1, cluster_level_features: 1 } +).pretty() +``` + +#### Find all clusters that have any cluster-level feature enabled (non-empty array) + +```js +db.cluster_level_config.find( + { tenant_id: "", "cluster_level_features.0": { $exists: true } }, + { _id: 0, account: 1, region: 1, cluster: 1 } +).pretty() +``` + +#### Find all clusters with no cluster-level features + +```js +db.cluster_level_config.find( + { tenant_id: "", cluster_level_features: { $size: 0 } }, + { _id: 0, account: 1, region: 1, cluster: 1 } +).pretty() +``` + +#### Count clusters per region (for a tenant) + +```js +db.cluster_level_config.aggregate([ + { $match: { tenant_id: "" } }, + { $group: { _id: { account: "$account", region: "$region" }, cluster_count: { $sum: 1 } } }, + { $sort: { "_id.account": 1, "_id.region": 1 } } +]) +``` + +#### Count clusters per account (for a tenant) + +```js +db.cluster_level_config.aggregate([ + { $match: { tenant_id: "" } }, + { $group: { _id: "$account", cluster_count: { $sum: 1 } } }, + { $sort: { _id: 1 } } +]) +``` + +--- + +### `instance_level_config` queries + +#### List all instance documents + +```js +db.instance_level_config.find().pretty() +``` + +#### List all instances for a tenant + +```js +db.instance_level_config.find( + { tenant_id: "" }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +#### List all instances for an account + +```js +db.instance_level_config.find( + { tenant_id: "", account: "" }, + { _id: 0, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +#### List all instances in a region + +```js +db.instance_level_config.find( + { tenant_id: "", account: "", region: "" }, + { _id: 0, cluster: 1, instance: 1 } +).pretty() +``` + +#### List all instances in a cluster + +```js +db.instance_level_config.find( + { tenant_id: "", account: "", region: "", cluster: "" }, + { _id: 0, instance: 1 } +).pretty() +``` + +#### Fetch a single instance document (full detail) + +```js +db.instance_level_config.findOne({ + tenant_id: "", + account: "", + region: "", + cluster: "", + instance: "" +}) +``` + +#### Fetch only the instance-level features for a specific instance + +```js +db.instance_level_config.findOne( + { + tenant_id: "", + account: "", + region: "", + cluster: "", + instance: "" + }, + { _id: 0, instance_level_features: 1 } +) +``` + +#### Fetch a single feature entry for a specific instance + +Returns just the matching element from `instance_level_features[]` using `$elemMatch`. + +```js +db.instance_level_config.findOne( + { + tenant_id: "", + account: "", + region: "", + cluster: "", + instance: "" + }, + { + _id: 0, + instance_level_features: { + $elemMatch: { feature_key: "" } + } + } +) +``` + +#### Find all instances that have a specific feature key + +```js +db.instance_level_config.find( + { + tenant_id: "", + "instance_level_features.feature_key": "" + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +#### Find all instances for a feature key — include the matching feature entry only + +```js +db.instance_level_config.find( + { + tenant_id: "", + "instance_level_features.feature_key": "" + }, + { + _id: 0, + account: 1, region: 1, cluster: 1, instance: 1, + instance_level_features: { $elemMatch: { feature_key: "" } } + } +).pretty() +``` + +#### Find all instances that have any non-empty allow list for a feature + +```js +db.instance_level_config.find( + { + tenant_id: "", + instance_level_features: { + $elemMatch: { + feature_key: "", + "allow_lists.ips": { $exists: true, $not: { $size: 0 } } + } + } + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +#### Find all instances whose allow list contains a specific IP or CIDR + +```js +db.instance_level_config.find( + { + tenant_id: "", + instance_level_features: { + $elemMatch: { + feature_key: "", + "allow_lists.ips": "" + } + } + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +#### Find all stale instances (poll status = stale or unreachable) + +```js +db.instance_level_config.find( + { + tenant_id: "", + "instance_level_features.cluster_poll_status": { $in: ["stale", "unreachable"] } + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1, + instance_level_features: { + $elemMatch: { cluster_poll_status: { $in: ["stale", "unreachable"] } } + } + } +).pretty() +``` + +#### Find all instances not polled since a given timestamp + +```js +db.instance_level_config.find( + { + tenant_id: "", + "instance_level_features.cluster_last_polled_at": { + $lt: ISODate("") + } + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +#### Find all instances never polled (cluster_last_polled_at absent) + +```js +db.instance_level_config.find( + { + tenant_id: "", + "instance_level_features.cluster_last_polled_at": { $exists: false } + }, + { _id: 0, account: 1, region: 1, cluster: 1, instance: 1 } +).pretty() +``` + +--- + +### Cross-collection queries + +#### List all distinct regions for an account + +```js +// From cluster_level_config (one query covers all clusters, hence all regions) +db.cluster_level_config.distinct("region", { + tenant_id: "", + account: "" +}) +``` + +#### List all distinct accounts for a tenant + +```js +db.cluster_level_config.distinct("account", { tenant_id: "" }) +``` + +#### List all distinct clusters in a region + +```js +db.cluster_level_config.distinct("cluster", { + tenant_id: "", + account: "", + region: "" +}) +``` + +#### List all distinct instances in a cluster + +```js +db.instance_level_config.distinct("instance", { + tenant_id: "", + account: "", + region: "", + cluster: "" +}) +``` + +#### Full cluster view — cluster features + all instances (aggregation join) + +Joins `cluster_level_config` and `instance_level_config` for a single cluster +using `$lookup`. + +```js +db.cluster_level_config.aggregate([ + { + $match: { + tenant_id: "", + account: "", + region: "", + cluster: "" + } + }, + { + $lookup: { + from: "instance_level_config", + localField: "cluster", + foreignField: "cluster", + let: { + t: "$tenant_id", + a: "$account", + r: "$region", + c: "$cluster" + }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$tenant_id", "$$t"] }, + { $eq: ["$account", "$$a"] }, + { $eq: ["$region", "$$r"] }, + { $eq: ["$cluster", "$$c"] } + ] + } + } + }, + { $project: { _id: 0, instance: 1, instance_level_features: 1 } } + ], + as: "instances" + } + }, + { + $project: { + _id: 0, + account: 1, region: 1, cluster: 1, + cluster_level_features: 1, + instances: 1 + } + } +]) +``` + +#### Count instances per cluster across all clusters for a tenant + +```js +db.instance_level_config.aggregate([ + { $match: { tenant_id: "" } }, + { + $group: { + _id: { account: "$account", region: "$region", cluster: "$cluster" }, + instance_count: { $sum: 1 } + } + }, + { $sort: { "_id.account": 1, "_id.region": 1, "_id.cluster": 1 } } +]) +``` + +#### Count instances per region for an account + +```js +db.instance_level_config.aggregate([ + { $match: { tenant_id: "", account: "" } }, + { $group: { _id: "$region", instance_count: { $sum: 1 } } }, + { $sort: { _id: 1 } } +]) +``` + +--- + +## Running + +### Initialize the collections + +```bash +mongosh "mongodb://:27017/feature_dashboard" mongodb_schemas/init_db.js +``` + +### Clear the collections + +```js +use feature_dashboard +db.cluster_level_config.deleteMany({}) +db.instance_level_config.deleteMany({}) +``` + +### Drop the collections + +```js +use feature_dashboard +db.cluster_level_config.drop() +db.instance_level_config.drop() +``` + +> **Note:** `drop()` removes the collection, all its documents, and its indexes. Re-run `init_db.js` to recreate them. + +### Run a schema file directly + +```bash +mongosh "mongodb://:27017/feature_dashboard" mongodb_schemas/cluster_level_config.js +mongosh "mongodb://:27017/feature_dashboard" mongodb_schemas/instance_level_config.js +``` + +--- + +## Validation behaviour + +Both collections use: + +```js +validationLevel: "strict" // enforced on inserts AND updates +validationAction: "error" // rejects non-conforming writes outright +``` + +`additionalProperties: false` is set on every top-level object and nested object (except `cluster_level_features[]` items, which allow extension fields) to prevent undocumented fields from being silently stored. diff --git a/mongodb_schemas/cluster_level_config.js b/mongodb_schemas/cluster_level_config.js new file mode 100644 index 00000000..75e59936 --- /dev/null +++ b/mongodb_schemas/cluster_level_config.js @@ -0,0 +1,101 @@ +// ============================================================================= +// Collection: cluster_level_config +// Database: feature_dashboard +// Purpose: Stores cluster-scoped feature entries. Each document represents +// one cluster within a region/account/tenant and holds the list of +// cluster-level features enabled for that cluster (e.g. 'dro'). +// +// Split from allowlisting_config: cluster_level_features[] was +// previously embedded inside the deep +// account → regions[] → clusters[] hierarchy. +// Flattening to one document per cluster simplifies writes and +// allows targeted index coverage without touching instance data. +// +// Document cardinality: +// ONE document per (tenant_id × account × region × cluster). +// ============================================================================= + +db.createCollection("cluster_level_config", { + validator: { + $jsonSchema: { + bsonType: "object", + required: [ + "_id", "tenant_id", "account", "region", "cluster", + "cluster_level_features", "created_at", "updated_at" + ], + additionalProperties: false, + properties: { + + // ------------------------------------------------------------------ + // Document identity + // ------------------------------------------------------------------ + _id: { + bsonType: "objectId", + description: "MongoDB-generated document identifier." + }, + tenant_id: { + bsonType: "string", + description: "Tenant/customer identifier. All customer-scoped queries MUST filter on this field. This is the multi-tenancy isolation key." + }, + account: { + bsonType: "string", + description: "Account identifier as returned by the cluster polling mechanism." + }, + region: { + bsonType: "string", + description: "Cloud/geographic region identifier. Example: 'us-east-1'." + }, + cluster: { + bsonType: "string", + description: "Cluster name or identifier as returned by the cluster polling mechanism." + }, + + // ------------------------------------------------------------------ + // Cluster-level features (e.g. dro) + // ------------------------------------------------------------------ + cluster_level_features: { + bsonType: "array", + description: "List of cluster-scoped feature entries. Each element represents one feature enabled at the cluster level (e.g. 'dro').", + minItems: 0, + items: { + bsonType: "object", + additionalProperties: true + } + }, + + // ------------------------------------------------------------------ + // Document-level audit timestamps + // ------------------------------------------------------------------ + created_at: { + bsonType: "date", + description: "UTC timestamp when this document was first created." + }, + updated_at: { + bsonType: "date", + description: "UTC timestamp of the most recent modification to this document." + } + + } + } + }, + validationLevel: "strict", + validationAction: "error" +}); + +// --------------------------------------------------------------------------- +// Indexes +// --------------------------------------------------------------------------- + +// Compound unique index — enforces the one-document-per +// (tenant × account × region × cluster) invariant +db.cluster_level_config.createIndex( + { tenant_id: 1, account: 1, region: 1, cluster: 1 }, + { unique: true, name: "ux_cluster_level_config_tenant_account_region_cluster" } +); + +// Index for querying all clusters for a given tenant + account +db.cluster_level_config.createIndex( + { tenant_id: 1, account: 1 }, + { name: "ix_cluster_level_config_tenant_account" } +); + diff --git a/mongodb_schemas/init_db.js b/mongodb_schemas/init_db.js new file mode 100644 index 00000000..a282b6fb --- /dev/null +++ b/mongodb_schemas/init_db.js @@ -0,0 +1,25 @@ +// ============================================================================= +// init_db.js — Bootstrap script for the feature_dashboard database +// Database: feature_dashboard +// +// Initialises all collections and their indexes: +// • cluster_level_config — cluster-scoped feature entries +// • instance_level_config — instance-scoped allowlisting entries +// +// Usage (mongosh): +// mongosh "mongodb://:27017/feature_dashboard" init_db.js +// +// Usage (legacy mongo shell): +// mongo "mongodb://:27017/feature_dashboard" init_db.js +// ============================================================================= + +const scriptDir = __dirname ?? (function() { + const parts = __filename.split("/"); + parts.pop(); + return parts.join("/"); +})(); + +load(scriptDir + "/cluster_level_config.js"); +load(scriptDir + "/instance_level_config.js"); + +print("✅ feature_dashboard: cluster_level_config and instance_level_config collections and indexes initialized."); diff --git a/mongodb_schemas/instance_level_config.js b/mongodb_schemas/instance_level_config.js new file mode 100644 index 00000000..db4c9ab7 --- /dev/null +++ b/mongodb_schemas/instance_level_config.js @@ -0,0 +1,286 @@ +// ============================================================================= +// Collection: instance_level_config +// Database: feature_dashboard +// Purpose: Stores instance-scoped feature entries. Each document represents +// one instance within a cluster/region/account/tenant and holds the +// list of per-feature state for that instance (IP/CIDR lists, +// deployment lifecycle, audit metadata, etc.). +// +// Split from allowlisting_config: instance_level_features[] was +// previously embedded at the deepest leaf of the +// account → regions[] → clusters[] → instances[] hierarchy. +// Flattening to one document per instance enables efficient +// targeted upserts by ansible-devops and GitHub webhook writes, +// precise index coverage for status detection, and independent +// scaling of cluster and instance data. +// +// Document cardinality: +// ONE document per (tenant_id × subscription_id × account × region × cluster × instance). +// +// instance_level_features item shape (flattened — no nested allow_lists[]): +// +// SUCCESS scenario +// ---------------- +// { +// 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.863523+00:00", +// deployment_end: "2026-09-11T11:48:42.863523+00:00", +// source: "admin_ui", +// created_at: ISODate(...), +// updated_at: ISODate(...) +// } +// +// FAILURE scenario +// ---------------- +// { +// type: "allow-list", +// feature_details: { ips: ["2405:201:d000:9062::/64"] }, +// status: "ERROR", +// status_details: { +// message: "sample error message", +// error_code: 401, +// error_source: { +// gitops_version: "8.6.0", +// filename: "...", +// line_no: 1, +// log_file: "...", +// stacktrace: "..." +// }, +// request_configuration: "2405:201:d000:9060::/64" +// }, +// deployment_start: "2026-09-11T11:48:42.863523+00:00", +// deployment_end: "2026-09-11T11:48:42.863523+00:00", +// source: "admin_ui", +// created_at: ISODate(...), +// updated_at: ISODate(...) +// } +// ============================================================================= + +db.createCollection("instance_level_config", { + validator: { + $jsonSchema: { + bsonType: "object", + required: [ + "_id", "tenant_id", "subscription_id", "account", "region", + "cluster", "instance", "instance_level_features", "created_at", "updated_at" + ], + additionalProperties: false, + properties: { + + // ------------------------------------------------------------------ + // Document identity + // ------------------------------------------------------------------ + _id: { + bsonType: "objectId", + description: "MongoDB-generated document identifier." + }, + tenant_id: { + bsonType: "string", + description: "Tenant/customer identifier. All customer-scoped queries MUST filter on this field. This is the multi-tenancy isolation key." + }, + subscription_id: { + bsonType: "string", + description: "Subscription identifier associated with the tenant/instance. Example: 'sub-id01'." + }, + account: { + bsonType: "string", + description: "Account identifier as returned by the cluster polling mechanism." + }, + region: { + bsonType: "string", + description: "Cloud/geographic region identifier. Example: 'us-east-2'." + }, + cluster: { + bsonType: "string", + description: "Cluster name or identifier as returned by the cluster polling mechanism." + }, + instance: { + bsonType: "string", + description: "Specific instance identifier within the cluster." + }, + + // ------------------------------------------------------------------ + // Instance-level features — one entry per deployed feature + // ------------------------------------------------------------------ + instance_level_features: { + bsonType: "array", + description: "List of feature-level entries for this instance. Each element represents one deployed feature (e.g. allow-list) with its full deployment lifecycle state.", + minItems: 0, + items: { + bsonType: "object", + required: [ + "type", "feature_details", "status", + "source", "created_at", "updated_at" + ], + additionalProperties: false, + properties: { + + // Feature type discriminator + type: { + bsonType: "string", + enum: ["allow-list"], + description: "Feature type. Determines the shape of feature_details. Currently only 'allow-list' is supported." + }, + + // ---- feature payload ---------------------------------------- + feature_details: { + bsonType: "object", + description: "Feature-specific configuration payload. Shape depends on 'type'.", + required: ["ips"], + additionalProperties: false, + properties: { + ips: { + bsonType: "array", + description: "List of IPv4/IPv6 addresses or CIDR ranges to allowlist. Example: ['2405:201:d000:9060::/64', '10.0.0.0/8'].", + minItems: 1, + items: { + bsonType: "string", + description: "A single IPv4/IPv6 address or CIDR range." + } + } + } + }, + + // ---- deployment lifecycle ------------------------------------ + status: { + bsonType: "string", + enum: ["REQUESTED", "IN_PROGRESS", "ACTIVE", "ERROR"], + description: "Deployment lifecycle state of this feature entry." + }, + + status_details: { + bsonType: "object", + description: "Additional context for the current status. Present for ACTIVE and ERROR; may be omitted for REQUESTED/IN_PROGRESS.", + additionalProperties: false, + properties: { + + message: { + bsonType: "string", + description: "Human-readable status message. ACTIVE: confirmation text. ERROR: error description." + }, + + // ERROR-only fields + error_code: { + bsonType: "int", + description: "Numeric HTTP/application error code. Set only when status = ERROR. Example: 401." + }, + error_source: { + bsonType: "object", + description: "Machine-readable origin of the error. Set only when status = ERROR.", + additionalProperties: false, + properties: { + gitops_version: { + bsonType: "string", + description: "GitOps toolchain version that processed this entry. Example: '8.6.0'." + }, + filename: { + bsonType: "string", + description: "Source filename in the GitOps pipeline where the error originated." + }, + line_no: { + bsonType: "int", + description: "Line number within 'filename' where the error was raised." + }, + log_file: { + bsonType: "string", + description: "Path or reference to the log file capturing the error output." + }, + stacktrace: { + bsonType: "string", + description: "Full stacktrace string captured at the point of failure." + } + } + }, + + // Common field for ACTIVE and ERROR + request_configuration: { + bsonType: "string", + description: "Verbatim echo of the originally submitted IP/CIDR value that was processed. Aids reconciliation when the applied value differs from what was requested." + } + + } + }, + + deployment_start: { + bsonType: "string", + description: "ISO-8601 timestamp set when the deployment pipeline begins processing this feature entry (status → IN_PROGRESS)." + }, + deployment_end: { + bsonType: "string", + description: "ISO-8601 timestamp set when the pipeline completes, whether successfully (ACTIVE) or with failure (ERROR). Null while the pipeline is still running." + }, + + // ---- audit --------------------------------------------------- + source: { + bsonType: "string", + enum: ["ansible_devops", "github_webhook", "cluster_poll", "admin_ui"], + description: "The system that last wrote this feature entry. Used for audit traceability and reconciliation." + }, + created_at: { + bsonType: "date", + description: "UTC timestamp when this feature entry was first created." + }, + updated_at: { + bsonType: "date", + description: "UTC timestamp of the most recent modification to this feature entry." + } + + } // end instance_level_features item properties + } // end instance_level_features items + }, // end instance_level_features array + + // ------------------------------------------------------------------ + // Document-level audit timestamps + // ------------------------------------------------------------------ + created_at: { + bsonType: "date", + description: "UTC timestamp when this document was first created." + }, + updated_at: { + bsonType: "date", + description: "UTC timestamp of the most recent modification to this document." + } + + } + } + }, + validationLevel: "strict", + validationAction: "error" +}); + +// --------------------------------------------------------------------------- +// Indexes +// --------------------------------------------------------------------------- + +// Compound unique index — enforces the one-document-per +// (tenant × subscription × account × region × cluster × instance) invariant +db.instance_level_config.createIndex( + { tenant_id: 1, subscription_id: 1, account: 1, region: 1, cluster: 1, instance: 1 }, + { unique: true, name: "ux_instance_level_config_tenant_sub_account_region_cluster_instance" } +); + +// Index for ansible-devops / GitHub webhook upserts — primary lookup path +db.instance_level_config.createIndex( + { tenant_id: 1, subscription_id: 1, account: 1, region: 1, cluster: 1 }, + { name: "ix_instance_level_config_tenant_sub_account_region_cluster" } +); + +// Multikey index on feature status — supports finding all documents with +// at least one entry in a given lifecycle state (e.g. IN_PROGRESS or ERROR) +db.instance_level_config.createIndex( + { "instance_level_features.status": 1 }, + { name: "ix_instance_level_config_feature_status" } +); + +// Sparse index for error triage — only indexes documents that have at least +// one ERROR entry; avoids index bloat for the common ACTIVE/non-error case +db.instance_level_config.createIndex( + { "instance_level_features.status_details.error_code": 1 }, + { sparse: true, name: "ix_instance_level_config_error_code" } +); diff --git a/setup.py b/setup.py index fa0fcc1d..6049b278 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 ], extras_require={ "dev": [ @@ -93,5 +94,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..357424c2 --- /dev/null +++ b/src/mas/devops/feature_status.py @@ -0,0 +1,328 @@ +# ***************************************************************************** +# 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. + +Supports two operations: + + prep — Verify the MongoDB connection and confirm the expected indexes + (instance_config_level, cluster_config_level) exist on the + target collection. Stores db-details for later use in an + environment variable so they do not need to be repeated on + every status-update call. + + status_update — Upsert a feature status document into + ``mas_devops.feature_status``. + +Collection: ``mas_devops.feature_status`` + +Document schema (mirrors the CIS allowlist status tracking design): + + { + "_id": , + "schema_version": 1, + "region": str, + "instance_id": str, + "account": str, + "cluster": str, + "subscription_id": str, + "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": datetime, + "deployment_end": datetime | None, + "created_at": datetime, + "updated_at": datetime, + } + +Indexes expected on the collection + • ``instance_config_level`` — compound: region + instance_id + account + • ``cluster_config_level`` — compound: region + cluster + account +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +COLLECTION = "feature_status" +DATABASE = "mas_devops" + +# Status enum values (matches the CIS allowlist lifecycle) +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} + +# Required index names that must exist on the collection. +REQUIRED_INDEX_NAMES = {"instance_config_level", "cluster_config_level"} + +# --------------------------------------------------------------------------- +# Per-type feature_details validators +# --------------------------------------------------------------------------- + +# Each key maps to the set of field names that MUST be present in feature_details +# when --type matches that key. +_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: + # Unknown type — no field-level validation, but emit a warning. + 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)}") + + +# --------------------------------------------------------------------------- +# MongoDB helpers +# --------------------------------------------------------------------------- + + +def _get_client(mongo_url: str, credentials: Optional[dict] = None): + """Return a pymongo MongoClient for *mongo_url*. + + Credentials dict may contain ``username`` and ``password`` keys. + If the URL already embeds credentials they take precedence. + """ + try: + from pymongo import MongoClient # type: ignore + except ImportError as exc: # pragma: no cover + raise ImportError("pymongo is required. Install it with: pip install pymongo") from exc + + kwargs: dict[str, Any] = {"serverSelectionTimeoutMS": 10_000} + if credentials: + if "username" in credentials: + kwargs["username"] = credentials["username"] + if "password" in credentials: + kwargs["password"] = credentials["password"] + if "authSource" in credentials: + kwargs["authSource"] = credentials["authSource"] + if "tls" in credentials: + kwargs["tls"] = credentials["tls"] + + return MongoClient(mongo_url, **kwargs) + + +def verify_connection_and_indexes(mongo_url: str, credentials: Optional[dict] = None) -> list[str]: + """Connect to MongoDB and check that the expected indexes exist. + + Returns a list of warning messages for any missing indexes. + Raises on connection failure. + """ + client = _get_client(mongo_url, credentials) + try: + # Ping — will raise if the server is unreachable. + client.admin.command("ping") + logger.info("MongoDB connection OK: %s", _redact_url(mongo_url)) + + db = client[DATABASE] + collection = db[COLLECTION] + + # Retrieve existing index names. + existing_index_names = {info["name"] for info in collection.list_indexes()} + + warnings = [] + for expected in REQUIRED_INDEX_NAMES: + if expected not in existing_index_names: + warnings.append( + f"Index '{expected}' not found on {DATABASE}.{COLLECTION}. " f"Run the index-creation script or use 'prep' with --create-indexes." + ) + return warnings + finally: + client.close() + + +def create_indexes(mongo_url: str, credentials: Optional[dict] = None) -> None: + """Create the required indexes on the feature_status collection if they do not exist.""" + try: + from pymongo import ASCENDING # type: ignore + except ImportError as exc: # pragma: no cover + raise ImportError("pymongo is required. Install it with: pip install pymongo") from exc + + client = _get_client(mongo_url, credentials) + try: + db = client[DATABASE] + collection = db[COLLECTION] + + collection.create_index( + [("region", ASCENDING), ("instance_id", ASCENDING), ("account", ASCENDING)], + name="instance_config_level", + background=True, + ) + logger.info("Index 'instance_config_level' ensured on %s.%s", DATABASE, COLLECTION) + + collection.create_index( + [("region", ASCENDING), ("cluster", ASCENDING), ("account", ASCENDING)], + name="cluster_config_level", + background=True, + ) + logger.info("Index 'cluster_config_level' ensured on %s.%s", DATABASE, COLLECTION) + finally: + client.close() + + +def upsert_feature_status( + mongo_url: str, + *, + region: str, + instance_id: str, + account: str, + cluster: str, + subscription_id: 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, + credentials: Optional[dict] = None, +) -> str: + """Upsert a feature status document. Returns the upserted / matched document ID as a string. + + The upsert key is ``(region, instance_id, account, cluster, type)``. + On insert ``created_at`` is set; ``updated_at`` is always refreshed. + """ + try: + from pymongo import ReturnDocument # type: ignore + except ImportError as exc: # pragma: no cover + raise ImportError("pymongo is required. Install it with: pip install pymongo") from exc + + 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) + deployment_start = deployment_start or now + updated_at = updated_at or now + created_at = created_at or now + + filter_doc = { + "region": region, + "instance_id": instance_id, + "account": account, + "cluster": cluster, + "type": feature_type, + } + + update_doc = { + "$set": { + "subscription_id": subscription_id, + "feature_details": feature_details, + "status": status, + "status_details": status_details, + "deployment_start": deployment_start, + "deployment_end": deployment_end, + "updated_at": updated_at, + "schema_version": 1, + }, + "$setOnInsert": { + "created_at": created_at, + }, + } + + client = _get_client(mongo_url, credentials) + try: + db = client[DATABASE] + collection = db[COLLECTION] + result = collection.find_one_and_update( + filter_doc, + update_doc, + upsert=True, + return_document=ReturnDocument.AFTER, + ) + doc_id = str(result["_id"]) + logger.info( + "Feature status upserted [%s / %s / %s] status=%s id=%s", + account, + instance_id, + feature_type, + status, + doc_id, + ) + return doc_id + finally: + client.close() + + +def get_feature_status_by_id(mongo_url: str, doc_id: str, credentials: Optional[dict] = None) -> Optional[dict]: + """Fetch a single feature status document by its ObjectId string. + + Args: + mongo_url (str): MongoDB connection URL. + doc_id (str): Hex string ObjectId of the document to retrieve. + credentials (dict, optional): Optional credential overrides. Defaults to None. + + Returns: + dict: The document with ``_id`` serialised to a string, or None if not found. + + Raises: + ValueError: If *doc_id* is not a valid 24-character hex ObjectId. + pymongo.errors.ConnectionFailure: If the MongoDB server is unreachable. + """ + 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 = _get_client(mongo_url, credentials) + try: + doc = client[DATABASE][COLLECTION].find_one({"_id": oid}) + if doc is None: + return None + doc["_id"] = str(doc["_id"]) + return doc + 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)