"""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 /acdl/{environment}/{contractId}/{output_name} so consumers can query their own outputs via aws ssm get-parameter --name /acdl/dev//... """ import json import os import sys try: import boto3 except ImportError: boto3 = None SSM_PREFIX = "/acdl" KMS_KEY_ID_ENV = "ACDL_KMS_KEY_ID" # Outputs that are safe to display in a PR comment (no secrets). SAFE_OUTPUT_NAMES = { "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 _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 ACDL_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 ACDL_ALLOW_DEFAULT_KMS=1 to use the AWS-managed key as an escape hatch for local testing. """ key_id = os.environ.get(KMS_KEY_ID_ENV) if key_id: return key_id if os.environ.get("ACDL_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"ACDL_ALLOW_DEFAULT_KMS=1 to use 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 Exception: # Don't fail the pipeline if one output fails to publish 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"### ACDL 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 /acdl/" + 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: import urllib.request 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 Exception: 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)