"""Publish deploy outputs to SSM + format GitHub PR comments (D-050). Two canonical mechanisms: 1. SSM Parameter Store (SecureString, KMS-encrypted) for runtime-injectable values — resources that need to read outputs at runtime (e.g. an ECS task reading its S3 bucket name). 2. GitHub PR comment / job summary for human-readable outputs (connection strings, ALB DNS, S3 bucket URL, CloudFront domain). No raw secrets in the comment — only non-sensitive outputs (DNS names, ARNs, bucket names). The namespace is /nova/{environment}/{contractId}/{output_name} so consumers can query their own outputs via aws ssm get-parameter --name /nova/dev//... (REQ-161, P3: migrated from /acdl/... ; scripts/migrate_ssm_paths.py copies existing /acdl/... parameters to /nova/... and deletes the old ones.) """ import json import os import sys import urllib.error import urllib.request try: import boto3 from botocore.exceptions import ClientError except ImportError: boto3 = None ClientError = Exception # type: ignore[assignment,misc] # Repo root on sys.path so `from core import env` resolves to THIS package # when run as a script (avoids editable-installed third-party `core` shadow). _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from core import env as _envhelper SSM_PREFIX = "/nova" KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID" # P14 (REQ-178): SAFE_OUTPUT_NAMES is schema-driven (derived from # modules/l1/*/interface.json outputs that don't have sensitive:true). # Falls back to the hardcoded set if the interfaces can't be read. _HARDCODED_SAFE_OUTPUTS = { "distribution_domain_name", "bucket_arn", "bucket_name", "bucket_regional_domain_name", "web_acl_arn", "lb_arn", "listener_arn", "target_group_arn", "service_arn", "cluster_arn", "repository_url", "db_endpoint", "db_arn", "distribution_arn", "vpc_id", "subnet_ids", } def _load_safe_output_names(): """Derive the safe-output allowlist from interface.json outputs. P14 (REQ-178): scan modules/l1/*/interface.json; an output is safe if its spec does not set sensitive:true. Falls back to the hardcoded set if no interfaces are readable. """ import json from pathlib import Path root = Path(__file__).resolve().parent.parent safe = set() try: for iface in (root / "modules" / "l1").glob("*/interface.json"): d = json.loads(iface.read_text()) outs = d.get("outputs", {}) if isinstance(outs, dict): for name, spec in outs.items(): if not (isinstance(spec, dict) and spec.get("sensitive")): safe.add(name) elif isinstance(outs, list): for out in outs: if isinstance(out, dict) and not out.get("sensitive"): safe.add(out.get("name", "")) except (OSError, ValueError): pass return safe or _HARDCODED_SAFE_OUTPUTS SAFE_OUTPUT_NAMES = _load_safe_output_names() def _ssm_client(): if boto3 is None: raise RuntimeError("boto3 is required for SSM publishing") return boto3.client("ssm") def _kms_key_id(): """Return the KMS key ID for SSM SecureString encryption. P1-3: Fail loud when NOVA_KMS_KEY_ID is not set — silently falling back to the AWS-managed key (`alias/aws/ssm`) was a security gap. The platform CMK must be explicitly configured. Set NOVA_ALLOW_DEFAULT_KMS=1 to use the AWS-managed key as an escape hatch for local testing. (Dual-read via core/env.py: NOVA_* preferred, ACDL_* fallback until P5.) """ key_id = _envhelper.get_env("KMS_KEY_ID") if key_id: return key_id if _envhelper.get_env("ALLOW_DEFAULT_KMS") == "1": return "alias/aws/ssm" raise RuntimeError( f"{KMS_KEY_ID_ENV} is not set — refusing to use the AWS-managed SSM key " f"silently. Set {KMS_KEY_ID_ENV} to your platform CMK ARN, or set " f"NOVA_ALLOW_DEFAULT_KMS=1 (ACDL_ALLOW_DEFAULT_KMS=1 fallback) to use " f"alias/aws/ssm (escape hatch for local testing)." ) def publish_to_ssm(outputs, environment, contract_id): """Write each output to SSM Parameter Store as a SecureString. Returns a dict of {output_name: parameter_arn} for successful writes. Skips None values and empty strings. """ if boto3 is None: return {} client = _ssm_client() kms_key = _kms_key_id() results = {} for name, value in outputs.items(): if value is None: continue if isinstance(value, str) and not value.strip(): continue param_name = f"{SSM_PREFIX}/{environment}/{contract_id}/{name}" try: client.put_parameter( Name=param_name, Value=str(value), Type="SecureString", KeyId=kms_key, Overwrite=True, ) results[name] = param_name except (ClientError, OSError) as e: # P4 (REQ-168): narrow from bare `except Exception` to AWS + # OS errors. Don't fail the pipeline if one output fails to # publish, but log it with context. import sys print(f"WARNING: SSM put_parameter failed for {name}: {type(e).__name__}: {e}", file=sys.stderr) results[name] = None return results def format_comment(outputs, environment, contract_id, ssm_results=None): """Format a GitHub PR comment / job summary with human-readable outputs. Only non-sensitive outputs (SAFE_OUTPUT_NAMES) are included. Sensitive outputs are noted as 'published to SSM' without their values. """ lines = [ f"### Nova Deploy Outputs ({environment})", "", f"**Contract:** `{contract_id}`", f"**Environment:** `{environment}`", "", "| Output | Value | SSM |", "|--------|-------|-----|", ] for name, value in sorted(outputs.items()): if value is None: continue if isinstance(value, str) and not value.strip(): continue safe = name in SAFE_OUTPUT_NAMES display = str(value) if safe else "`(published to SSM)`" ssm_path = "" if ssm_results and ssm_results.get(name): ssm_path = f"`{ssm_results[name]}`" elif ssm_results is not None: ssm_path = "—" lines.append(f"| `{name}` | {display} | {ssm_path} |") lines.append("") lines.append("> Sensitive outputs are available via `aws ssm get-parameter --name /nova/" + environment + "/" + contract_id + "/` (KMS-encrypted SecureString).") return "\n".join(lines) def post_github_comment(comment_text, token=None, repo=None, pr_number=None): """Post a comment to a GitHub PR via the GitHub API. Uses GITHUB_TOKEN from env if token is None. Uses GITHUB_REPOSITORY if repo is None. Uses the PR number from the GITHUB_REF env if pr_number is None (extracts from refs/pull//merge). No-op if not in a PR context. """ if token is None: token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") if repo is None: repo = os.environ.get("GITHUB_REPOSITORY", "") if pr_number is None: ref = os.environ.get("GITHUB_REF", "") if "refs/pull/" in ref: try: pr_number = int(ref.split("/")[2]) except (IndexError, ValueError): pass if not token or not repo or not pr_number: return False # not in a PR context or no token try: url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments" data = json.dumps({"body": comment_text}).encode() req = urllib.request.Request(url, data=data, method="POST") req.add_header("Authorization", f"token {token}") req.add_header("Accept", "application/vnd.github+json") urllib.request.urlopen(req, timeout=10) return True except (OSError, urllib.error.URLError, urllib.error.HTTPError) as e: # P4 (REQ-168): narrow from bare `except Exception` to network + # HTTP errors. Don't fail the pipeline if the PR comment can't be # posted, but log it with context. import sys print(f"WARNING: GitHub PR comment failed: {type(e).__name__}: {e}", file=sys.stderr) return False if __name__ == "__main__": # CLI: output_publisher.py if len(sys.argv) != 4: print("usage: output_publisher.py ", file=sys.stderr) sys.exit(2) with open(sys.argv[1]) as f: outputs = json.load(f) env = sys.argv[2] cid = sys.argv[3] ssm_results = publish_to_ssm(outputs, env, cid) comment = format_comment(outputs, env, cid, ssm_results) print(comment)