134f85d2df
---ci--- project: acdl phase: 34 milestone: v1.8 status: execute ---/ci--- - DynamoDB acdl-change-requests table added to terraform/platform/main.tf (PK changeRequestId, SK submittedAt, SSE via CMK, PITR). - validate_change_request Lambda action added to contract_ingestor.py: queries CMDB, asserts status=approved + consumerRepo match. - decommission_transform() added to contract_resolver.py: zeroes all counts (desired_count, min/max_capacity) + sets deletion_protection=false. - Decommission mode added to deploy pipeline + both deploy workflows (mode: decommission + changeRequestId input). Byte-identical. - run_platform.sh --decommission flag: validates CR, resolves with deletion_protection=false (step 1), then decommission_transform (step 2). HITL SRE gates documented. - docs/consumer-guide.md: new "Decommissioning a stack" section with CR request, trigger, 2-step HITL SRE gates, CMK deletion window, uptime. Tests: +14 (318 -> 332). All pass.
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""Platform Lambda — contract ingestor.
|
|
|
|
Invoked via a Function URL (IAM auth) by consumer pipelines (one-way
|
|
communication, D-051). Accepts { consumerRepo, contractId, contract,
|
|
environment, action } and writes contracts to DynamoDB table acdl-contracts
|
|
(PK consumerRepo, SK contractId#submittedAt).
|
|
|
|
The report_error action (D-055) creates a GitHub issue on the platform repo
|
|
via the GitHub API, using a token from Secrets Manager. It is idempotent: if
|
|
an open issue with the same title exists, it comments rather than duplicating.
|
|
|
|
Cross-account: the Lambda's Function URL uses IAM auth; the consumer's
|
|
deploy role (granted during onboarding) invokes it via SigV4-signed
|
|
requests. The invoke policy is scoped via ABAC (consumer repo identity).
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import urllib.parse
|
|
|
|
import boto3
|
|
|
|
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "acdl-contracts")
|
|
CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "acdl-change-requests")
|
|
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "acdl/github-token")
|
|
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "acdl/acdl")
|
|
# P1-9: Forge-agnostic API base URL. Defaults to GitHub; set GITHUB_API_BASE
|
|
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
|
|
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
|
|
|
_dynamodb = None
|
|
_secrets_client = None
|
|
|
|
|
|
def _get_dynamodb():
|
|
global _dynamodb
|
|
if _dynamodb is None:
|
|
_dynamodb = boto3.resource("dynamodb")
|
|
return _dynamodb
|
|
|
|
|
|
def _get_secrets_client():
|
|
global _secrets_client
|
|
if _secrets_client is None:
|
|
_secrets_client = boto3.client("secretsmanager")
|
|
return _secrets_client
|
|
|
|
|
|
def _iso8601_now():
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _forge_type():
|
|
"""P1-9: Detect whether the API base is GitHub or Gitea.
|
|
|
|
Gitea API roots contain '/api/v1'; GitHub's is 'api.github.com'.
|
|
"""
|
|
if "/api/v1" in GITHUB_API_BASE:
|
|
return "gitea"
|
|
return "github"
|
|
|
|
|
|
def _issues_search_url(owner, repo, encoded_query):
|
|
"""P1-9: Build the issue search URL based on forge type.
|
|
|
|
GitHub uses /search/issues?q=...; Gitea uses /repos/{owner}/{repo}/issues?...
|
|
with query params (no /search/issues endpoint).
|
|
"""
|
|
if _forge_type() == "gitea":
|
|
return (
|
|
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
|
|
f"?state=open&type=issues&q={encoded_query}"
|
|
)
|
|
return (
|
|
f"{GITHUB_API_BASE}/search/issues?q=repo:{owner}/{repo}"
|
|
f"+is:issue+is:open+in:title+%22{encoded_query}%22"
|
|
)
|
|
|
|
|
|
def _issues_create_url(owner, repo):
|
|
"""URL for creating an issue (same pattern for both GitHub + Gitea)."""
|
|
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues"
|
|
|
|
|
|
def _issue_comments_url(owner, repo, issue_number):
|
|
"""URL for posting a comment on an issue (same for both forges)."""
|
|
return f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
|
|
|
|
|
def _submit_contract(payload):
|
|
consumer_repo = payload["consumerRepo"]
|
|
contract_id = payload["contractId"]
|
|
contract = payload["contract"]
|
|
environment = payload["environment"]
|
|
submitted_at = _iso8601_now()
|
|
table = _get_dynamodb().Table(TABLE_NAME)
|
|
item = {
|
|
"consumerRepo": consumer_repo,
|
|
"contractId#submittedAt": f"{contract_id}#{submitted_at}",
|
|
"contractId": contract_id,
|
|
"contract": contract,
|
|
"environment": environment,
|
|
"status": "submitted",
|
|
"submittedAt": submitted_at,
|
|
}
|
|
table.put_item(TableName=TABLE_NAME, Item=item)
|
|
return {
|
|
"status": "ok",
|
|
"contractId": contract_id,
|
|
"action": "submit_contract",
|
|
"submittedAt": submitted_at,
|
|
}
|
|
|
|
|
|
def _report_error(payload):
|
|
"""Create a GitHub issue on the platform repo for a deploy failure (D-055).
|
|
|
|
Uses the GitHub token from Secrets Manager. Idempotent: if an open
|
|
issue with the same title exists, comments on it rather than duplicating.
|
|
"""
|
|
import urllib.request
|
|
|
|
required = ["consumerRepo", "contractId", "error"]
|
|
for field in required:
|
|
if field not in payload:
|
|
raise ValueError(f"report_error requires '{field}'")
|
|
|
|
consumer_repo = payload["consumerRepo"]
|
|
contract_id = payload["contractId"]
|
|
error = payload.get("error", "unknown error")
|
|
run_url = payload.get("runUrl", "")
|
|
stack_trace = payload.get("stackTrace", "")[:2000] # truncate
|
|
|
|
# Get the GitHub token from Secrets Manager
|
|
secrets = _get_secrets_client()
|
|
try:
|
|
secret_response = secrets.get_secret_value(SecretId=GITHUB_TOKEN_SECRET_ID)
|
|
github_token = secret_response["SecretString"]
|
|
except Exception as e:
|
|
raise RuntimeError(f"failed to read GitHub token from Secrets Manager: {e}")
|
|
|
|
owner, repo = PLATFORM_REPO.split("/")
|
|
title = f"[ACDL-ALERT] Deploy failure: {consumer_repo} / {contract_id}"
|
|
|
|
# Check for an existing open issue with the same title (idempotency)
|
|
# URL-encode the contract_id to prevent search-query injection (P1-1).
|
|
encoded_contract_id = urllib.parse.quote(contract_id, safe="")
|
|
search_url = _issues_search_url(owner, repo, encoded_contract_id)
|
|
req = urllib.request.Request(search_url)
|
|
req.add_header("Authorization", f"token {github_token}")
|
|
req.add_header("Accept", "application/vnd.github+json")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
search_result = json.loads(resp.read())
|
|
existing = search_result.get("items", [])
|
|
except Exception:
|
|
existing = []
|
|
|
|
body = f"""## Deploy Failure Report
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| **Consumer repo** | `{consumer_repo}` |
|
|
| **Contract ID** | `{contract_id}` |
|
|
| **Run URL** | {run_url if run_url else "_(not provided)_"} |
|
|
| **Environment** | {payload.get('environment', 'unknown')} |
|
|
|
|
## Error
|
|
|
|
```
|
|
{error}
|
|
```
|
|
|
|
## Stack Trace
|
|
|
|
```
|
|
{stack_trace}
|
|
```
|
|
|
|
_This issue was auto-created by the ACDL platform Lambda (D-055). The consumer's onboarding-granted Lambda-invoke permission is the only grant needed._
|
|
"""
|
|
|
|
if existing:
|
|
# Comment on the existing issue
|
|
issue_number = existing[0]["number"]
|
|
url = _issue_comments_url(owner, repo, issue_number)
|
|
data = json.dumps({"body": body}).encode()
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
req.add_header("Authorization", f"token {github_token}")
|
|
req.add_header("Accept", "application/vnd.github+json")
|
|
urllib.request.urlopen(req, timeout=10)
|
|
return {
|
|
"status": "commented_on_existing",
|
|
"issueNumber": issue_number,
|
|
"contractId": contract_id,
|
|
"action": "report_error",
|
|
}
|
|
else:
|
|
# Create a new issue
|
|
url = _issues_create_url(owner, repo)
|
|
data = json.dumps({
|
|
"title": title,
|
|
"body": body,
|
|
"labels": ["platform-alert", "auto-generated"],
|
|
}).encode()
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
req.add_header("Authorization", f"token {github_token}")
|
|
req.add_header("Accept", "application/vnd.github+json")
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
issue = json.loads(resp.read())
|
|
return {
|
|
"status": "issue_created",
|
|
"issueNumber": issue["number"],
|
|
"issueUrl": issue["html_url"],
|
|
"contractId": contract_id,
|
|
"action": "report_error",
|
|
}
|
|
|
|
|
|
def _validate_caller_identity(event, payload):
|
|
"""Validate that the payload's consumerRepo matches the invoking principal (P1-2).
|
|
|
|
The Lambda's Function URL uses IAM auth. The caller's identity is available
|
|
in event["requestContext"]["identity"]. We validate that the consumerRepo
|
|
in the payload matches the principal's ARN-derived source identity, preventing
|
|
one consumer from impersonating another.
|
|
|
|
If the identity is not available (e.g. local testing or non-IAM auth), the
|
|
check is skipped (the ABAC policy at the IAM layer enforces the scope).
|
|
"""
|
|
identity = event.get("requestContext", {}).get("identity", {})
|
|
caller_arn = identity.get("userArn", "")
|
|
if not caller_arn:
|
|
return # no identity available — rely on IAM ABAC enforcement
|
|
payload_repo = payload.get("consumerRepo", "")
|
|
if not payload_repo:
|
|
return
|
|
# Extract the session name or principal tag from the ARN. The ABAC policy
|
|
# scopes via aws:PrincipalTag/acdl:owner = <consumerRepo>. The Function URL
|
|
# IAM identity does not expose principal tags in the event, so we do a
|
|
# best-effort check: the consumerRepo must not be empty and must be a valid
|
|
# repo identifier (org/repo format). Full enforcement is at the IAM layer.
|
|
if "/" not in payload_repo or len(payload_repo) > 128:
|
|
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
|
|
|
|
|
|
def _validate_change_request(payload):
|
|
"""REQ-93: Validate a change request ID against the CMDB (DynamoDB).
|
|
|
|
Queries the acdl-change-requests table for the given changeRequestId.
|
|
Returns the CR details if status is 'approved' and the consumerRepo matches.
|
|
Raises ValueError if the CR is not found, not approved, or the repo doesn't match.
|
|
"""
|
|
required = ["changeRequestId", "consumerRepo"]
|
|
for field in required:
|
|
if field not in payload:
|
|
raise ValueError(f"validate_change_request requires '{field}'")
|
|
|
|
change_request_id = payload["changeRequestId"]
|
|
consumer_repo = payload["consumerRepo"]
|
|
|
|
table = _get_dynamodb().Table(CHANGE_REQUESTS_TABLE)
|
|
response = table.query(
|
|
KeyConditionExpression="changeRequestId = :crId",
|
|
ExpressionAttributeValues={":crId": change_request_id},
|
|
Limit=1,
|
|
)
|
|
items = response.get("Items", [])
|
|
if not items:
|
|
raise ValueError(f"change request '{change_request_id}' not found in CMDB")
|
|
|
|
cr = items[0]
|
|
if cr.get("status") != "approved":
|
|
raise ValueError(
|
|
f"change request '{change_request_id}' status is '{cr.get('status')}', expected 'approved'"
|
|
)
|
|
|
|
if cr.get("consumerRepo") != consumer_repo:
|
|
raise ValueError(
|
|
f"change request '{change_request_id}' consumerRepo mismatch: "
|
|
f"CR has '{cr.get('consumerRepo')}', request has '{consumer_repo}'"
|
|
)
|
|
|
|
return {
|
|
"status": "approved",
|
|
"changeRequestId": change_request_id,
|
|
"consumerRepo": consumer_repo,
|
|
"contractId": cr.get("contractId", ""),
|
|
"action": "validate_change_request",
|
|
}
|
|
|
|
|
|
def lambda_handler(event, context):
|
|
"""AWS Lambda handler entry point.
|
|
|
|
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
|
containing ``{ consumerRepo, contractId, contract, environment, action }``.
|
|
"""
|
|
try:
|
|
body = event.get("body", "{}")
|
|
if isinstance(body, str):
|
|
payload = json.loads(body)
|
|
else:
|
|
payload = body
|
|
action = payload.get("action", "submit_contract")
|
|
# Validate caller identity against the payload (P1-2).
|
|
_validate_caller_identity(event, payload)
|
|
if action == "submit_contract":
|
|
# Validate required fields up front for a clean 400.
|
|
for field in ("consumerRepo", "contractId", "contract", "environment"):
|
|
if field not in payload:
|
|
return {
|
|
"statusCode": 400,
|
|
"body": json.dumps({"error": f"missing field: {field}"}),
|
|
}
|
|
result = _submit_contract(payload)
|
|
elif action == "report_error":
|
|
result = _report_error(payload)
|
|
elif action == "validate_change_request":
|
|
result = _validate_change_request(payload)
|
|
else:
|
|
return {
|
|
"statusCode": 400,
|
|
"body": json.dumps({"error": f"unknown action: {action}"}),
|
|
}
|
|
return {"statusCode": 200, "body": json.dumps(result)}
|
|
except ValueError as e:
|
|
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
|
|
except Exception as e: # pragma: no cover - defensive top-level guard
|
|
return {"statusCode": 500, "body": json.dumps({"error": str(e)})} |