feat(P24): platform Lambda + DynamoDB contract ingestion + cross-account IAM
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---
This commit is contained in:
@@ -38,6 +38,7 @@ jobs:
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
scripts/push_consumer_image.py
|
||||
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
scripts/push_consumer_image.py
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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)})}
|
||||
@@ -53,6 +53,41 @@ normally.
|
||||
attestation (a platform-runner deployment approval) and a higher confidence
|
||||
threshold. Staging does not exist.
|
||||
|
||||
## Cross-account contract ingestion grant (D-051)
|
||||
|
||||
Onboarding now also grants the consumer repo's deploy role permission to
|
||||
invoke the **platform Lambda** — `acdl-contract-ingestor` — across
|
||||
accounts. The Lambda is invoked via a Function URL with IAM auth, so the
|
||||
grant is an inline IAM policy applied to the consumer's deploy role. The
|
||||
policy template lives at
|
||||
[`terraform/platform/consumer_invoke_policy.json`](https://github.com/acdl/acdl/blob/main/terraform/platform/consumer_invoke_policy.json)
|
||||
and is scoped via **ABAC**: the condition
|
||||
`aws:PrincipalTag/acdl:owner == ${consumerRepo}` ensures a repo can only
|
||||
invoke the Lambda when its principal tag matches its claimed identity.
|
||||
|
||||
The consumer's deploy workflow signs the Function URL request with
|
||||
SigV4 using its deploy-role credentials; the platform Lambda validates
|
||||
the signature and the ABAC condition before accepting the payload.
|
||||
|
||||
This is a **one-way** channel — the consumer pushes contracts *to* the
|
||||
platform; the platform never reaches back into the consumer account. It
|
||||
is used for two purposes:
|
||||
|
||||
1. **Contract ingestion** — the consumer submits its resolved deployment
|
||||
contract (`action: "submit_contract"`) so the platform has a durable
|
||||
record in the `acdl-contracts` DynamoDB table (PK `consumerRepo`, SK
|
||||
`contractId#submittedAt`).
|
||||
2. **Error reporting** (D-055) — the consumer reports a deployment error
|
||||
(`action: "report_error"`) which the platform turns into a GitHub
|
||||
issue on the platform repo (wired in Phase 25; the Lambda returns a
|
||||
prepared-status stub until then).
|
||||
|
||||
The Lambda handler and the Terraform that deploys it live in
|
||||
[`core/lambda/contract_ingestor.py`](https://github.com/acdl/acdl/blob/main/core/lambda/contract_ingestor.py)
|
||||
and
|
||||
[`terraform/platform/main.tf`](https://github.com/acdl/acdl/blob/main/terraform/platform/main.tf)
|
||||
respectively.
|
||||
|
||||
## Onboarding scaffold (current state)
|
||||
|
||||
The platform repo ships a minimal onboarding scaffold:
|
||||
|
||||
@@ -32,6 +32,7 @@ stages:
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
scripts/push_consumer_image.py
|
||||
|
||||
@@ -45,6 +45,7 @@ python3 -m py_compile \
|
||||
core/confidence_signal.py \
|
||||
core/outbox_writer.py \
|
||||
core/contract_resolver.py \
|
||||
core/lambda/contract_ingestor.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
adapters/terraform/policy/custom_rules/acdl_tagging.py \
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# ACDL Platform Infrastructure (D-051)
|
||||
|
||||
Terraform configuration for the **platform-side** infrastructure that
|
||||
ingests consumer deployment contracts and (Phase 25) reports errors as
|
||||
GitHub issues.
|
||||
|
||||
This stack is **separate** from `terraform/spike/` (the consumer stack
|
||||
spike) and `terraform/microservice/` (the demo microservice). It manages
|
||||
resources that live in the platform AWS account and serve **all**
|
||||
consumers — the contract ingestion pipeline and the secrets it needs.
|
||||
|
||||
## What it deploys
|
||||
|
||||
| Resource | Name | Purpose |
|
||||
|----------|------|---------|
|
||||
| `aws_dynamodb_table` | `acdl-contracts` | Stores submitted consumer contracts. PK `consumerRepo`, SK `contractId#submittedAt`. SSE via CMK, PITR enabled. |
|
||||
| `aws_kms_key` + `aws_kms_alias` | `alias/acdl-platform` | Customer-managed key — encrypts DynamoDB SSE, Secrets Manager, and SSM. Key rotation enabled. |
|
||||
| `aws_secretsmanager_secret` | `acdl/github-token` | GitHub PAT used by the Lambda to create issues on the platform repo (D-055, wired in Phase 25). |
|
||||
| `aws_iam_role` + `aws_iam_role_policy` | `acdl-contract-ingestor-role` | Execution role for the Lambda — DynamoDB write, Secrets Manager read, KMS decrypt, CloudWatch logs. |
|
||||
| `aws_lambda_function` | `acdl-contract-ingestor` | Python 3.12 Lambda. Handler `contract_ingestor.lambda_handler`. Source: `core/lambda/contract_ingestor.py`, packaged as `contract_ingestor.zip`. |
|
||||
| `aws_lambda_function_url` | — | Function URL with `AWS_IAM` authorization. Consumers invoke it via SigV4-signed requests. |
|
||||
|
||||
## State
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| Backend | S3 |
|
||||
| Bucket | `acdl-tfstate-581513795199-us-east-1` |
|
||||
| State key | `platform/terraform.tfstate` |
|
||||
| Region | `us-east-1` |
|
||||
|
||||
The state key is distinct from `spike/terraform.tfstate` and
|
||||
`microservice/terraform.tfstate` — the three stacks are independent.
|
||||
|
||||
## Apply
|
||||
|
||||
```bash
|
||||
# Package the Lambda source first (from the repo root):
|
||||
cd core/lambda
|
||||
zip contract_ingestor.zip contract_ingestor.py
|
||||
cd ../../terraform/platform
|
||||
|
||||
terraform init
|
||||
terraform plan
|
||||
terraform apply
|
||||
```
|
||||
|
||||
The Lambda's `filename` points at `contract_ingestor.zip` in the working
|
||||
directory (`terraform/platform/`); either place the zip there or adjust
|
||||
the path. `source_code_hash = filebase64sha256("contract_ingestor.zip")`
|
||||
forces a redeploy whenever the package changes.
|
||||
|
||||
## Cross-account invocation model
|
||||
|
||||
The Lambda is invoked **cross-account** by consumer pipelines. The
|
||||
flow:
|
||||
|
||||
1. **Onboarding.** When a consumer repo is onboarded, the platform team
|
||||
applies [`consumer_invoke_policy.json`](./consumer_invoke_policy.json)
|
||||
to the consumer's deploy role. The policy grants
|
||||
`lambda:InvokeFunctionUrl` on the Lambda ARN, scoped via ABAC — the
|
||||
condition `aws:PrincipalTag/acdl:owner == ${consumerRepo}` ensures a
|
||||
repo can only invoke when it is the owner it claims to be.
|
||||
2. **Runtime.** The consumer's deploy workflow (running in the consumer
|
||||
AWS account under the consumer's deploy role) signs the Function URL
|
||||
request with SigV4 using its deploy-role credentials. The IAM auth on
|
||||
the Function URL validates the signature and the ABAC condition.
|
||||
3. **Lambda.** The Lambda parses the JSON body, validates the fields,
|
||||
and writes the contract to `acdl-contracts`.
|
||||
|
||||
This is a **one-way** channel (D-051): the consumer pushes contracts
|
||||
*to* the platform; the platform never reaches back into the consumer
|
||||
account. Error reporting (D-055, `action: "report_error"`) flows over
|
||||
the same channel and is implemented in Phase 25 (GitHub issue creation on
|
||||
the platform repo).
|
||||
|
||||
## Related files
|
||||
|
||||
- [`core/lambda/contract_ingestor.py`](../../core/lambda/contract_ingestor.py) — the Lambda handler.
|
||||
- [`tests/test_contract_ingestor.py`](../../tests/test_contract_ingestor.py) — unit tests (moto-backed DynamoDB mock).
|
||||
- [`terraform/platform/consumer_invoke_policy.json`](./consumer_invoke_policy.json) — the ABAC policy applied to consumer deploy roles during onboarding.
|
||||
- [`docs/environments/index.md`](../../docs/environments/index.md) — documents the cross-account grant as part of onboarding.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "lambda:InvokeFunctionUrl",
|
||||
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:acdl-contract-ingestor",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:PrincipalTag/acdl:owner": "${consumerRepo}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# ACDL platform infrastructure — contract ingestion Lambda + DynamoDB (D-051)
|
||||
#
|
||||
# Deploys:
|
||||
# - DynamoDB table acdl-contracts (PK consumerRepo, SK contractId#submittedAt, SSE via CMK, PITR)
|
||||
# - KMS customer-managed key for DynamoDB + SSM (shared CMK)
|
||||
# - Lambda function acdl-contract-ingestor (Python 3.12, handler contract_ingestor.lambda_handler)
|
||||
# - Lambda Function URL (IAM auth — consumers invoke via SigV4)
|
||||
# - Secrets Manager secret acdl/github-token (stores the Lambda's GitHub PAT for issue creation)
|
||||
# - IAM execution role for the Lambda (DynamoDB write + Secrets Manager read + KMS decrypt)
|
||||
#
|
||||
# State: terraform/platform/terraform.tfstate (separate from spike/ and microservice/)
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.9, < 1.10"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
backend "s3" {
|
||||
bucket = "acdl-tfstate-581513795199-us-east-1"
|
||||
key = "platform/terraform.tfstate"
|
||||
region = "us-east-1"
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
# KMS customer-managed key for DynamoDB SSE + SSM Parameter Store encryption
|
||||
resource "aws_kms_key" "acdl_platform" {
|
||||
description = "ACDL platform KMS key (DynamoDB SSE + SSM + Secrets Manager)"
|
||||
enable_key_rotation = true
|
||||
deletion_window_in_days = 30
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "acdl_platform" {
|
||||
name = "alias/acdl-platform"
|
||||
target_key_id = aws_kms_key.acdl_platform.key_id
|
||||
}
|
||||
|
||||
# DynamoDB table for contract ingestion
|
||||
resource "aws_dynamodb_table" "acdl_contracts" {
|
||||
name = "acdl-contracts"
|
||||
billing_mode = "PAY_PER_REQUEST"
|
||||
hash_key = "consumerRepo"
|
||||
range_key = "contractId#submittedAt"
|
||||
|
||||
attribute {
|
||||
name = "consumerRepo"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
attribute {
|
||||
name = "contractId#submittedAt"
|
||||
type = "S"
|
||||
}
|
||||
|
||||
point_in_time_recovery {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
server_side_encryption {
|
||||
enabled = true
|
||||
kms_key_arn = aws_kms_key.acdl_platform.arn
|
||||
}
|
||||
|
||||
tags = {
|
||||
acdl:owner = "acdl"
|
||||
acdl:contract = "platform"
|
||||
acdl:environment = "prod"
|
||||
acdl:cost-center = "acdl-default"
|
||||
}
|
||||
}
|
||||
|
||||
# Secrets Manager secret for the Lambda's GitHub token (issue creation)
|
||||
resource "aws_secretsmanager_secret" "github_token" {
|
||||
name = "acdl/github-token"
|
||||
description = "GitHub PAT for the platform Lambda to create issues on the platform repo (D-055)."
|
||||
kms_key_id = aws_kms_key.acdl_platform.arn
|
||||
|
||||
tags = {
|
||||
acdl:owner = "acdl"
|
||||
acdl:contract = "platform"
|
||||
acdl:environment = "prod"
|
||||
acdl:cost-center = "acdl-default"
|
||||
}
|
||||
}
|
||||
|
||||
# IAM execution role for the Lambda
|
||||
resource "aws_iam_role" "lambda_exec" {
|
||||
name = "acdl-contract-ingestor-role"
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Action = "sts:AssumeRole"
|
||||
Effect = "Allow"
|
||||
Principal = { Service = "lambda.amazonaws.com" }
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "lambda_permissions" {
|
||||
name = "acdl-contract-ingestor-policy"
|
||||
role = aws_iam_role.lambda_exec.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Query", "dynamodb:UpdateItem"]
|
||||
Resource = aws_dynamodb_table.acdl_contracts.arn
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
Resource = aws_secretsmanager_secret.github_token.arn
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["kms:Decrypt"]
|
||||
Resource = aws_kms_key.acdl_platform.arn
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
|
||||
Resource = "arn:aws:logs:*:*:*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
# Lambda function
|
||||
resource "aws_lambda_function" "contract_ingestor" {
|
||||
function_name = "acdl-contract-ingestor"
|
||||
handler = "contract_ingestor.lambda_handler"
|
||||
runtime = "python3.12"
|
||||
role = aws_iam_role.lambda_exec.arn
|
||||
filename = "contract_ingestor.zip"
|
||||
source_code_hash = filebase64sha256("contract_ingestor.zip")
|
||||
|
||||
environment {
|
||||
variables = {
|
||||
CONTRACTS_TABLE = aws_dynamodb_table.acdl_contracts.name
|
||||
GITHUB_TOKEN_SECRET_ID = aws_secretsmanager_secret.github_token.name
|
||||
PLATFORM_REPO = "acdl/acdl"
|
||||
}
|
||||
}
|
||||
|
||||
tags = {
|
||||
acdl:owner = "acdl"
|
||||
acdl:contract = "platform"
|
||||
acdl:environment = "prod"
|
||||
acdl:cost-center = "acdl-default"
|
||||
}
|
||||
}
|
||||
|
||||
# Lambda Function URL (IAM auth — consumers invoke via SigV4)
|
||||
resource "aws_lambda_function_url" "contract_ingestor" {
|
||||
function_name = aws_lambda_function.contract_ingestor.function_name
|
||||
authorization_type = "AWS_IAM"
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Unit tests for core/lambda/contract_ingestor.py.
|
||||
|
||||
The source file lives at ``core/lambda/contract_ingestor.py`` for repo
|
||||
organization, but ``lambda`` is a Python reserved word — so the package
|
||||
path ``core.lambda`` cannot be imported with normal ``import`` syntax.
|
||||
The Lambda runtime packages the handler as a top-level
|
||||
``contract_ingestor.py`` (handler ``contract_ingestor.lambda_handler``),
|
||||
which is the name the Terraform ``handler`` attribute uses. The tests
|
||||
mirror that by loading the module from its file path under the name
|
||||
``contract_ingestor``.
|
||||
|
||||
Uses moto (already a test dependency — see requirements-test.txt) to mock
|
||||
DynamoDB, mirroring the pattern in tests/test_outbox_writer.py.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Load core/lambda/contract_ingestor.py as a top-level module named
|
||||
# `contract_ingestor` (the name the Lambda runtime uses).
|
||||
_SOURCE_PATH = Path(__file__).resolve().parent.parent / "core" / "lambda" / "contract_ingestor.py"
|
||||
_spec = importlib.util.spec_from_file_location("contract_ingestor", _SOURCE_PATH)
|
||||
ingestor = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ingestor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def sample_payload():
|
||||
return {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "contract-001",
|
||||
"contract": {"stack": "s3", "environment": "dev"},
|
||||
"environment": "dev",
|
||||
"action": "submit_contract",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def function_url_event(sample_payload):
|
||||
return {"body": json.dumps(sample_payload)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moto_contracts_table(monkeypatch):
|
||||
"""Spin up a moto-backed DynamoDB and point the ingestor at it."""
|
||||
from moto import mock_aws
|
||||
import boto3
|
||||
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
|
||||
with mock_aws():
|
||||
dyn = boto3.client("dynamodb", region_name="us-east-1")
|
||||
dyn.create_table(
|
||||
TableName="acdl-contracts",
|
||||
KeySchema=[
|
||||
{"AttributeName": "consumerRepo", "KeyType": "HASH"},
|
||||
{"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"},
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{"AttributeName": "consumerRepo", "AttributeType": "S"},
|
||||
{"AttributeName": "contractId#submittedAt", "AttributeType": "S"},
|
||||
],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
|
||||
# Reset the cached boto3 clients so the ingestor picks up the moto
|
||||
# session, then yield with moto active.
|
||||
saved_dynamodb = ingestor._dynamodb
|
||||
saved_secrets = ingestor._secrets_client
|
||||
ingestor._dynamodb = None
|
||||
ingestor._secrets_client = None
|
||||
monkeypatch.setattr(ingestor, "TABLE_NAME", "acdl-contracts")
|
||||
|
||||
yield dyn
|
||||
|
||||
ingestor._dynamodb = saved_dynamodb
|
||||
ingestor._secrets_client = saved_secrets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# submit_contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSubmitContract:
|
||||
def test_submit_contract_writes_correct_pk_sk_attributes(self, moto_contracts_table, sample_payload):
|
||||
result = ingestor._submit_contract(sample_payload)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["contractId"] == "contract-001"
|
||||
assert result["action"] == "submit_contract"
|
||||
assert "submittedAt" in result
|
||||
|
||||
# Verify what landed in DynamoDB.
|
||||
sk = f"contract-001#{result['submittedAt']}"
|
||||
resp = moto_contracts_table.get_item(
|
||||
TableName="acdl-contracts",
|
||||
Key={
|
||||
"consumerRepo": {"S": "acdl/consumer-a"},
|
||||
"contractId#submittedAt": {"S": sk},
|
||||
},
|
||||
)
|
||||
assert "Item" in resp
|
||||
item = resp["Item"]
|
||||
assert item["consumerRepo"]["S"] == "acdl/consumer-a"
|
||||
assert item["contractId"]["S"] == "contract-001"
|
||||
assert item["status"]["S"] == "submitted"
|
||||
assert item["environment"]["S"] == "dev"
|
||||
assert item["submittedAt"]["S"] == result["submittedAt"]
|
||||
# The contract attribute holds the full contract object. boto3's
|
||||
# resource API serializes a dict as a DynamoDB Map (type "M"); each
|
||||
# leaf scalar is wrapped in its own type tag.
|
||||
expected_contract = sample_payload["contract"]
|
||||
actual_contract = item["contract"]
|
||||
# The resource API stores scalars inside the map with their own type
|
||||
# tags (e.g. {"S": ...}); unwrap one level for the two known leaves.
|
||||
unwrapped = {
|
||||
k: list(v.values())[0] if isinstance(v, dict) and len(v) == 1 else v
|
||||
for k, v in actual_contract["M"].items()
|
||||
}
|
||||
assert unwrapped == expected_contract
|
||||
|
||||
def test_submit_contract_sk_contains_contract_id_and_timestamp(self, moto_contracts_table, sample_payload):
|
||||
result = ingestor._submit_contract(sample_payload)
|
||||
sk = f"contract-001#{result['submittedAt']}"
|
||||
# SK format is contractId#ISO8601
|
||||
assert sk.split("#")[0] == "contract-001"
|
||||
# timestamp parses as ISO 8601 with a Z suffix.
|
||||
ts = sk.split("#", 1)[1]
|
||||
datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# report_error stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReportError:
|
||||
def test_report_error_returns_prepared_status(self):
|
||||
payload = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "contract-001",
|
||||
"error": "deploy failed",
|
||||
}
|
||||
result = ingestor._report_error(payload)
|
||||
assert result["status"] == "error_report_prepared"
|
||||
assert result["contractId"] == "contract-001"
|
||||
assert result["action"] == "report_error"
|
||||
|
||||
def test_report_error_missing_field_raises(self):
|
||||
payload = {"consumerRepo": "acdl/consumer-a"} # missing contractId, error
|
||||
with pytest.raises(ValueError):
|
||||
ingestor._report_error(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lambda_handler wrapper (Function URL event)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLambdaHandler:
|
||||
def test_submit_contract_event_returns_200(self, moto_contracts_table, function_url_event):
|
||||
resp = ingestor.lambda_handler(function_url_event, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
assert body["status"] == "ok"
|
||||
assert body["contractId"] == "contract-001"
|
||||
assert body["action"] == "submit_contract"
|
||||
|
||||
def test_body_can_be_dict_not_string(self, moto_contracts_table, sample_payload):
|
||||
# Some test harnesses pass body as a dict already.
|
||||
resp = ingestor.lambda_handler({"body": sample_payload}, None)
|
||||
assert resp["statusCode"] == 200
|
||||
|
||||
def test_missing_field_returns_400(self, moto_contracts_table):
|
||||
payload = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
# missing contractId, contract, environment
|
||||
}
|
||||
resp = ingestor.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert resp["statusCode"] == 400
|
||||
body = json.loads(resp["body"])
|
||||
assert "missing field" in body["error"]
|
||||
|
||||
def test_missing_required_field_contract(self, moto_contracts_table, sample_payload):
|
||||
del sample_payload["contract"]
|
||||
resp = ingestor.lambda_handler({"body": json.dumps(sample_payload)}, None)
|
||||
assert resp["statusCode"] == 400
|
||||
assert "contract" in json.loads(resp["body"])["error"]
|
||||
|
||||
def test_unknown_action_returns_400(self, moto_contracts_table):
|
||||
payload = {
|
||||
"consumerRepo": "acdl/consumer-a",
|
||||
"contractId": "contract-001",
|
||||
"contract": {},
|
||||
"environment": "dev",
|
||||
"action": "do_something_else",
|
||||
}
|
||||
resp = ingestor.lambda_handler({"body": json.dumps(payload)}, None)
|
||||
assert resp["statusCode"] == 400
|
||||
body = json.loads(resp["body"])
|
||||
assert "unknown action" in body["error"]
|
||||
|
||||
def test_default_action_is_submit_contract(self, moto_contracts_table, sample_payload):
|
||||
del sample_payload["action"]
|
||||
resp = ingestor.lambda_handler({"body": json.dumps(sample_payload)}, None)
|
||||
assert resp["statusCode"] == 200
|
||||
body = json.loads(resp["body"])
|
||||
assert body["action"] == "submit_contract"
|
||||
|
||||
def test_internal_error_returns_500(self, moto_contracts_table, sample_payload):
|
||||
# Force _submit_contract to blow up after passing validation.
|
||||
with mock.patch.object(ingestor, "_submit_contract", side_effect=RuntimeError("boom")):
|
||||
resp = ingestor.lambda_handler(
|
||||
{"body": json.dumps(sample_payload)}, None
|
||||
)
|
||||
assert resp["statusCode"] == 500
|
||||
body = json.loads(resp["body"])
|
||||
assert body["error"] == "boom"
|
||||
Reference in New Issue
Block a user