tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check working directory and AWS env vars
ls /app/ && echo "---" && env | grep -i aws
--- AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566 [stdout] --- AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566
Check working dir contents and localstack health
ls -la /app && curl -s http://localstack:4566/_localstack/health | head -100
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 28 14:31 ..
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "disabled", "ses": "disabled", "sns": "disabled", "sqs": "available", "ssm": "disabled", "stepfunctions": "disabled", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}
[stdout]
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 28 14:31 ..
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "disabled", "ses": "disabled", "sns": "disabled", "sqs": "available", "ssm": "disabled", "stepfunctions": "disabled", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}Check tool versions
aws --version && python3 --version && which zip jq
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3 /usr/bin/zip /usr/bin/jq [stdout] aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3 /usr/bin/zip /usr/bin/jq
Get account ID
aws sts get-caller-identity
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}Create project layout
mkdir -p /app/lambdas/order_api /app/lambdas/outbox_relay /app/policies /app/build
(Bash completed with no output)
import json
import os
import time
import uuid
import boto3
_endpoint = os.environ.get("AWS_ENDPOINT_URL")
ddb = boto3.client("dynamodb", endpoint_url=_endpoint)
ORDERS = os.environ["ORDERS_TABLE"]
OUTBOX = os.environ["OUTBOX_TABLE"]
VALID_KINDS = {"OrderCreated", "OrderCancelled"}
def handler(event, context):
body = event
if isinstance(event, str):
body = json.loads(event)
if "body" in body and isinstance(body["body"], str):
body = json.loads(body["body"])
order_id = body["order_id"]
kind = body["kind"]
if kind not in VALID_KINDS:
raise ValueError(f"unknown kind {kind!r}")
extras = {k: v for k, v in body.items() if k not in ("order_id", "kind")}
event_id = str(uuid.uuid4())
now = int(time.time())
ddb.transact_write_items(
TransactItems=[
{
"Put": {
"TableName": ORDERS,
"Item": {
"order_id": {"S": order_id},
"kind": {"S": kind},
"updated_at": {"N": str(now)},
"data": {"S": json.dumps(extras)},
},
}
},
{
"Put": {
"TableName": OUTBOX,
"Item": {
"event_id": {"S": event_id},
"order_id": {"S": order_id},
"kind": {"S": kind},
"created_at": {"N": str(now)},
"payload": {"S": json.dumps(body)},
},
"ConditionExpression": "attribute_not_exists(event_id)",
}
},
]
)
return {"event_id": event_id, "order_id": order_id, "kind": kind}
import json
import os
import time
import boto3
from botocore.exceptions import ClientError
_endpoint = os.environ.get("AWS_ENDPOINT_URL")
ddb = boto3.client("dynamodb", endpoint_url=_endpoint)
events = boto3.client("events", endpoint_url=_endpoint)
DEDUP_TABLE = os.environ["DEDUP_TABLE"]
BUS_NAME = os.environ["BUS_NAME"]
TTL_SECONDS = int(os.environ.get("TTL_SECONDS", "604800"))
def _attr(image, key):
v = image.get(key)
if v is None:
return None
if "S" in v:
return v["S"]
if "N" in v:
return v["N"]
if "BOOL" in v:
return v["BOOL"]
return None
def _already_processed(event_id):
got = ddb.get_item(
TableName=DEDUP_TABLE,
Key={"event_id": {"S": event_id}},
ConsistentRead=True,
)
return "Item" in got
def _mark_processed(event_id, kind):
expires_at = int(time.time()) + TTL_SECONDS
try:
ddb.put_item(
TableName=DEDUP_TABLE,
Item={
"event_id": {"S": event_id},
"kind": {"S": kind},
"expires_at": {"N": str(expires_at)},
},
ConditionExpression="attribute_not_exists(event_id)",
)
except ClientError as e:
if e.response["Error"]["Code"] != "ConditionalCheckFailedException":
raise
def handler(event, context):
failures = []
for record in event.get("Records", []):
seq = record.get("dynamodb", {}).get("SequenceNumber")
try:
if record.get("eventName") != "INSERT":
continue
new_image = record.get("dynamodb", {}).get("NewImage", {})
event_id = _attr(new_image, "event_id")
kind = _attr(new_image, "kind")
order_id = _attr(new_image, "order_id")
payload = _attr(new_image, "payload")
if not event_id or not kind:
print(f"skipping malformed record seq={seq}")
continue
if _already_processed(event_id):
print(f"dedup hit event_id={event_id}")
continue
detail = {
"event_id": event_id,
"order_id": order_id,
"kind": kind,
"payload": payload,
}
resp = events.put_events(
Entries=[
{
"Source": "outbox.relay",
"DetailType": kind,
"Detail": json.dumps(detail),
"EventBusName": BUS_NAME,
}
]
)
if resp.get("FailedEntryCount", 0) > 0:
print(f"PutEvents failed for event_id={event_id}: {resp}")
failures.append({"itemIdentifier": seq})
continue
_mark_processed(event_id, kind)
except Exception as e:
print(f"record seq={seq} error: {e}")
if seq:
failures.append({"itemIdentifier": seq})
return {"batchItemFailures": failures}
Check localstack lambda container networking config
curl -s http://localstack:4566/_localstack/config 2>/dev/null | jq '. | {LAMBDA_DOCKER_NETWORK: .LAMBDA_DOCKER_NETWORK, LAMBDA_RUNTIME_EXECUTOR: .LAMBDA_RUNTIME_EXECUTOR, MAIN_CONTAINER_NAME: .MAIN_CONTAINER_NAME, HOSTNAME_FROM_LAMBDA: .HOSTNAME_FROM_LAMBDA, LOCALSTACK_HOST: .LOCALSTACK_HOST}' 2>/dev/null || echo "config endpoint not available"(Bash completed with no output)
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Id": "outbox-cmk-policy",
"Statement": [
{
"Sid": "EnableAccountRootIAM",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::${ACCOUNT_ID}:root" },
"Action": [
"kms:Create*",
"kms:Describe*",
"kms:Enable*",
"kms:List*",
"kms:Put*",
"kms:Update*",
"kms:Revoke*",
"kms:Disable*",
"kms:Get*",
"kms:Delete*",
"kms:TagResource",
"kms:UntagResource",
"kms:ScheduleKeyDeletion",
"kms:CancelKeyDeletion",
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*"
],
"Resource": "*"
},
{
"Sid": "AllowEventBridgeUseForSqsEncryption",
"Effect": "Allow",
"Principal": { "Service": "events.amazonaws.com" },
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
},
{
"Sid": "AllowDynamoDBService",
"Effect": "Allow",
"Principal": { "Service": "dynamodb.amazonaws.com" },
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
},
{
"Sid": "AllowSqsService",
"Effect": "Allow",
"Principal": { "Service": "sqs.amazonaws.com" },
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:/aws/lambda/order-api*"
},
{
"Sid": "WriteOrdersAndOutboxAtomically",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:GetItem",
"dynamodb:DescribeTable",
"dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:${REGION}:${ACCOUNT_ID}:table/orders",
"arn:aws:dynamodb:${REGION}:${ACCOUNT_ID}:table/outbox"
]
},
{
"Sid": "UseCmkForTableEncryption",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
],
"Resource": "${CMK_ARN}"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:/aws/lambda/outbox-relay*"
},
{
"Sid": "ReadOutboxStream",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator"
],
"Resource": "${OUTBOX_STREAM_ARN}"
},
{
"Sid": "ListStreams",
"Effect": "Allow",
"Action": ["dynamodb:ListStreams"],
"Resource": "*"
},
{
"Sid": "DedupTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:${REGION}:${ACCOUNT_ID}:table/processed_events"
},
{
"Sid": "PublishToOrderEventsBus",
"Effect": "Allow",
"Action": ["events:PutEvents"],
"Resource": "arn:aws:events:${REGION}:${ACCOUNT_ID}:event-bus/order-events"
},
{
"Sid": "UseCmkForStreamAndDedup",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "${CMK_ARN}"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": { "Service": "events.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "${QUEUE_ARN}",
"Condition": {
"ArnEquals": { "aws:SourceArn": "${RULE_ARN}" }
}
}
]
}
#!/usr/bin/env bash
# Deploy outbox pattern stack into LocalStack from zero.
set -euo pipefail
ENDPOINT="${AWS_ENDPOINT_URL:-http://localstack:4566}"
REGION="${AWS_DEFAULT_REGION:-us-east-1}"
ACCOUNT_ID="$(aws --endpoint-url "$ENDPOINT" sts get-caller-identity --query Account --output text)"
export ENDPOINT REGION ACCOUNT_ID
ROOT="$(cd "$(dirname "$0")" && pwd)"
POLICIES="$ROOT/policies"
BUILD="$ROOT/build"
mkdir -p "$BUILD"
awsl() { aws --endpoint-url "$ENDPOINT" --region "$REGION" "$@"; }
render() {
local tpl="$1"
python3 -c '
import os, sys, string
with open(sys.argv[1]) as f:
print(string.Template(f.read()).substitute(os.environ), end="")
' "$tpl"
}
log() { printf "\n==> %s\n" "$*"; }
# ---- 1. KMS CMK + alias --------------------------------------------------
log "KMS: customer managed key + alias/outbox-cmk"
EXISTING_KEY_ID="$(awsl kms list-aliases --query "Aliases[?AliasName=='alias/outbox-cmk'].TargetKeyId | [0]" --output text 2>/dev/null || echo None)"
if [[ -z "$EXISTING_KEY_ID" || "$EXISTING_KEY_ID" == "None" ]]; then
KEY_ID="$(awsl kms create-key \
--description "outbox pattern CMK" \
--key-usage ENCRYPT_DECRYPT \
--query 'KeyMetadata.KeyId' --output text)"
awsl kms create-alias --alias-name alias/outbox-cmk --target-key-id "$KEY_ID"
else
KEY_ID="$EXISTING_KEY_ID"
fi
CMK_ARN="arn:aws:kms:${REGION}:${ACCOUNT_ID}:key/${KEY_ID}"
export CMK_ARN
render "$POLICIES/kms_key_policy.json.tpl" > "$BUILD/kms_key_policy.json"
awsl kms put-key-policy \
--key-id "$KEY_ID" \
--policy-name default \
--policy "file://$BUILD/kms_key_policy.json"
echo " CMK_ARN=$CMK_ARN"
# ---- 2. DynamoDB tables --------------------------------------------------
create_table() {
local name="$1"; local pk="$2"; shift 2
if awsl dynamodb describe-table --table-name "$name" >/dev/null 2>&1; then
echo " table $name already exists, skipping create"
return
fi
awsl dynamodb create-table \
--table-name "$name" \
--attribute-definitions AttributeName="$pk",AttributeType=S \
--key-schema AttributeName="$pk",KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--sse-specification "Enabled=true,SSEType=KMS,KMSMasterKeyId=${KEY_ID}" \
"$@" >/dev/null
awsl dynamodb wait table-exists --table-name "$name"
}
log "DynamoDB: orders / outbox (with stream) / processed_events"
create_table orders order_id
create_table outbox event_id \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
create_table processed_events event_id
awsl dynamodb update-time-to-live \
--table-name processed_events \
--time-to-live-specification "Enabled=true,AttributeName=expires_at" >/dev/null || true
OUTBOX_STREAM_ARN="$(awsl dynamodb describe-table --table-name outbox \
--query 'Table.LatestStreamArn' --output text)"
export OUTBOX_STREAM_ARN
echo " OUTBOX_STREAM_ARN=$OUTBOX_STREAM_ARN"
# ---- 3. EventBridge bus + SQS (DLQs first) -------------------------------
log "EventBridge bus order-events"
awsl events create-event-bus --name order-events 2>/dev/null || echo " bus exists"
BUS_ARN="arn:aws:events:${REGION}:${ACCOUNT_ID}:event-bus/order-events"
create_queue() {
local name="$1"; shift
awsl sqs create-queue \
--queue-name "$name" \
--attributes "$@" >/dev/null 2>&1 || true
awsl sqs get-queue-url --queue-name "$name" --query QueueUrl --output text
}
log "SQS: DLQs"
DLQ_CREATED_URL="$(create_queue orders-created-dlq KmsMasterKeyId=$KEY_ID)"
DLQ_CANCELLED_URL="$(create_queue orders-cancelled-dlq KmsMasterKeyId=$KEY_ID)"
DLQ_CREATED_ARN="$(awsl sqs get-queue-attributes --queue-url "$DLQ_CREATED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)"
DLQ_CANCELLED_ARN="$(awsl sqs get-queue-attributes --queue-url "$DLQ_CANCELLED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)"
log "SQS: main queues with redrive policies"
REDRIVE_CREATED="$(jq -nc --arg arn "$DLQ_CREATED_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"5"}')"
REDRIVE_CANCELLED="$(jq -nc --arg arn "$DLQ_CANCELLED_ARN" '{deadLetterTargetArn:$arn, maxReceiveCount:"5"}')"
CREATED_ATTRS="$(jq -nc --arg kms "$KEY_ID" --arg rd "$REDRIVE_CREATED" '{KmsMasterKeyId:$kms, RedrivePolicy:$rd}')"
CANCELLED_ATTRS="$(jq -nc --arg kms "$KEY_ID" --arg rd "$REDRIVE_CANCELLED" '{KmsMasterKeyId:$kms, RedrivePolicy:$rd}')"
awsl sqs create-queue --queue-name orders-created --attributes "$CREATED_ATTRS" >/dev/null 2>&1 || \
awsl sqs set-queue-attributes \
--queue-url "$(awsl sqs get-queue-url --queue-name orders-created --query QueueUrl --output text)" \
--attributes "$CREATED_ATTRS" >/dev/null
awsl sqs create-queue --queue-name orders-cancelled --attributes "$CANCELLED_ATTRS" >/dev/null 2>&1 || \
awsl sqs set-queue-attributes \
--queue-url "$(awsl sqs get-queue-url --queue-name orders-cancelled --query QueueUrl --output text)" \
--attributes "$CANCELLED_ATTRS" >/dev/null
CREATED_URL="$(awsl sqs get-queue-url --queue-name orders-created --query QueueUrl --output text)"
CANCELLED_URL="$(awsl sqs get-queue-url --queue-name orders-cancelled --query QueueUrl --output text)"
CREATED_ARN="$(awsl sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)"
CANCELLED_ARN="$(awsl sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)"
echo " CREATED_ARN=$CREATED_ARN"
echo " CANCELLED_ARN=$CANCELLED_ARN"
# ---- 4. EventBridge rules + targets --------------------------------------
log "EventBridge rules"
RULE_CREATED_PATTERN='{"source":["outbox.relay"],"detail-type":["OrderCreated"]}'
RULE_CANCELLED_PATTERN='{"source":["outbox.relay"],"detail-type":["OrderCancelled"]}'
awsl events put-rule \
--name on-order-created \
--event-bus-name order-events \
--event-pattern "$RULE_CREATED_PATTERN" \
--state ENABLED >/dev/null
awsl events put-rule \
--name on-order-cancelled \
--event-bus-name order-events \
--event-pattern "$RULE_CANCELLED_PATTERN" \
--state ENABLED >/dev/null
RULE_CREATED_ARN="arn:aws:events:${REGION}:${ACCOUNT_ID}:rule/order-events/on-order-created"
RULE_CANCELLED_ARN="arn:aws:events:${REGION}:${ACCOUNT_ID}:rule/order-events/on-order-cancelled"
# Set SQS queue policies allowing the rules to send messages.
QUEUE_ARN="$CREATED_ARN" RULE_ARN="$RULE_CREATED_ARN" \
render "$POLICIES/sqs_queue_policy.json.tpl" > "$BUILD/policy_orders_created.json"
QUEUE_ARN="$CANCELLED_ARN" RULE_ARN="$RULE_CANCELLED_ARN" \
render "$POLICIES/sqs_queue_policy.json.tpl" > "$BUILD/policy_orders_cancelled.json"
awsl sqs set-queue-attributes \
--queue-url "$CREATED_URL" \
--attributes "Policy=$(cat $BUILD/policy_orders_created.json)" >/dev/null
awsl sqs set-queue-attributes \
--queue-url "$CANCELLED_URL" \
--attributes "Policy=$(cat $BUILD/policy_orders_cancelled.json)" >/dev/null
awsl events put-targets \
--rule on-order-created \
--event-bus-name order-events \
--targets "Id=q1,Arn=$CREATED_ARN" >/dev/null
awsl events put-targets \
--rule on-order-cancelled \
--event-bus-name order-events \
--targets "Id=q1,Arn=$CANCELLED_ARN" >/dev/null
# ---- 5. IAM roles --------------------------------------------------------
log "IAM: roles + scoped inline policies"
create_role() {
local role="$1"; local trust="$2"
if awsl iam get-role --role-name "$role" >/dev/null 2>&1; then
awsl iam update-assume-role-policy --role-name "$role" --policy-document "file://$trust" >/dev/null
else
awsl iam create-role --role-name "$role" --assume-role-policy-document "file://$trust" >/dev/null
fi
}
create_role order-api-role "$POLICIES/lambda_trust.json"
create_role outbox-relay-role "$POLICIES/lambda_trust.json"
render "$POLICIES/order_api_role.json.tpl" > "$BUILD/order_api_role.json"
render "$POLICIES/outbox_relay_role.json.tpl" > "$BUILD/outbox_relay_role.json"
awsl iam put-role-policy --role-name order-api-role \
--policy-name order-api-inline \
--policy-document "file://$BUILD/order_api_role.json"
awsl iam put-role-policy --role-name outbox-relay-role \
--policy-name outbox-relay-inline \
--policy-document "file://$BUILD/outbox_relay_role.json"
ORDER_API_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/order-api-role"
OUTBOX_RELAY_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/outbox-relay-role"
# ---- 6. Lambda packaging + deploy ----------------------------------------
log "Lambda: package and deploy"
pkg_lambda() {
local src_dir="$1"; local zip_path="$2"
rm -f "$zip_path"
( cd "$src_dir" && zip -qr "$zip_path" . )
}
pkg_lambda "$ROOT/lambdas/order_api" "$BUILD/order_api.zip"
pkg_lambda "$ROOT/lambdas/outbox_relay" "$BUILD/outbox_relay.zip"
deploy_lambda() {
local name="$1"; local role_arn="$2"; local zip="$3"; local env="$4"
if awsl lambda get-function --function-name "$name" >/dev/null 2>&1; then
awsl lambda update-function-code --function-name "$name" --zip-file "fileb://$zip" >/dev/null
awsl lambda wait function-updated --function-name "$name"
awsl lambda update-function-configuration \
--function-name "$name" \
--role "$role_arn" \
--environment "$env" \
--timeout 30 >/dev/null
awsl lambda wait function-updated --function-name "$name"
else
awsl lambda create-function \
--function-name "$name" \
--runtime python3.11 \
--role "$role_arn" \
--handler handler.handler \
--timeout 30 \
--environment "$env" \
--zip-file "fileb://$zip" >/dev/null
awsl lambda wait function-active-v2 --function-name "$name"
fi
}
ORDER_API_ENV="Variables={AWS_ENDPOINT_URL=$ENDPOINT,ORDERS_TABLE=orders,OUTBOX_TABLE=outbox}"
RELAY_ENV="Variables={AWS_ENDPOINT_URL=$ENDPOINT,DEDUP_TABLE=processed_events,BUS_NAME=order-events,TTL_SECONDS=604800}"
deploy_lambda order-api "$ORDER_API_ROLE_ARN" "$BUILD/order_api.zip" "$ORDER_API_ENV"
deploy_lambda outbox-relay "$OUTBOX_RELAY_ROLE_ARN" "$BUILD/outbox_relay.zip" "$RELAY_ENV"
# ---- 7. Event source mapping (DDB stream -> relay) -----------------------
log "Event source mapping outbox stream -> outbox-relay (ReportBatchItemFailures)"
EXISTING_ESM_UUID="$(awsl lambda list-event-source-mappings \
--function-name outbox-relay \
--event-source-arn "$OUTBOX_STREAM_ARN" \
--query 'EventSourceMappings[0].UUID' --output text 2>/dev/null || echo None)"
if [[ -z "$EXISTING_ESM_UUID" || "$EXISTING_ESM_UUID" == "None" ]]; then
awsl lambda create-event-source-mapping \
--function-name outbox-relay \
--event-source-arn "$OUTBOX_STREAM_ARN" \
--starting-position TRIM_HORIZON \
--batch-size 10 \
--maximum-batching-window-in-seconds 1 \
--function-response-types ReportBatchItemFailures >/dev/null
else
awsl lambda update-event-source-mapping \
--uuid "$EXISTING_ESM_UUID" \
--function-response-types ReportBatchItemFailures >/dev/null
fi
# Wait for ESM to become enabled
for i in $(seq 1 30); do
state="$(awsl lambda list-event-source-mappings \
--function-name outbox-relay \
--event-source-arn "$OUTBOX_STREAM_ARN" \
--query 'EventSourceMappings[0].State' --output text)"
echo " ESM state: $state"
[[ "$state" == "Enabled" ]] && break
sleep 1
done
log "Done."
Run deploy script
chmod +x /app/deploy.sh && bash /app/deploy.sh 2>&1 | tail -120
==> KMS: customer managed key + alias/outbox-cmk
CMK_ARN=arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415
==> DynamoDB: orders / outbox (with stream) / processed_events
OUTBOX_STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599
==> EventBridge bus order-events
{
"EventBusArn": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
}
==> SQS: DLQs
==> SQS: main queues with redrive policies
CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created
CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled
==> EventBridge rules
aws: [ERROR]: An error occurred (ParamValidation): Error parsing parameter '--attributes': Expected: '=', received: '"' for input:
Policy={
"Version": "2012-10-17",
^
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": { "Service": "events.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:orders-created",
"Condition": {
"ArnEquals": { "aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created" }
}
}
]
}
[stdout]
==> KMS: customer managed key + alias/outbox-cmk
CMK_ARN=arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415
==> DynamoDB: orders / outbox (with stream) / processed_events
OUTBOX_STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599
==> EventBridge bus order-events
{
"EventBusArn": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
}
==> SQS: DLQs
==> SQS: main queues with redrive policies
CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created
CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled
==> EventBridge rules
aws: [ERROR]: An error occurred (ParamValidation): Error parsing parameter '--attributes': Expected: '=', received: '"' for input:
Policy={
"Version": "2012-10-17",
^
"Statement": [
{
"Sid": "AllowEventBridgeRule",
"Effect": "Allow",
"Principal": { "Service": "events.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:orders-created",
"Condition": {
"ArnEquals": { "aws:SourceArn": "arn:aws:events:us-east-1:000000000000:rule/order-events/on-order-created" }
}
}
]
}/app/deploy.sh
awsl sqs set-queue-attributes \ --queue-url "$CREATED_URL" \ --attributes "Policy=$(cat $BUILD/policy_orders_created.json)" >/dev/null awsl sqs set-queue-attributes \ --queue-url "$CANCELLED_URL" \ --attributes "Policy=$(cat $BUILD/policy_orders_cancelled.json)" >/dev/null
jq -n --arg url "$CREATED_URL" \
--arg pol "$(cat $BUILD/policy_orders_created.json)" \
'{QueueUrl:$url, Attributes:{Policy:$pol}}' \
> "$BUILD/sqa_created.json"
jq -n --arg url "$CANCELLED_URL" \
--arg pol "$(cat $BUILD/policy_orders_cancelled.json)" \
'{QueueUrl:$url, Attributes:{Policy:$pol}}' \
> "$BUILD/sqa_cancelled.json"
awsl sqs set-queue-attributes --cli-input-json "file://$BUILD/sqa_created.json" >/dev/null
awsl sqs set-queue-attributes --cli-input-json "file://$BUILD/sqa_cancelled.json" >/dev/nullRe-run deploy script
bash /app/deploy.sh 2>&1 | tail -80
==> KMS: customer managed key + alias/outbox-cmk CMK_ARN=arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415 ==> DynamoDB: orders / outbox (with stream) / processed_events table orders already exists, skipping create table outbox already exists, skipping create table processed_events already exists, skipping create OUTBOX_STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599 ==> EventBridge bus order-events bus exists ==> SQS: DLQs ==> SQS: main queues with redrive policies CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled ==> EventBridge rules ==> IAM: roles + scoped inline policies ==> Lambda: package and deploy ==> Event source mapping outbox stream -> outbox-relay (ReportBatchItemFailures) ESM state: Enabled ==> Done. [stdout] ==> KMS: customer managed key + alias/outbox-cmk CMK_ARN=arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415 ==> DynamoDB: orders / outbox (with stream) / processed_events table orders already exists, skipping create table outbox already exists, skipping create table processed_events already exists, skipping create OUTBOX_STREAM_ARN=arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599 ==> EventBridge bus order-events bus exists ==> SQS: DLQs ==> SQS: main queues with redrive policies CREATED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-created CANCELLED_ARN=arn:aws:sqs:us-east-1:000000000000:orders-cancelled ==> EventBridge rules ==> IAM: roles + scoped inline policies ==> Lambda: package and deploy ==> Event source mapping outbox stream -> outbox-relay (ReportBatchItemFailures) ESM state: Enabled ==> Done.
Invoke order-api with OrderCreated
aws lambda invoke \
--function-name order-api \
--cli-binary-format raw-in-base64-out \
--payload '{"order_id":"o-1","kind":"OrderCreated","sku":"abc","qty":2}' \
/tmp/inv1.json && cat /tmp/inv1.json && echo{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
{"event_id": "e800ca7b-d089-45c8-97b0-2cc77ebf493d", "order_id": "o-1", "kind": "OrderCreated"}
[stdout]
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
{"event_id": "e800ca7b-d089-45c8-97b0-2cc77ebf493d", "order_id": "o-1", "kind": "OrderCreated"}Poll SQS counts
CREATED_URL=$(aws sqs get-queue-url --queue-name orders-created --query QueueUrl --output text)
CANCELLED_URL=$(aws sqs get-queue-url --queue-name orders-cancelled --query QueueUrl --output text)
echo "waiting up to 30s for messages..."
for i in $(seq 1 30); do
c1=$(aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text)
c2=$(aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text)
echo "t+${i}s created=$c1 cancelled=$c2"
if [[ "$c1" -ge 1 ]]; then break; fi
sleep 1
donewaiting up to 30s for messages... t+1s created=1 cancelled=0 [stdout] waiting up to 30s for messages... t+1s created=1 cancelled=0
Check dedup rows + peek at queued message
aws dynamodb scan --table-name processed_events --query 'Items[*]' --output json echo "---" aws sqs receive-message --queue-url "$(aws sqs get-queue-url --queue-name orders-created --query QueueUrl --output text)" --max-number-of-messages 10 --visibility-timeout 0 --query 'Messages[*].Body' --output text | head -50
[
{
"kind": {
"S": "OrderCreated"
},
"event_id": {
"S": "e800ca7b-d089-45c8-97b0-2cc77ebf493d"
},
"expires_at": {
"N": "1777991913"
}
}
]
---
{"version":"0","id":"2b9c634e-2ee7-462b-8bcc-023f81022a7e","detail-type":"OrderCreated","source":"outbox.relay","account":"000000000000","time":"2026-04-28T14:38:33Z","region":"us-east-1","resources":[],"detail":{"event_id":"e800ca7b-d089-45c8-97b0-2cc77ebf493d","order_id":"o-1","kind":"OrderCreated","payload":"{\"order_id\": \"o-1\", \"kind\": \"OrderCreated\", \"sku\": \"abc\", \"qty\": 2}"}}
[stdout]
[
{
"kind": {
"S": "OrderCreated"
},
"event_id": {
"S": "e800ca7b-d089-45c8-97b0-2cc77ebf493d"
},
"expires_at": {
"N": "1777991913"
}
}
]
---
{"version":"0","id":"2b9c634e-2ee7-462b-8bcc-023f81022a7e","detail-type":"OrderCreated","source":"outbox.relay","account":"000000000000","time":"2026-04-28T14:38:33Z","region":"us-east-1","resources":[],"detail":{"event_id":"e800ca7b-d089-45c8-97b0-2cc77ebf493d","order_id":"o-1","kind":"OrderCreated","payload":"{\"order_id\": \"o-1\", \"kind\": \"OrderCreated\", \"sku\": \"abc\", \"qty\": 2}"}}Invoke OrderCancelled and poll
aws lambda invoke \
--function-name order-api \
--cli-binary-format raw-in-base64-out \
--payload '{"order_id":"o-2","kind":"OrderCancelled","reason":"customer-request"}' \
/tmp/inv2.json >/dev/null && cat /tmp/inv2.json && echo
echo "---"
CREATED_URL=$(aws sqs get-queue-url --queue-name orders-created --query QueueUrl --output text)
CANCELLED_URL=$(aws sqs get-queue-url --queue-name orders-cancelled --query QueueUrl --output text)
for i in $(seq 1 30); do
c1=$(aws sqs get-queue-attributes --queue-url "$CREATED_URL" --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text)
c2=$(aws sqs get-queue-attributes --queue-url "$CANCELLED_URL" --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text)
echo "t+${i}s created=$c1 cancelled=$c2"
if [[ "$c2" -ge 1 ]]; then break; fi
sleep 1
done{"event_id": "acd1452f-5430-476c-a6b5-c36972d2f37c", "order_id": "o-2", "kind": "OrderCancelled"}
---
t+1s created=1 cancelled=0
t+2s created=1 cancelled=1
[stdout]
{"event_id": "acd1452f-5430-476c-a6b5-c36972d2f37c", "order_id": "o-2", "kind": "OrderCancelled"}
---
t+1s created=1 cancelled=0
t+2s created=1 cancelled=1#!/usr/bin/env bash
# End-to-end check matching the spec's "done looks like this".
set -euo pipefail
awsl() { aws "$@"; }
CREATED_URL="$(awsl sqs get-queue-url --queue-name orders-created --query QueueUrl --output text)"
CANCELLED_URL="$(awsl sqs get-queue-url --queue-name orders-cancelled --query QueueUrl --output text)"
count() {
awsl sqs get-queue-attributes --queue-url "$1" \
--attribute-names ApproximateNumberOfMessages \
--query 'Attributes.ApproximateNumberOfMessages' --output text
}
purge() { awsl sqs purge-queue --queue-url "$1" >/dev/null 2>&1 || true; }
wait_count() {
local url="$1" want="$2" timeout="${3:-30}"
for i in $(seq 1 "$timeout"); do
[[ "$(count "$url")" == "$want" ]] && return 0
sleep 1
done
return 1
}
echo "== purge queues + reset dedup =="
purge "$CREATED_URL"; purge "$CANCELLED_URL"
# Wipe processed_events so this run is fresh
for ev in $(awsl dynamodb scan --table-name processed_events --query 'Items[*].event_id.S' --output text); do
awsl dynamodb delete-item --table-name processed_events --key "{\"event_id\":{\"S\":\"$ev\"}}"
done
echo
echo "== invoke order-api { o-1, OrderCreated } =="
awsl lambda invoke --function-name order-api \
--cli-binary-format raw-in-base64-out \
--payload '{"order_id":"o-1","kind":"OrderCreated","sku":"abc","qty":2}' \
/tmp/r1.json >/dev/null
cat /tmp/r1.json; echo
EVENT_ID_1=$(jq -r .event_id /tmp/r1.json)
echo " waiting for orders-created to reach 1..."
wait_count "$CREATED_URL" 1 30 || { echo "TIMEOUT: orders-created count != 1 (got $(count "$CREATED_URL"))"; exit 1; }
sleep 2 # give cancelled queue a chance to (incorrectly) receive
C_CREATED="$(count "$CREATED_URL")"
C_CANCELLED="$(count "$CANCELLED_URL")"
echo " orders-created=$C_CREATED orders-cancelled=$C_CANCELLED"
[[ "$C_CREATED" == "1" && "$C_CANCELLED" == "0" ]] || { echo "FAIL"; exit 1; }
echo " processed_events row for $EVENT_ID_1:"
awsl dynamodb get-item --table-name processed_events \
--key "{\"event_id\":{\"S\":\"$EVENT_ID_1\"}}" --query 'Item' --output json
[[ "$(awsl dynamodb get-item --table-name processed_events \
--key "{\"event_id\":{\"S\":\"$EVENT_ID_1\"}}" \
--query 'Item.event_id.S' --output text)" == "$EVENT_ID_1" ]] \
|| { echo "FAIL: missing dedup row"; exit 1; }
echo
echo "== invoke order-api { o-2, OrderCancelled } =="
awsl lambda invoke --function-name order-api \
--cli-binary-format raw-in-base64-out \
--payload '{"order_id":"o-2","kind":"OrderCancelled","reason":"customer-request"}' \
/tmp/r2.json >/dev/null
cat /tmp/r2.json; echo
echo " waiting for orders-cancelled to reach 1..."
wait_count "$CANCELLED_URL" 1 30 || { echo "TIMEOUT: cancelled queue not 1"; exit 1; }
sleep 2
C_CREATED="$(count "$CREATED_URL")"
C_CANCELLED="$(count "$CANCELLED_URL")"
echo " orders-created=$C_CREATED orders-cancelled=$C_CANCELLED"
[[ "$C_CREATED" == "1" && "$C_CANCELLED" == "1" ]] || { echo "FAIL"; exit 1; }
echo
echo "ALL CHECKS PASSED"
Run end-to-end verification
chmod +x /app/verify.sh && bash /app/verify.sh 2>&1
== purge queues + reset dedup ==
== invoke order-api { o-1, OrderCreated } ==
{"event_id": "32d3e999-ab48-4b0e-b867-25c432e5d37c", "order_id": "o-1", "kind": "OrderCreated"}
waiting for orders-created to reach 1...
orders-created=1 orders-cancelled=0
processed_events row for 32d3e999-ab48-4b0e-b867-25c432e5d37c:
{
"kind": {
"S": "OrderCreated"
},
"event_id": {
"S": "32d3e999-ab48-4b0e-b867-25c432e5d37c"
},
"expires_at": {
"N": "1777991970"
}
}
== invoke order-api { o-2, OrderCancelled } ==
{"event_id": "254ea322-3041-4d7b-9fa6-7d7f7823ca2b", "order_id": "o-2", "kind": "OrderCancelled"}
waiting for orders-cancelled to reach 1...
orders-created=1 orders-cancelled=1
ALL CHECKS PASSED
[stdout]
== purge queues + reset dedup ==
== invoke order-api { o-1, OrderCreated } ==
{"event_id": "32d3e999-ab48-4b0e-b867-25c432e5d37c", "order_id": "o-1", "kind": "OrderCreated"}
waiting for orders-created to reach 1...
orders-created=1 orders-cancelled=0
processed_events row for 32d3e999-ab48-4b0e-b867-25c432e5d37c:
{
"kind": {
"S": "OrderCreated"
},
"event_id": {
"S": "32d3e999-ab48-4b0e-b867-25c432e5d37c"
},
"expires_at": {
"N": "1777991970"
}
}
== invoke order-api { o-2, OrderCancelled } ==
{"event_id": "254ea322-3041-4d7b-9fa6-7d7f7823ca2b", "order_id": "o-2", "kind": "OrderCancelled"}
waiting for orders-cancelled to reach 1...
orders-created=1 orders-cancelled=1
ALL CHECKS PASSEDVerify stream/ESM/SSE/TTL config
echo "=== outbox stream view type ==="
aws dynamodb describe-table --table-name outbox \
--query '{StreamEnabled:Table.StreamSpecification.StreamEnabled, ViewType:Table.StreamSpecification.StreamViewType, SSE:Table.SSEDescription}' --output json
echo
echo "=== ESM FunctionResponseTypes ==="
aws lambda list-event-source-mappings --function-name outbox-relay \
--query 'EventSourceMappings[*].{State:State, FRT:FunctionResponseTypes, Source:EventSourceArn}' --output json
echo
echo "=== processed_events TTL ==="
aws dynamodb describe-time-to-live --table-name processed_events --query 'TimeToLiveDescription' --output json
echo
echo "=== orders SSE ==="
aws dynamodb describe-table --table-name orders --query 'Table.SSEDescription' --output json
echo "=== outbox SSE ==="
aws dynamodb describe-table --table-name outbox --query 'Table.SSEDescription' --output json
echo "=== processed_events SSE ==="
aws dynamodb describe-table --table-name processed_events --query 'Table.SSEDescription' --output json=== outbox stream view type ===
{
"StreamEnabled": true,
"ViewType": "NEW_AND_OLD_IMAGES",
"SSE": {
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
}
=== ESM FunctionResponseTypes ===
[
{
"State": "Enabled",
"FRT": [
"ReportBatchItemFailures"
],
"Source": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599"
}
]
=== processed_events TTL ===
{
"TimeToLiveStatus": "ENABLED",
"AttributeName": "expires_at"
}
=== orders SSE ===
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
=== outbox SSE ===
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
=== processed_events SSE ===
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
[stdout]
=== outbox stream view type ===
{
"StreamEnabled": true,
"ViewType": "NEW_AND_OLD_IMAGES",
"SSE": {
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
}
=== ESM FunctionResponseTypes ===
[
{
"State": "Enabled",
"FRT": [
"ReportBatchItemFailures"
],
"Source": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599"
}
]
=== processed_events TTL ===
{
"TimeToLiveStatus": "ENABLED",
"AttributeName": "expires_at"
}
=== orders SSE ===
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
=== outbox SSE ===
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
=== processed_events SSE ===
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}Check SQS encryption + redrive
for q in orders-created orders-cancelled orders-created-dlq orders-cancelled-dlq; do
url=$(aws sqs get-queue-url --queue-name $q --query QueueUrl --output text)
echo "=== $q ==="
aws sqs get-queue-attributes --queue-url "$url" \
--attribute-names KmsMasterKeyId RedrivePolicy QueueArn \
--query 'Attributes' --output json
done=== orders-created ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"5\"}",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created"
}
=== orders-cancelled ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq\",\"maxReceiveCount\":\"5\"}",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled"
}
=== orders-created-dlq ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created-dlq"
}
=== orders-cancelled-dlq ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq"
}
[stdout]
=== orders-created ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-created-dlq\",\"maxReceiveCount\":\"5\"}",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created"
}
=== orders-cancelled ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq\",\"maxReceiveCount\":\"5\"}",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled"
}
=== orders-created-dlq ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-created-dlq"
}
=== orders-cancelled-dlq ===
{
"KmsMasterKeyId": "1ad260c5-65ad-43c9-b082-5127d40a7415",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:orders-cancelled-dlq"
}Show inline role policies for verifier
echo "=== order-api-role inline policy ===" aws iam get-role-policy --role-name order-api-role --policy-name order-api-inline --query PolicyDocument --output json echo echo "=== outbox-relay-role inline policy ===" aws iam get-role-policy --role-name outbox-relay-role --policy-name outbox-relay-inline --query PolicyDocument --output json
=== order-api-role inline policy ===
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api*"
},
{
"Sid": "WriteOrdersAndOutboxAtomically",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:GetItem",
"dynamodb:DescribeTable",
"dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "UseCmkForTableEncryption",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
]
}
=== outbox-relay-role inline policy ===
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay*"
},
{
"Sid": "ReadOutboxStream",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599"
},
{
"Sid": "ListStreams",
"Effect": "Allow",
"Action": [
"dynamodb:ListStreams"
],
"Resource": "*"
},
{
"Sid": "DedupTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PublishToOrderEventsBus",
"Effect": "Allow",
"Action": [
"events:PutEvents"
],
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "UseCmkForStreamAndDedup",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
]
}
[stdout]
=== order-api-role inline policy ===
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/order-api*"
},
{
"Sid": "WriteOrdersAndOutboxAtomically",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:GetItem",
"dynamodb:DescribeTable",
"dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:000000000000:table/orders",
"arn:aws:dynamodb:us-east-1:000000000000:table/outbox"
]
},
{
"Sid": "UseCmkForTableEncryption",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
]
}
=== outbox-relay-role inline policy ===
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/outbox-relay*"
},
{
"Sid": "ReadOutboxStream",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeStream",
"dynamodb:GetRecords",
"dynamodb:GetShardIterator"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/outbox/stream/2026-04-28T14:37:21.599"
},
{
"Sid": "ListStreams",
"Effect": "Allow",
"Action": [
"dynamodb:ListStreams"
],
"Resource": "*"
},
{
"Sid": "DedupTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/processed_events"
},
{
"Sid": "PublishToOrderEventsBus",
"Effect": "Allow",
"Action": [
"events:PutEvents"
],
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/order-events"
},
{
"Sid": "UseCmkForStreamAndDedup",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/1ad260c5-65ad-43c9-b082-5127d40a7415"
}
]
}No file changes, the agent left the workspace untouched.
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/X6Bc9ke4C9vcX-v1sqM-e/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 20 items
../tests/test_outputs.py::test_01_cmk_alias_exists_and_enabled PASSED [ 5%]
../tests/test_outputs.py::test_02_orders_table_exists PASSED [ 10%]
../tests/test_outputs.py::test_03_outbox_table_exists PASSED [ 15%]
../tests/test_outputs.py::test_04_processed_events_table_exists PASSED [ 20%]
../tests/test_outputs.py::test_05_custom_event_bus_exists PASSED [ 25%]
../tests/test_outputs.py::test_06_orders_table_encrypted_with_cmk PASSED [ 30%]
../tests/test_outputs.py::test_07_outbox_table_encrypted_with_cmk PASSED [ 35%]
../tests/test_outputs.py::test_08_outbox_stream_is_new_and_old_images PASSED [ 40%]
../tests/test_outputs.py::test_09_processed_events_ttl_enabled PASSED [ 45%]
../tests/test_outputs.py::test_10_both_rule_patterns_use_array_wrap_and_disjoint_types PASSED [ 50%]
../tests/test_outputs.py::test_11_each_rule_targets_only_its_intended_queue PASSED [ 55%]
../tests/test_outputs.py::test_12_both_main_queues_encrypted_with_cmk PASSED [ 60%]
../tests/test_outputs.py::test_13_main_queues_have_redrive_to_distinct_dlqs PASSED [ 65%]
../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn PASSED [ 70%]
../tests/test_outputs.py::test_15_relay_esm_has_batch_item_failures_on_outbox_stream PASSED [ 75%]
../tests/test_outputs.py::test_16_relay_role_is_scoped_not_wildcard FAILED [ 80%]
../tests/test_outputs.py::test_17_order_api_role_scoped_to_orders_and_outbox_only PASSED [ 85%]
../tests/test_outputs.py::test_18_e2e_order_created_routes_to_created_queue_only PASSED [ 90%]
../tests/test_outputs.py::test_19_e2e_order_cancelled_routes_to_cancelled_queue_only PASSED [ 95%]
../tests/test_outputs.py::test_20_e2e_idempotent_duplicate_does_not_double_fanout PASSED [100%]
=================================== FAILURES ===================================
__________________ test_16_relay_role_is_scoped_not_wildcard ___________________
def test_16_relay_role_is_scoped_not_wildcard():
"""outbox-relay role must NOT have '*' on Resource for dynamodb stream
actions or for kms:Decrypt. These actions must be scoped to the outbox
stream ARN and to the CMK respectively."""
ddb = _client("dynamodb")
stream_arn = ddb.describe_table(TableName=OUTBOX_TABLE)["Table"]["LatestStreamArn"]
cmk_arn, cmk_id = _cmk_arn()
docs = _get_role_policies(RELAY_ROLE)
stmts = _statements(docs)
for s in stmts:
if s.get("Effect") != "Allow":
continue
actions = _as_list(s.get("Action"))
resources = _as_list(s.get("Resource"))
if any(a.startswith("dynamodb:") or a == "*" for a in actions):
for r in resources:
if r == "*":
if any(a in ("dynamodb:GetRecords", "dynamodb:GetShardIterator",
"dynamodb:DescribeStream", "dynamodb:ListStreams",
"*") for a in actions):
> pytest.fail(
f"relay role wildcards dynamodb stream actions on '*': "
f"actions={actions}"
)
E Failed: relay role wildcards dynamodb stream actions on '*': actions=['dynamodb:ListStreams']
/tests/test_outputs.py:456: Failed
=============================== warnings summary ===============================
test_outputs.py: 89 warnings
/root/.cache/uv/archive-v0/X6Bc9ke4C9vcX-v1sqM-e/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
datetime_now = datetime.datetime.utcnow()
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_01_cmk_alias_exists_and_enabled
PASSED ../tests/test_outputs.py::test_02_orders_table_exists
PASSED ../tests/test_outputs.py::test_03_outbox_table_exists
PASSED ../tests/test_outputs.py::test_04_processed_events_table_exists
PASSED ../tests/test_outputs.py::test_05_custom_event_bus_exists
PASSED ../tests/test_outputs.py::test_06_orders_table_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_07_outbox_table_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_08_outbox_stream_is_new_and_old_images
PASSED ../tests/test_outputs.py::test_09_processed_events_ttl_enabled
PASSED ../tests/test_outputs.py::test_10_both_rule_patterns_use_array_wrap_and_disjoint_types
PASSED ../tests/test_outputs.py::test_11_each_rule_targets_only_its_intended_queue
PASSED ../tests/test_outputs.py::test_12_both_main_queues_encrypted_with_cmk
PASSED ../tests/test_outputs.py::test_13_main_queues_have_redrive_to_distinct_dlqs
PASSED ../tests/test_outputs.py::test_14_queue_policies_scope_events_with_source_arn
PASSED ../tests/test_outputs.py::test_15_relay_esm_has_batch_item_failures_on_outbox_stream
PASSED ../tests/test_outputs.py::test_17_order_api_role_scoped_to_orders_and_outbox_only
PASSED ../tests/test_outputs.py::test_18_e2e_order_created_routes_to_created_queue_only
PASSED ../tests/test_outputs.py::test_19_e2e_order_cancelled_routes_to_cancelled_queue_only
PASSED ../tests/test_outputs.py::test_20_e2e_idempotent_duplicate_does_not_double_fanout
FAILED ../tests/test_outputs.py::test_16_relay_role_is_scoped_not_wildcard - ...
================== 1 failed, 19 passed, 89 warnings in 22.38s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_e6313d63052441d4. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_e6313d63052441d4 · verifier authoritative; classifier explanatory.