"""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 compatible forge API root (e.g. https://forge.example.com/api/v1). GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com") # P11 (REQ-175): consistent cap for error/stackTrace fields (was 10k vs 2k). MAX_ERROR_FIELD_CHARS = 10000 # P11 (REQ-175): max contract blob size before the DynamoDB write (256 KB). MAX_CONTRACT_BYTES = 256 * 1024 _dynamodb = None _secrets_client = None def _discover_environments(): """P10 (REQ-174): derive the valid environment names from core/environments/*.json (the directory is the single source of truth, not a hardcoded set). Falls back to {'dev','qa','prod','dr'} if the directory is not readable (e.g. packaged Lambda without the dir). """ env_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))), "core", "environments") try: names = {f[:-5] for f in os.listdir(env_dir) if f.endswith(".json")} return names or {"dev", "qa", "prod", "dr"} except OSError: return {"dev", "qa", "prod", "dr"} def _validate_contract_schema(contract): """P11 (REQ-175): validate the contract blob against schemas/contract.schema.json before the DynamoDB write. Raises ValueError on invalid. Falls back to a no-op if the schema or jsonschema is unavailable (e.g. packaged Lambda without the schema). """ try: import json as _json import jsonschema schema_path = os.path.join(os.path.dirname(os.path.dirname( os.path.dirname(os.path.abspath(__file__)))), "schemas", "contract.schema.json") with open(schema_path) as f: schema = _json.load(f) jsonschema.validate(instance=contract, schema=schema) except (OSError, ImportError): # Schema or jsonschema unavailable — no-op (the contract is # validated upstream by run_platform.sh in the normal path). pass except jsonschema.ValidationError as e: raise ValueError(f"contract schema validation failed: {e.message}") 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(): """Detect whether the API base is GitHub or a compatible forge. Compatible forge API roots contain '/api/v1'; GitHub's is 'api.github.com'. """ if "/api/v1" in GITHUB_API_BASE: return "generic_forge" return "github" def _issues_search_url(owner, repo, encoded_query): """Build the issue search URL based on forge type. GitHub uses /search/issues?q=...; compatible forges use /repos/{owner}/{repo}/issues?... with query params (no /search/issues endpoint). """ if _forge_type() == "generic_forge": 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 across forges).""" 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"] # P11 (REQ-175): size-cap the contract blob before the DynamoDB write # (unbounded payload → write amplification). 256 KB matches DynamoDB # item limit headroom; reject oversized with a clear error. import json as _json contract_json = _json.dumps(contract).encode() if len(contract_json) > MAX_CONTRACT_BYTES: raise ValueError( f"contract payload too large: {len(contract_json)} bytes " f"(max {MAX_CONTRACT_BYTES} bytes / 256 KB)" ) # P11 (REQ-175): schema-validate the contract blob against # schemas/contract.schema.json before the write. Reject invalid with 400. # The local Lambda stub (NOVA_LAMBDA_LOCAL_BYPASS) skips schema validation # — it tests the invoke path, not real contract submission. if not os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"): _validate_contract_schema(contract) 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", "")[:MAX_ERROR_FIELD_CHARS] # P11: aligned cap # 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"[NOVA-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 Nova 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. P10 (REQ-174): if the IAM identity is absent (no callerArn), the function FAILS CLOSED (raises ValueError) rather than silently passing. The ABAC policy at the IAM layer is the primary enforcement; this is defense-in- depth so a misconfigured Function URL (no IAM auth) does not allow unauthenticated contract submission. Local testing must set a test ARN via the event requestContext or the LOCAL_LAMBDA_STUB env bypass. v1.14 (REQ-144): also validates contractId format, environment enum, and error length. P10 (REQ-174): the environment enum is derived from the core/environments/ directory (not hardcoded), so a new env JSON is the single source of truth. 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: # P10 (REQ-174): fail closed. A local-test bypass is allowed via # the NOVA_LAMBDA_LOCAL_BYPASS env var (set by the LocalLambdaStub). import os as _os if not _os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"): raise ValueError( "missing IAM caller identity (requestContext.identity.userArn) — " "the Function URL must use IAM auth; refusing unauthenticated submission" ) 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)") # P10 (REQ-174): environment enum derived from core/environments/ (not # hardcoded) — the directory is the single source of truth. environment = payload.get("environment", "") if environment: valid_envs = _discover_environments() if environment not in valid_envs: raise ValueError(f"invalid environment: {environment!r} (must be one of {sorted(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)) > MAX_ERROR_FIELD_CHARS: payload["error"] = str(error_msg)[:MAX_ERROR_FIELD_CHARS] 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 _onboard_consumer(payload): """P18 (REQ-182): accept a self-service onboarding request. Validates the payload against schemas/onboarding.schema.json, then writes a 'pending' row to nova-contracts (D-119). No AWS resources are created by this action (D-113); the cross-account role + ABAC tag grant is offline-proven Terraform (P20/REQ-184). """ import jsonschema schema_path = os.path.join(os.path.dirname(os.path.dirname( os.path.dirname(os.path.abspath(__file__)))), "schemas", "onboarding.schema.json") try: with open(schema_path) as f: schema = json.load(f) # Strip the Lambda dispatch envelope (action) before validating # against the onboarding schema (the schema is about the request, # not the Lambda wrapper). onboarding_payload = {k: v for k, v in payload.items() if k != "action"} jsonschema.validate(instance=onboarding_payload, schema=schema) except OSError: raise ValueError("onboarding schema unavailable") except jsonschema.ValidationError as e: raise ValueError(f"onboarding payload invalid: {e.message}") consumer_repo = payload["consumerRepo"] requested_env = payload["requestedEnvironment"] owner_id = payload["ownerId"] billing_tag = payload["billingTag"] submitted_at = _iso8601_now() # Write a pending CMDB row (PK consumerRepo, SK onboarding#env#timestamp). table = _get_dynamodb().Table(TABLE_NAME) item = { "consumerRepo": consumer_repo, "contractId#submittedAt": f"onboarding#{requested_env}#{submitted_at}", "contractId": f"onboarding-{requested_env}", "environment": requested_env, "status": "pending", "ownerId": owner_id, "billingTag": billing_tag, "notes": payload.get("notes", ""), "submittedAt": submitted_at, } table.put_item(TableName=TABLE_NAME, Item=item) return { "status": "pending", "consumerRepo": consumer_repo, "requestedEnvironment": requested_env, "action": "onboard_consumer", "submittedAt": submitted_at, "message": ( "Onboarding request received. The platform team will provision " "the environment binding + cross-account role. Track the status " "via the nova-contracts table (status=pending → granted)." ), } def dispatch_action(payload, event=None): """Shared business-logic dispatch for the contract ingestor (REQ-329). Both the AWS Lambda handler (``lambda_handler``) and the CLI path (``cli_main`` / ``__main__``) call this function so the two paths share a single source of truth for action routing, contract validation, the DynamoDB write, and error reporting (NFR-7 — dual-use, single source). Args: payload: the decoded action envelope dict ``{ consumerRepo, contractId, contract, environment, action }``. event: the raw Lambda Function-URL event (used for IAM caller identity validation). When ``None`` (the CLI path), the identity check uses the ``NOVA_LAMBDA_LOCAL_BYPASS`` env var — CLI invocations are local-only and do not carry an IAM principal. Returns: The action result dict (e.g. ``{status, contractId, action, ...}``) on success. Raises ``ValueError`` for validation failures and other exceptions for downstream errors — the caller is responsible for mapping these to the appropriate status code / exit code. """ action = payload.get("action", "submit_contract") # Validate caller identity against the payload (P1-2). The CLI path # passes event=None; the fail-closed check honours the local bypass. _validate_caller_identity(event or {}, 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: raise ValueError(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) elif action == "onboard_consumer": result = _onboard_consumer(payload) else: raise ValueError(f"unknown action: {action}") return result def _to_http_response(result_or_error): """Map a dispatch_action result / exception to a Lambda HTTP response. Shared error→status mapping so both Lambda + CLI paths interpret errors identically (REQ-329 dual-use). """ if isinstance(result_or_error, Exception): msg = str(result_or_error) if isinstance(result_or_error, ValueError): if "missing IAM caller identity" in msg: return {"statusCode": 401, "body": json.dumps({"error": msg})} return {"statusCode": 400, "body": json.dumps({"error": msg})} return {"statusCode": 500, "body": json.dumps({"error": msg})} return {"statusCode": 200, "body": json.dumps(result_or_error)} def lambda_handler(event, context): """AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use). Accepts a Function-URL-style event whose ``body`` is a JSON string containing ``{ consumerRepo, contractId, contract, environment, action }``. Parses the Lambda-specific envelope then delegates to the shared ``dispatch_action`` business logic. """ try: body = event.get("body", "{}") payload = json.loads(body) if isinstance(body, str) else body result = dispatch_action(payload, event=event) return _to_http_response(result) except Exception as e: # pragma: no cover - defensive top-level guard return _to_http_response(e) def cli_main(argv=None): """CLI entry point for the contract ingestor (REQ-329 dual-use). Usage: python3 -m core.lambda.contract_ingestor --dispatch python3 -m core.lambda.contract_ingestor --dispatch-stdin < Parses the CLI-specific input (a JSON file path or stdin) then delegates to the shared ``dispatch_action`` business logic — the same path as the Lambda handler. Returns a process exit code (0 success, 1 validation error, 2 internal error). """ import sys raw = argv if argv is not None else sys.argv[1:] # The --dispatch flag consumes the next positional arg as a payload path; # --dispatch-stdin reads the payload from stdin. if "--dispatch-stdin" in raw: payload = json.loads(sys.stdin.read()) elif "--dispatch" in raw: idx = raw.index("--dispatch") path = raw[idx + 1] if idx + 1 < len(raw) else None if not path: print("Usage: --dispatch ", file=sys.stderr) return 2 with open(path) as fh: payload = json.loads(fh.read()) else: print( "Usage: python3 -m core.lambda.contract_ingestor --dispatch ", file=sys.stderr, ) return 2 try: result = dispatch_action(payload, event=None) sys.stdout.write(json.dumps(result, indent=2) + "\n") return 0 except ValueError as e: sys.stderr.write(f"error: {e}\n") return 1 except Exception as e: # pragma: no cover - defensive top-level guard sys.stderr.write(f"internal error: {e}\n") return 2 # --- CLI: --check-readiness (D-133, REQ-218) + --dispatch (REQ-329) ---- # Invoked as: # python3 -m core.lambda.contract_ingestor --check-readiness # python3 -m core.lambda.contract_ingestor --dispatch # The --check-readiness path delegates to core.submission_readiness; the # --dispatch path is the dual-use CLI entry (REQ-329) that calls the same # dispatch_action() as the Lambda handler. if __name__ == "__main__": # pragma: no cover - CLI entry import sys if "--check-readiness" in sys.argv: sys.path.insert( 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ) from core.submission_readiness import cli_main as _readiness_cli # Strip the --check-readiness flag; pass the file path. rest = [a for a in sys.argv[1:] if a != "--check-readiness"] sys.exit(_readiness_cli(["check-readiness"] + rest)) elif "--dispatch" in sys.argv or "--dispatch-stdin" in sys.argv: sys.exit(cli_main()) else: print( "Usage: python3 -m core.lambda.contract_ingestor " "--check-readiness | --dispatch ", file=sys.stderr, )