07c0349131
Phase 24 — platform-lambda-and-contract-ingestion. - core/lambda/contract_ingestor.py: AWS Lambda handler invoked via Function URL (IAM auth). Parses JSON body, validates required fields, writes the contract to DynamoDB table acdl-contracts (PK consumerRepo, SK contractId#submittedAt, status submitted, ISO-8601 submittedAt). report_error action is a stub returning "error_report_prepared"; GitHub issue creation is wired in Phase 25. Returns 400 on missing fields / unknown action, 500 on error. Table name + GitHub-token secret ID come from env (set by Terraform). - core/lambda/__init__.py: empty package marker. - terraform/platform/main.tf: DynamoDB acdl-contracts (PITR, SSE via CMK), KMS customer-managed key with alias/acdl-platform, Secrets Manager secret acdl/github-token, IAM execution role (DynamoDB write + Secrets Manager read + KMS decrypt + CloudWatch logs), Lambda acdl-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler), Function URL with AWS_IAM auth. State key platform/terraform.tfstate (distinct from spike/microservice). - terraform/platform/README.md: documents what it deploys, the state key, how to apply, and the cross-account invocation model. - terraform/platform/consumer_invoke_policy.json: ABAC-scoped policy template applied to consumer deploy roles during onboarding; grants lambda:InvokeFunctionUrl conditioned on aws:PrincipalTag/acdl:owner == consumerRepo. - tests/test_contract_ingestor.py: 11 tests (moto-backed DynamoDB mock) covering submit_contract put_item shape, report_error stub, missing-field 400, unknown action 400, the lambda_handler wrapper with a Function-URL-style event, dict body, default action, and internal-error 500. - docs/environments/index.md: new section documenting the cross-account contract-ingestion grant (one-way consumer→platform, D-051) and that onboarding now also grants the consumer deploy role InvokeFunctionUrl. - scripts/run_ci.sh, pipelines/ci.yaml, .gitea/workflows/ci.yml, .github/workflows/ci.yml: add core/lambda/contract_ingestor.py to the lint py_compile list. The two workflow YAMLs remain byte-identical. Verification: scripts/run_ci.sh passes all 3 stages (lint/test/check-only); python3 -m pytest tests/ -v passes all 213 tests (11 new + 202 existing). ---ci--- project: acdl phase: 24 milestone: v1.7 status: execute ---/ci---
120 lines
4.0 KiB
Python
120 lines
4.0 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) is prepared as a stub in this phase; the
|
|
GitHub issue creation is implemented in Phase 25.
|
|
|
|
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 boto3
|
|
|
|
TABLE_NAME = os.environ.get("CONTRACTS_TABLE", "acdl-contracts")
|
|
GITHUB_TOKEN_SECRET_ID = os.environ.get("GITHUB_TOKEN_SECRET_ID", "acdl/github-token")
|
|
PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "acdl/acdl")
|
|
|
|
_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 _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):
|
|
# Phase 25 implements the GitHub issue creation.
|
|
# This stub validates the payload and returns a prepared status.
|
|
required = ["consumerRepo", "contractId", "error"]
|
|
for field in required:
|
|
if field not in payload:
|
|
raise ValueError(f"report_error requires '{field}'")
|
|
return {
|
|
"status": "error_report_prepared",
|
|
"contractId": payload["contractId"],
|
|
"action": "report_error",
|
|
}
|
|
|
|
|
|
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")
|
|
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)
|
|
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)})} |