Files
acdl/core/output_publisher.py
T
Jon Chery 2806c6c3ed verify(P4): migrate-ssm-except-narrowing — 4-layer verify PASS + ship
VERIFY: structural — narrowed excepts; behavioral — 48 tests + CI PASS; security — non-ParameterNotFound errors surface; quality — 2 new + 2 updated tests.

---ci---
project: acdl
phase: 4
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-168]
  partial: []
---/ci---
2026-08-01 12:24:30 +00:00

207 lines
7.6 KiB
Python

"""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/<id>/...
(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"
# 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 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 + "/<output_name>` (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/<N>/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 <outputs.json> <environment> <contract_id>
if len(sys.argv) != 4:
print("usage: output_publisher.py <outputs.json> <environment> <contract-id>", 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)