"""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 nova-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.error import urllib.parse import boto3 TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "nova-contracts") CHANGE_REQUESTS_TABLE = os.environ.get("CHANGE_REQUESTS_TABLE", "nova-change-requests") GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "nova/github-token") PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/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 urllib.error.HTTPError as e: if e.code == 404: existing = [] else: import sys print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr) existing = [] except urllib.error.URLError as e: import sys print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr) 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). v1.14 (REQ-144): also validates contractId format, environment enum, and error length. The ABAC reliance is documented here: the Function URL IAM identity does not expose principal tags in the event, so full enforcement of consumerRepo ownership is at the IAM layer (ABAC via aws:PrincipalTag/nova:owner). This function validates format only, not ownership. """ identity = event.get("requestContext", {}).get("identity", {}) caller_arn = identity.get("userArn", "") if not caller_arn: pass # no identity available — rely on IAM ABAC enforcement payload_repo = payload.get("consumerRepo", "") if payload_repo: # consumerRepo must be org/repo format, <=128 chars if "/" not in payload_repo or len(payload_repo) > 128: raise ValueError(f"invalid consumerRepo format: {payload_repo!r}") # v1.14 (REQ-144): contractId format validation contract_id = payload.get("contractId", "") if contract_id: import re if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id): raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)") # v1.14 (REQ-144): environment enum validation environment = payload.get("environment", "") if environment: valid_envs = {"dev", "qa", "prod", "dr"} if environment not in valid_envs: raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})") # v1.14 (REQ-144): error length cap (for report_error action) error_msg = payload.get("error", "") if error_msg and len(str(error_msg)) > 10000: payload["error"] = str(error_msg)[:10000] def _validate_change_request(payload): """REQ-93: Validate a change request ID against the CMDB (DynamoDB). Queries the nova-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)})}