225de0f613
---ci--- project: acdl phase: 7 milestone: v1.14 status: complete requirements: covered: [REQ-141] partial: [] ---/ci---
187 lines
6.5 KiB
Python
187 lines
6.5 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 /acdl/{environment}/{contractId}/{output_name} so consumers
|
|
can query their own outputs via aws ssm get-parameter --name /acdl/dev/<id>/...
|
|
"""
|
|
|
|
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 as e:
|
|
# Don't fail the pipeline if one output fails to publish, but log it
|
|
import sys
|
|
print(f"WARNING: SSM put_parameter failed for {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"### 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 + "/<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:
|
|
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 as e:
|
|
import sys
|
|
print(f"WARNING: GitHub PR comment failed: {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) |