tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check environment and LocalStack connectivity
ls /app && aws --version && python3 --version && echo "---" && aws --endpoint-url=http://localstack:4566 sts get-caller-identity
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3
---
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3
---
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}Check app directory and LocalStack health
ls /app -la && export AWS_ENDPOINT_URL=http://localstack:4566 && aws --endpoint-url=$AWS_ENDPOINT_URL s3 ls && echo "---" && curl -s $AWS_ENDPOINT_URL/_localstack/health | head -c 500
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 22 08:40 ..
---
{"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": "disa
[stdout]
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 22 08:40 ..
---
{"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": "disaCreate working directories
mkdir -p /app/build /app/policies /app/lambda && ls /app
build lambda policies [stdout] build lambda policies
{
"Version": "2012-10-17",
"Id": "source-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableRootAccountAdmin",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleDecryptSourceObjects",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Id": "sqs-queue-cmk-policy",
"Statement": [
{
"Sid": "EnableRootAccountAdmin",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowS3ToEncryptMessagesForQueue",
"Effect": "Allow",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleConsumeEncryptedMessages",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Id": "dest-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableRootAccountAdmin",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleEncryptProcessedObjects",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:GenerateDataKey",
"kms:Encrypt",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
Create three KMS CMKs with policies and aliases
export AWS_ENDPOINT_URL=http://localstack:4566 && \ SRC_KEY=$(aws --endpoint-url=$AWS_ENDPOINT_URL kms create-key --description "source-bucket CMK" --policy file:///app/policies/kms-source-bucket.json --query 'KeyMetadata.KeyId' --output text) && echo "SRC_KEY=$SRC_KEY" > /app/build/vars.sh && \ aws --endpoint-url=$AWS_ENDPOINT_URL kms create-alias --alias-name alias/source-bucket-cmk --target-key-id $SRC_KEY && \ SQS_KEY=$(aws --endpoint-url=$AWS_ENDPOINT_URL kms create-key --description "sqs-queue CMK" --policy file:///app/policies/kms-sqs-queue.json --query 'KeyMetadata.KeyId' --output text) && echo "SQS_KEY=$SQS_KEY" >> /app/build/vars.sh && \ aws --endpoint-url=$AWS_ENDPOINT_URL kms create-alias --alias-name alias/sqs-queue-cmk --target-key-id $SQS_KEY && \ DST_KEY=$(aws --endpoint-url=$AWS_ENDPOINT_URL kms create-key --description "dest-bucket CMK" --policy file:///app/policies/kms-dest-bucket.json --query 'KeyMetadata.KeyId' --output text) && echo "DST_KEY=$DST_KEY" >> /app/build/vars.sh && \ aws --endpoint-url=$AWS_ENDPOINT_URL kms create-alias --alias-name alias/dest-bucket-cmk --target-key-id $DST_KEY && \ cat /app/build/vars.sh
SRC_KEY=3e421f97-9be9-4d2c-8934-b9ece620d63e SQS_KEY=8dbfa037-0ef7-4dfb-a71f-bb391bdc64cd DST_KEY=ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2 [stdout] SRC_KEY=3e421f97-9be9-4d2c-8934-b9ece620d63e SQS_KEY=8dbfa037-0ef7-4dfb-a71f-bb391bdc64cd DST_KEY=ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
{
"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/image-ingest-consumer*"
},
{
"Sid": "ConsumeFromMainQueue",
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
},
{
"Sid": "ReadSourceObjects",
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
},
{
"Sid": "WriteProcessedObjects",
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
},
{
"Sid": "WriteDdbRecord",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records"
},
{
"Sid": "DecryptSqsMessages",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/SQS_KEY_PLACEHOLDER"
},
{
"Sid": "DecryptSourceObjects",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/SRC_KEY_PLACEHOLDER"
},
{
"Sid": "EncryptDestObjects",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/DST_KEY_PLACEHOLDER"
}
]
}
Create Lambda IAM role with inline least-privilege policy
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
sed -e "s|SQS_KEY_PLACEHOLDER|$SQS_KEY|g" \
-e "s|SRC_KEY_PLACEHOLDER|$SRC_KEY|g" \
-e "s|DST_KEY_PLACEHOLDER|$DST_KEY|g" \
/app/policies/lambda-inline.json > /app/build/lambda-inline.json && \
aws --endpoint-url=$AWS_ENDPOINT_URL iam create-role \
--role-name image-ingest-lambda-role \
--assume-role-policy-document file:///app/policies/lambda-trust.json \
--query 'Role.Arn' --output text && \
aws --endpoint-url=$AWS_ENDPOINT_URL iam put-role-policy \
--role-name image-ingest-lambda-role \
--policy-name image-ingest-lambda-inline \
--policy-document file:///app/build/lambda-inline.json && \
echo "role created"arn:aws:iam::000000000000:role/image-ingest-lambda-role role created [stdout] arn:aws:iam::000000000000:role/image-ingest-lambda-role role created
Create DynamoDB table
export AWS_ENDPOINT_URL=http://localstack:4566 && \ aws --endpoint-url=$AWS_ENDPOINT_URL dynamodb create-table \ --table-name image-ingest-records \ --attribute-definitions AttributeName=object_key,AttributeType=S \ --key-schema AttributeName=object_key,KeyType=HASH \ --billing-mode PAY_PER_REQUEST \ --query 'TableDescription.TableArn' --output text
arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records [stdout] arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records
Create DLQ
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
aws --endpoint-url=$AWS_ENDPOINT_URL sqs create-queue \
--queue-name ingest-dlq \
--attributes '{"SqsManagedSseEnabled":"true","MessageRetentionPeriod":"1209600"}' \
--query 'QueueUrl' --output text | tee /app/build/dlq-url.txt && \
DLQ_URL=$(cat /app/build/dlq-url.txt) && \
DLQ_ARN=$(aws --endpoint-url=$AWS_ENDPOINT_URL sqs get-queue-attributes --queue-url $DLQ_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text) && \
echo "DLQ_URL=$DLQ_URL" >> /app/build/vars.sh && \
echo "DLQ_ARN=$DLQ_ARN" >> /app/build/vars.sh && \
echo "DLQ_ARN=$DLQ_ARN"http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-dlq DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-dlq [stdout] http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-dlq DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-dlq
Create main queue with SSE-KMS + redrive
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
cat > /app/build/main-queue-attrs.json <<EOF
{
"VisibilityTimeout": "180",
"MessageRetentionPeriod": "345600",
"KmsMasterKeyId": "$SQS_KEY",
"KmsDataKeyReusePeriodSeconds": "300",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"$DLQ_ARN\",\"maxReceiveCount\":\"5\"}"
}
EOF
aws --endpoint-url=$AWS_ENDPOINT_URL sqs create-queue \
--queue-name ingest-events \
--attributes file:///app/build/main-queue-attrs.json \
--query 'QueueUrl' --output text | tee /app/build/main-url.txt && \
MAIN_URL=$(cat /app/build/main-url.txt) && \
MAIN_ARN=$(aws --endpoint-url=$AWS_ENDPOINT_URL sqs get-queue-attributes --queue-url $MAIN_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text) && \
echo "MAIN_URL=$MAIN_URL" >> /app/build/vars.sh && \
echo "MAIN_ARN=$MAIN_ARN" >> /app/build/vars.sh && \
echo "MAIN_ARN=$MAIN_ARN"http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events MAIN_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events [stdout] http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events MAIN_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
Attach SQS resource policy allowing S3 to SendMessage
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
cat > /app/build/queue-policy.json <<EOF
{
"Version": "2012-10-17",
"Id": "ingest-events-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucketToSendMessage",
"Effect": "Allow",
"Principal": { "Service": "s3.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "$MAIN_ARN",
"Condition": {
"ArnEquals": { "aws:SourceArn": "arn:aws:s3:::ingest-source-bucket" },
"StringEquals": { "aws:SourceAccount": "000000000000" }
}
}
]
}
EOF
aws --endpoint-url=$AWS_ENDPOINT_URL sqs set-queue-attributes \
--queue-url $MAIN_URL \
--attributes "Policy=$(cat /app/build/queue-policy.json | jq -c . | jq -Rs .)" && \
echo "queue policy set"queue policy set [stdout] queue policy set
Create S3 buckets with SSE-KMS default encryption
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
aws --endpoint-url=$AWS_ENDPOINT_URL s3api create-bucket --bucket ingest-source-bucket --query 'Location' --output text && \
aws --endpoint-url=$AWS_ENDPOINT_URL s3api create-bucket --bucket ingest-dest-bucket --query 'Location' --output text && \
cat > /app/build/src-enc.json <<EOF
{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/$SRC_KEY"
},
"BucketKeyEnabled": false
}
]
}
EOF
cat > /app/build/dst-enc.json <<EOF
{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/$DST_KEY"
},
"BucketKeyEnabled": false
}
]
}
EOF
aws --endpoint-url=$AWS_ENDPOINT_URL s3api put-bucket-encryption --bucket ingest-source-bucket --server-side-encryption-configuration file:///app/build/src-enc.json && \
aws --endpoint-url=$AWS_ENDPOINT_URL s3api put-bucket-encryption --bucket ingest-dest-bucket --server-side-encryption-configuration file:///app/build/dst-enc.json && \
echo "---encryption configured" && \
aws --endpoint-url=$AWS_ENDPOINT_URL s3api get-bucket-encryption --bucket ingest-source-bucket && \
aws --endpoint-url=$AWS_ENDPOINT_URL s3api get-bucket-encryption --bucket ingest-dest-bucket/ingest-source-bucket
/ingest-dest-bucket
---encryption configured
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/3e421f97-9be9-4d2c-8934-b9ece620d63e"
},
"BucketKeyEnabled": false
}
]
}
}
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2"
},
"BucketKeyEnabled": false
}
]
}
}
[stdout]
/ingest-source-bucket
/ingest-dest-bucket
---encryption configured
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/3e421f97-9be9-4d2c-8934-b9ece620d63e"
},
"BucketKeyEnabled": false
}
]
}
}
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2"
},
"BucketKeyEnabled": false
}
]
}
}Attach S3->SQS notification
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
cat > /app/build/notif.json <<EOF
{
"QueueConfigurations": [
{
"Id": "ingest-events-objectcreated",
"QueueArn": "$MAIN_ARN",
"Events": ["s3:ObjectCreated:*"]
}
]
}
EOF
aws --endpoint-url=$AWS_ENDPOINT_URL s3api put-bucket-notification-configuration \
--bucket ingest-source-bucket \
--notification-configuration file:///app/build/notif.json && \
aws --endpoint-url=$AWS_ENDPOINT_URL s3api get-bucket-notification-configuration --bucket ingest-source-bucket{
"QueueConfigurations": [
{
"Id": "ingest-events-objectcreated",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
[stdout]
{
"QueueConfigurations": [
{
"Id": "ingest-events-objectcreated",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}import json
import logging
import os
from datetime import datetime, timezone
from urllib.parse import unquote_plus
import boto3
log = logging.getLogger()
log.setLevel(logging.INFO)
_ENDPOINT = os.environ.get("AWS_ENDPOINT_URL") or os.environ.get("LOCALSTACK_ENDPOINT")
_boto_kwargs = {"endpoint_url": _ENDPOINT} if _ENDPOINT else {}
s3 = boto3.client("s3", **_boto_kwargs)
ddb = boto3.client("dynamodb", **_boto_kwargs)
DEST_BUCKET = os.environ["DEST_BUCKET"]
TABLE_NAME = os.environ["TABLE_NAME"]
DEST_KMS_KEY_ARN = os.environ["DEST_KMS_KEY_ARN"]
def _process_s3_record(s3_rec):
bucket = s3_rec["s3"]["bucket"]["name"]
# S3 event notifications URL-encode keys (spaces -> "+", etc.)
raw_key = s3_rec["s3"]["object"]["key"]
key = unquote_plus(raw_key)
obj = s3.get_object(Bucket=bucket, Key=key)
body = obj["Body"].read()
size = obj.get("ContentLength", len(body))
dest_key = f"processed/{key}"
s3.put_object(
Bucket=DEST_BUCKET,
Key=dest_key,
Body=body,
ServerSideEncryption="aws:kms",
SSEKMSKeyId=DEST_KMS_KEY_ARN,
)
ddb.put_item(
TableName=TABLE_NAME,
Item={
"object_key": {"S": key},
"source_size": {"N": str(size)},
"processed_at": {"S": datetime.now(timezone.utc).isoformat()},
"status": {"S": "OK"},
},
)
return key
def lambda_handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
message_id = record.get("messageId")
try:
body = json.loads(record["body"])
# S3 test events have no "Records" , ignore gracefully
s3_records = body.get("Records") or []
if not s3_records:
log.info("no s3 records in message %s, skipping", message_id)
continue
for s3_rec in s3_records:
key = _process_s3_record(s3_rec)
log.info("processed %s", key)
except Exception:
log.exception("failed to process message %s", message_id)
batch_item_failures.append({"itemIdentifier": message_id})
return {"batchItemFailures": batch_item_failures}
Package and deploy Lambda
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
cd /app/lambda && zip -q /app/build/handler.zip handler.py && ls -la /app/build/handler.zip && \
ROLE_ARN=arn:aws:iam::000000000000:role/image-ingest-lambda-role && \
DEST_KMS_ARN=arn:aws:kms:us-east-1:000000000000:key/$DST_KEY && \
aws --endpoint-url=$AWS_ENDPOINT_URL lambda create-function \
--function-name image-ingest-consumer \
--runtime python3.11 \
--role $ROLE_ARN \
--handler handler.lambda_handler \
--zip-file fileb:///app/build/handler.zip \
--timeout 30 \
--environment "Variables={DEST_BUCKET=ingest-dest-bucket,TABLE_NAME=image-ingest-records,DEST_KMS_KEY_ARN=$DEST_KMS_ARN,AWS_ENDPOINT_URL=http://localstack:4566}" \
--query 'FunctionArn' --output text-rw-r--r-- 1 root root 1147 Apr 22 08:44 /app/build/handler.zip arn:aws:lambda:us-east-1:000000000000:function:image-ingest-consumer [stdout] -rw-r--r-- 1 root root 1147 Apr 22 08:44 /app/build/handler.zip arn:aws:lambda:us-east-1:000000000000:function:image-ingest-consumer
Wait for Lambda to become Active
export AWS_ENDPOINT_URL=http://localstack:4566 && \ for i in 1 2 3 4 5 6 7 8 9 10; do STATE=$(aws --endpoint-url=$AWS_ENDPOINT_URL lambda get-function --function-name image-ingest-consumer --query 'Configuration.State' --output text 2>/dev/null) echo "state=$STATE" [ "$STATE" = "Active" ] && break sleep 2 done
state=Active [stdout] state=Active
Create event source mapping with ReportBatchItemFailures
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \ aws --endpoint-url=$AWS_ENDPOINT_URL lambda create-event-source-mapping \ --function-name image-ingest-consumer \ --event-source-arn $MAIN_ARN \ --batch-size 10 \ --maximum-batching-window-in-seconds 1 \ --function-response-types ReportBatchItemFailures \ --query 'UUID' --output text | tee /app/build/esm-uuid.txt
08323bb1-ce6e-4498-ad39-959e3617e71b [stdout] 08323bb1-ce6e-4498-ad39-959e3617e71b
Verify event source mapping enabled
export AWS_ENDPOINT_URL=http://localstack:4566 && \
ESM_UUID=$(cat /app/build/esm-uuid.txt) && \
for i in 1 2 3 4 5 6 7 8 9 10; do
STATE=$(aws --endpoint-url=$AWS_ENDPOINT_URL lambda get-event-source-mapping --uuid $ESM_UUID --query 'State' --output text 2>/dev/null)
echo "esm_state=$STATE"
[ "$STATE" = "Enabled" ] && break
sleep 2
done
aws --endpoint-url=$AWS_ENDPOINT_URL lambda get-event-source-mapping --uuid $ESM_UUID --query '{State:State,FnRespTypes:FunctionResponseTypes,BatchSize:BatchSize}'esm_state=Enabled
{
"State": "Enabled",
"FnRespTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
[stdout]
esm_state=Enabled
{
"State": "Enabled",
"FnRespTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}Upload test objects including one with spaces
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \ echo "hello world" > /tmp/test1.txt && \ aws --endpoint-url=$AWS_ENDPOINT_URL s3 cp /tmp/test1.txt s3://ingest-source-bucket/test1.txt && \ echo "spaces content" > "/tmp/file with spaces.txt" && \ aws --endpoint-url=$AWS_ENDPOINT_URL s3 cp "/tmp/file with spaces.txt" "s3://ingest-source-bucket/file with spaces.txt" && \ echo "uploads done"
Completed 12 Bytes/12 Bytes (712 Bytes/s) with 1 file(s) remaining upload: ../../tmp/test1.txt to s3://ingest-source-bucket/test1.txt Completed 15 Bytes/15 Bytes (2.6 KiB/s) with 1 file(s) remaining upload: ../../tmp/file with spaces.txt to s3://ingest-source-bucket/file with spaces.txt uploads done [stdout] Completed 12 Bytes/12 Bytes (712 Bytes/s) with 1 file(s) remaining upload: ../../tmp/test1.txt to s3://ingest-source-bucket/test1.txt Completed 15 Bytes/15 Bytes (2.6 KiB/s) with 1 file(s) remaining upload: ../../tmp/file with spaces.txt to s3://ingest-source-bucket/file with spaces.txt uploads done
Verify processed objects and DDB rows after S3 notification pipeline
export AWS_ENDPOINT_URL=http://localstack:4566 && sleep 8 && \
echo "--- dest bucket:" && aws --endpoint-url=$AWS_ENDPOINT_URL s3 ls s3://ingest-dest-bucket/ --recursive && \
echo "--- ddb scan:" && aws --endpoint-url=$AWS_ENDPOINT_URL dynamodb scan --table-name image-ingest-records --query 'Items[*].{key:object_key.S,size:source_size.N,status:status.S}' && \
echo "--- dlq depth:" && source /app/build/vars.sh && aws --endpoint-url=$AWS_ENDPOINT_URL sqs get-queue-attributes --queue-url $DLQ_URL --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible --query 'Attributes'--- dest bucket:
2026-04-22 08:44:59 15 processed/file with spaces.txt
2026-04-22 08:44:59 12 processed/test1.txt
--- ddb scan:
[
{
"key": "test1.txt",
"size": "12",
"status": "OK"
},
{
"key": "file with spaces.txt",
"size": "15",
"status": "OK"
}
]
--- dlq depth:
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
[stdout]
--- dest bucket:
2026-04-22 08:44:59 15 processed/file with spaces.txt
2026-04-22 08:44:59 12 processed/test1.txt
--- ddb scan:
[
{
"key": "test1.txt",
"size": "12",
"status": "OK"
},
{
"key": "file with spaces.txt",
"size": "15",
"status": "OK"
}
]
--- dlq depth:
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}{
"Records": [
{
"messageId": "good-msg-id",
"receiptHandle": "r1",
"body": "{\"Records\":[{\"s3\":{\"bucket\":{\"name\":\"ingest-source-bucket\"},\"object\":{\"key\":\"test1.txt\"}}}]}",
"eventSource": "aws:sqs"
},
{
"messageId": "broken-msg-id",
"receiptHandle": "r2",
"body": "{\"Records\":[{\"s3\":{\"bucket\":{\"name\":\"ingest-source-bucket\"},\"object\":{\"key\":\"does-not-exist-key-xyz\"}}}]}",
"eventSource": "aws:sqs"
}
]
}
Invoke Lambda with partial-batch event
export AWS_ENDPOINT_URL=http://localstack:4566 && \ aws --endpoint-url=$AWS_ENDPOINT_URL lambda invoke \ --function-name image-ingest-consumer \ --payload fileb:///app/build/partial-batch.json \ --cli-binary-format raw-in-base64-out \ /tmp/resp.json >/dev/null && \ echo "--- response:" && cat /tmp/resp.json && echo
--- response:
{"batchItemFailures": [{"itemIdentifier": "broken-msg-id"}]}
[stdout]
--- response:
{"batchItemFailures": [{"itemIdentifier": "broken-msg-id"}]}Final verification of the full stack
export AWS_ENDPOINT_URL=http://localstack:4566 && source /app/build/vars.sh && \
echo "=== FINAL VERIFICATION ===" && \
echo "--- KMS aliases:" && aws --endpoint-url=$AWS_ENDPOINT_URL kms list-aliases --query 'Aliases[?starts_with(AliasName, `alias/source-bucket-cmk`) || starts_with(AliasName, `alias/sqs-queue-cmk`) || starts_with(AliasName, `alias/dest-bucket-cmk`)].[AliasName,TargetKeyId]' --output table && \
echo "--- source bucket enc:" && aws --endpoint-url=$AWS_ENDPOINT_URL s3api get-bucket-encryption --bucket ingest-source-bucket --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault' && \
echo "--- dest bucket enc:" && aws --endpoint-url=$AWS_ENDPOINT_URL s3api get-bucket-encryption --bucket ingest-dest-bucket --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault' && \
echo "--- main queue attrs:" && aws --endpoint-url=$AWS_ENDPOINT_URL sqs get-queue-attributes --queue-url $MAIN_URL --attribute-names KmsMasterKeyId VisibilityTimeout RedrivePolicy --query 'Attributes' && \
echo "--- ESM:" && aws --endpoint-url=$AWS_ENDPOINT_URL lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[0].{State:State,FnResp:FunctionResponseTypes,Queue:EventSourceArn}' && \
echo "--- dest bucket contents:" && aws --endpoint-url=$AWS_ENDPOINT_URL s3 ls s3://ingest-dest-bucket/ --recursive && \
echo "--- DDB count:" && aws --endpoint-url=$AWS_ENDPOINT_URL dynamodb scan --table-name image-ingest-records --select COUNT --query 'Count' && \
echo "--- DLQ depth:" && aws --endpoint-url=$AWS_ENDPOINT_URL sqs get-queue-attributes --queue-url $DLQ_URL --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages'=== FINAL VERIFICATION ===
--- KMS aliases:
---------------------------------------------------------------------
| ListAliases |
+--------------------------+----------------------------------------+
| alias/source-bucket-cmk | 3e421f97-9be9-4d2c-8934-b9ece620d63e |
| alias/sqs-queue-cmk | 8dbfa037-0ef7-4dfb-a71f-bb391bdc64cd |
| alias/dest-bucket-cmk | ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2 |
+--------------------------+----------------------------------------+
--- source bucket enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/3e421f97-9be9-4d2c-8934-b9ece620d63e"
}
--- dest bucket enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2"
}
--- main queue attrs:
{
"KmsMasterKeyId": "8dbfa037-0ef7-4dfb-a71f-bb391bdc64cd",
"VisibilityTimeout": "180",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}"
}
--- ESM:
{
"State": "Enabled",
"FnResp": [
"ReportBatchItemFailures"
],
"Queue": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
}
--- dest bucket contents:
2026-04-22 08:44:59 15 processed/file with spaces.txt
2026-04-22 08:45:17 12 processed/test1.txt
--- DDB count:
2
--- DLQ depth:
"0"
[stdout]
=== FINAL VERIFICATION ===
--- KMS aliases:
---------------------------------------------------------------------
| ListAliases |
+--------------------------+----------------------------------------+
| alias/source-bucket-cmk | 3e421f97-9be9-4d2c-8934-b9ece620d63e |
| alias/sqs-queue-cmk | 8dbfa037-0ef7-4dfb-a71f-bb391bdc64cd |
| alias/dest-bucket-cmk | ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2 |
+--------------------------+----------------------------------------+
--- source bucket enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/3e421f97-9be9-4d2c-8934-b9ece620d63e"
}
--- dest bucket enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/ea1389a1-c213-4b2e-b8e1-7bf2d036e3c2"
}
--- main queue attrs:
{
"KmsMasterKeyId": "8dbfa037-0ef7-4dfb-a71f-bb391bdc64cd",
"VisibilityTimeout": "180",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}"
}
--- ESM:
{
"State": "Enabled",
"FnResp": [
"ReportBatchItemFailures"
],
"Queue": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
}
--- dest bucket contents:
2026-04-22 08:44:59 15 processed/file with spaces.txt
2026-04-22 08:45:17 12 processed/test1.txt
--- DDB count:
2
--- DLQ depth:
"0"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/SVpk3-ItEL-WOq9WM2g-5/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 18 items
../tests/test_outputs.py::test_aws_cli_binary_is_real PASSED [ 5%]
../tests/test_outputs.py::test_localstack_reachable PASSED [ 11%]
../tests/test_outputs.py::test_source_and_dest_buckets_use_customer_cmks PASSED [ 16%]
../tests/test_outputs.py::test_main_queue_and_dlq_exist_and_are_encrypted FAILED [ 22%]
../tests/test_outputs.py::test_lambda_exists_with_expected_handler PASSED [ 27%]
../tests/test_outputs.py::test_ddb_table_exists PASSED [ 33%]
../tests/test_outputs.py::test_source_bucket_notification_targets_main_queue PASSED [ 38%]
../tests/test_outputs.py::test_main_queue_policy_allows_s3_service FAILED [ 44%]
../tests/test_outputs.py::test_sqs_cmk_allows_s3_service PASSED [ 50%]
../tests/test_outputs.py::test_source_cmk_allows_lambda_role_decrypt PASSED [ 55%]
../tests/test_outputs.py::test_dest_cmk_allows_lambda_role_encrypt PASSED [ 61%]
../tests/test_outputs.py::test_lambda_role_has_kms_decrypt_on_sqs_cmk PASSED [ 66%]
../tests/test_outputs.py::test_main_queue_visibility_timeout_covers_lambda_timeout PASSED [ 72%]
../tests/test_outputs.py::test_event_source_mapping_declares_report_batch_item_failures PASSED [ 77%]
../tests/test_outputs.py::test_lambda_handler_returns_correct_response_shape FAILED [ 83%]
../tests/test_outputs.py::test_end_to_end_preserves_object_size PASSED [ 88%]
../tests/test_outputs.py::test_end_to_end_key_with_spaces_is_decoded PASSED [ 94%]
../tests/test_outputs.py::test_end_to_end_upload_propagates_to_dest_and_ddb PASSED [100%]
=================================== FAILURES ===================================
_______________ test_main_queue_and_dlq_exist_and_are_encrypted ________________
sqs = <botocore.client.SQS object at 0xffffa9c5c440>
kms = <botocore.client.KMS object at 0xffffa9cb12e0>
def test_main_queue_and_dlq_exist_and_are_encrypted(sqs, kms):
"""Queues encrypted."""
main_url = _queue_url(sqs, MAIN_QUEUE)
main_attrs = _queue_attrs(sqs, main_url)
assert main_attrs.get("KmsMasterKeyId"), (
f"{MAIN_QUEUE} missing KmsMasterKeyId (SSE-KMS)"
)
expected = _resolve_key_id(kms, SQS_CMK_ALIAS)
assert expected in main_attrs["KmsMasterKeyId"] or main_attrs[
"KmsMasterKeyId"
].endswith(expected), (
f"{MAIN_QUEUE} encrypted with {main_attrs['KmsMasterKeyId']}, "
f"expected {SQS_CMK_ALIAS} ({expected})"
)
dlq_url = _queue_url(sqs, DLQ_QUEUE)
dlq_attrs = _queue_attrs(sqs, dlq_url)
> assert dlq_attrs.get("KmsMasterKeyId"), f"{DLQ_QUEUE} missing SSE-KMS"
E AssertionError: ingest-dlq missing SSE-KMS
E assert None
E + where None = <built-in method get of dict object at 0xffffa9c80e00>('KmsMasterKeyId')
E + where <built-in method get of dict object at 0xffffa9c80e00> = {'ApproximateNumberOfMessages': '0', 'ApproximateNumberOfMessagesDelayed': '0', 'ApproximateNumberOfMessagesNotVisible': '0', 'CreatedTimestamp': '1776847391', ...}.get
/tests/test_outputs.py:208: AssertionError
___________________ test_main_queue_policy_allows_s3_service ___________________
sqs = <botocore.client.SQS object at 0xffffa9c5c440>
def test_main_queue_policy_allows_s3_service(sqs):
"""Queue policy allows S3."""
url = _queue_url(sqs, MAIN_QUEUE)
attrs = _queue_attrs(sqs, url, attrs=("Policy", "QueueArn"))
policy_str = attrs.get("Policy")
assert policy_str, (
f"{MAIN_QUEUE} has no Policy attribute set. S3 cannot send "
f"ObjectCreated events to a queue without a matching queue "
f"policy allowing s3.amazonaws.com to sqs:SendMessage."
)
> policy = json.loads(policy_str)
^^^^^^^^^^^^^^^^^^^^^^
/tests/test_outputs.py:267:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/usr/lib/python3.12/json/__init__.py:346: in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <json.decoder.JSONDecoder object at 0xffffabca9850>
s = '{"Version":"2012-10-17","Id":"ingest-events-policy","Statement":[{"Sid":"AllowS3SourceBucketToSendMessage","Effect":"...uals":{"aws:SourceArn":"arn:aws:s3:::ingest-source-bucket"},"StringEquals":{"aws:SourceAccount":"000000000000"}}}]}\\n'
_w = <built-in method match of re.Pattern object at 0xffffab8fc930>
def decode(self, s, _w=WHITESPACE.match):
"""Return the Python representation of ``s`` (a ``str`` instance
containing a JSON document).
"""
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
end = _w(s, end).end()
if end != len(s):
> raise JSONDecodeError("Extra data", s, end)
E json.decoder.JSONDecodeError: Extra data: line 1 column 391 (char 390)
/usr/lib/python3.12/json/decoder.py:340: JSONDecodeError
______________ test_lambda_handler_returns_correct_response_shape ______________
lmb = <botocore.client.Lambda object at 0xffffabae3860>
sqs = <botocore.client.SQS object at 0xffffa9c5c440>
s3 = <botocore.client.S3 object at 0xffffa9f90ec0>
def test_lambda_handler_returns_correct_response_shape(lmb, sqs, s3):
"""Partial batch response shape."""
probe_key = f"probe/verifier-{uuid.uuid4().hex[:8]}.bin"
s3.put_object(Bucket=SRC_BUCKET, Key=probe_key, Body=b"x" * 32)
main_arn = _queue_attrs(
sqs, _queue_url(sqs, MAIN_QUEUE), attrs=("QueueArn",)
)["QueueArn"]
good_body = {
"Records": [
{
"s3": {
"bucket": {"name": SRC_BUCKET},
"object": {"key": probe_key},
}
}
]
}
bad_body = {"some-other-shape": "not-an-s3-event"}
good_mid = "msg-good-" + uuid.uuid4().hex[:8]
bad_mid = "msg-bad-" + uuid.uuid4().hex[:8]
event = {
"Records": [
{
"messageId": good_mid,
"body": json.dumps(good_body),
"eventSource": "aws:sqs",
"eventSourceARN": main_arn,
"awsRegion": REGION,
},
{
"messageId": bad_mid,
"body": json.dumps(bad_body),
"eventSource": "aws:sqs",
"eventSourceARN": main_arn,
"awsRegion": REGION,
},
]
}
resp = lmb.invoke(
FunctionName=LAMBDA_FUNC,
InvocationType="RequestResponse",
Payload=json.dumps(event).encode(),
)
func_err = resp.get("FunctionError")
payload = resp["Payload"].read()
assert not func_err, (
f"Lambda invocation raised FunctionError={func_err}. "
f"Payload: {payload!r}"
)
result = json.loads(payload) if payload else None
assert isinstance(result, dict), (
f"Lambda returned non-object payload: {result!r}. Expected "
f"{{'batchItemFailures': [...]}}."
)
assert "batchItemFailures" in result, (
f"Lambda response is missing the required `batchItemFailures` "
f"key. AWS silently ignores any other key (e.g. "
f"`failedBatchItems`) when FunctionResponseTypes includes "
f"ReportBatchItemFailures. Got: {result}"
)
failures = result["batchItemFailures"]
assert isinstance(failures, list), (
f"batchItemFailures must be a list; got {type(failures).__name__}"
)
ids = [f.get("itemIdentifier") for f in failures]
> assert bad_mid in ids, (
f"Expected the bad record {bad_mid} in batchItemFailures; "
f"got ids={ids}. The handler must catch per-record exceptions "
f"and return the failed messageIds."
)
E AssertionError: Expected the bad record msg-bad-64de4c4f in batchItemFailures; got ids=[]. The handler must catch per-record exceptions and return the failed messageIds.
E assert 'msg-bad-64de4c4f' in []
/tests/test_outputs.py:522: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 59 warnings
/root/.cache/uv/archive-v0/SVpk3-ItEL-WOq9WM2g-5/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_aws_cli_binary_is_real
PASSED ../tests/test_outputs.py::test_localstack_reachable
PASSED ../tests/test_outputs.py::test_source_and_dest_buckets_use_customer_cmks
PASSED ../tests/test_outputs.py::test_lambda_exists_with_expected_handler
PASSED ../tests/test_outputs.py::test_ddb_table_exists
PASSED ../tests/test_outputs.py::test_source_bucket_notification_targets_main_queue
PASSED ../tests/test_outputs.py::test_sqs_cmk_allows_s3_service
PASSED ../tests/test_outputs.py::test_source_cmk_allows_lambda_role_decrypt
PASSED ../tests/test_outputs.py::test_dest_cmk_allows_lambda_role_encrypt
PASSED ../tests/test_outputs.py::test_lambda_role_has_kms_decrypt_on_sqs_cmk
PASSED ../tests/test_outputs.py::test_main_queue_visibility_timeout_covers_lambda_timeout
PASSED ../tests/test_outputs.py::test_event_source_mapping_declares_report_batch_item_failures
PASSED ../tests/test_outputs.py::test_end_to_end_preserves_object_size
PASSED ../tests/test_outputs.py::test_end_to_end_key_with_spaces_is_decoded
PASSED ../tests/test_outputs.py::test_end_to_end_upload_propagates_to_dest_and_ddb
FAILED ../tests/test_outputs.py::test_main_queue_and_dlq_exist_and_are_encrypted
FAILED ../tests/test_outputs.py::test_main_queue_policy_allows_s3_service - j...
FAILED ../tests/test_outputs.py::test_lambda_handler_returns_correct_response_shape
================== 3 failed, 15 passed, 59 warnings in 10.75s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_a7aa148352ff4136. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_a7aa148352ff4136 · verifier authoritative; classifier explanatory.