tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check versions and localstack connectivity
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"
}Create workspace
mkdir -p /app/build && cd /app/build && echo "workspace ready" && export AWS_ENDPOINT_URL=http://localstack:4566 && env | grep AWS
workspace ready 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] workspace ready AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
export AWS_ENDPOINT_URL=http://localstack:4566 export AWS_DEFAULT_REGION=us-east-1 export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test export ACCOUNT=000000000000
{
"Version": "2012-10-17",
"Id": "source-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableRootAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowS3ServiceUse",
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleDecrypt",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"},
"Action": [
"kms:Decrypt"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Id": "sqs-queue-cmk-policy",
"Statement": [
{
"Sid": "EnableRootAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowS3ServiceToEnqueue",
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleConsume",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Id": "dest-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableRootAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleEncrypt",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"},
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
}
]
}
Create 3 KMS keys and aliases
source env.sh && \ SRC_KEY=$(aws kms create-key --description "source bucket CMK" --policy file://kms-source-policy.json --query 'KeyMetadata.KeyId' --output text) && echo "SRC_KEY=$SRC_KEY" && \ SQS_KEY=$(aws kms create-key --description "sqs queue CMK" --policy file://kms-sqs-policy.json --query 'KeyMetadata.KeyId' --output text) && echo "SQS_KEY=$SQS_KEY" && \ DST_KEY=$(aws kms create-key --description "dest bucket CMK" --policy file://kms-dest-policy.json --query 'KeyMetadata.KeyId' --output text) && echo "DST_KEY=$DST_KEY" && \ aws kms create-alias --alias-name alias/source-bucket-cmk --target-key-id "$SRC_KEY" && \ aws kms create-alias --alias-name alias/sqs-queue-cmk --target-key-id "$SQS_KEY" && \ aws kms create-alias --alias-name alias/dest-bucket-cmk --target-key-id "$DST_KEY" && \ echo "SRC_KEY=$SRC_KEY" > keys.env && \ echo "SQS_KEY=$SQS_KEY" >> keys.env && \ echo "DST_KEY=$DST_KEY" >> keys.env && \ cat keys.env
SRC_KEY=d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb SQS_KEY=e17db9db-0f42-4b14-9cfe-4a47f088ef89 DST_KEY=386438ac-69b0-4a36-a1a5-18dd00163f19 SRC_KEY=d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb SQS_KEY=e17db9db-0f42-4b14-9cfe-4a47f088ef89 DST_KEY=386438ac-69b0-4a36-a1a5-18dd00163f19 [stdout] SRC_KEY=d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb SQS_KEY=e17db9db-0f42-4b14-9cfe-4a47f088ef89 DST_KEY=386438ac-69b0-4a36-a1a5-18dd00163f19 SRC_KEY=d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb SQS_KEY=e17db9db-0f42-4b14-9cfe-4a47f088ef89 DST_KEY=386438ac-69b0-4a36-a1a5-18dd00163f19
Create DynamoDB table
source env.sh && \ aws 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.TableStatus' --output text && \ aws dynamodb wait table-exists --table-name image-ingest-records && \ echo "ddb ready"
ACTIVE ddb ready [stdout] ACTIVE ddb ready
Create DLQ with SSE-KMS
source env.sh && source keys.env && \
DLQ_URL=$(aws sqs create-queue --queue-name ingest-dlq \
--attributes "{\"KmsMasterKeyId\":\"alias/sqs-queue-cmk\",\"MessageRetentionPeriod\":\"1209600\"}" \
--query QueueUrl --output text) && \
echo "DLQ_URL=$DLQ_URL" && \
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text) && \
echo "DLQ_ARN=$DLQ_ARN" && \
echo "DLQ_URL=$DLQ_URL" > queues.env && echo "DLQ_ARN=$DLQ_ARN" >> queues.envDLQ_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-dlq DLQ_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-dlq [stdout] DLQ_URL=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 and redrive policy
source env.sh && source keys.env && source queues.env && \
REDRIVE=$(printf '{"deadLetterTargetArn":"%s","maxReceiveCount":"5"}' "$DLQ_ARN" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))') && \
ATTRS=$(python3 -c "import json; print(json.dumps({'KmsMasterKeyId':'alias/sqs-queue-cmk','VisibilityTimeout':'180','MessageRetentionPeriod':'345600','RedrivePolicy':json.dumps({'deadLetterTargetArn':'$DLQ_ARN','maxReceiveCount':5})}))") && \
echo "ATTRS=$ATTRS" && \
Q_URL=$(aws sqs create-queue --queue-name ingest-events --attributes "$ATTRS" --query QueueUrl --output text) && \
echo "Q_URL=$Q_URL" && \
Q_ARN=$(aws sqs get-queue-attributes --queue-url "$Q_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text) && \
echo "Q_ARN=$Q_ARN" && \
echo "Q_URL=$Q_URL" >> queues.env && echo "Q_ARN=$Q_ARN" >> queues.env && \
aws sqs get-queue-attributes --queue-url "$Q_URL" --attribute-names AllATTRS={"KmsMasterKeyId": "alias/sqs-queue-cmk", "VisibilityTimeout": "180", "MessageRetentionPeriod": "345600", "RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\", \"maxReceiveCount\": 5}"}
Q_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events
Q_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
{
"Attributes": {
"CreatedTimestamp": "1776849679",
"DelaySeconds": "0",
"LastModifiedTimestamp": "1776849679",
"MaximumMessageSize": "1048576",
"MessageRetentionPeriod": "345600",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"ReceiveMessageWaitTimeSeconds": "0",
"VisibilityTimeout": "180",
"SqsManagedSseEnabled": "true",
"KmsMasterKeyId": "alias/sqs-queue-cmk",
"RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\", \"maxReceiveCount\": 5}",
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesDelayed": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
}
[stdout]
ATTRS={"KmsMasterKeyId": "alias/sqs-queue-cmk", "VisibilityTimeout": "180", "MessageRetentionPeriod": "345600", "RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\", \"maxReceiveCount\": 5}"}
Q_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events
Q_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
{
"Attributes": {
"CreatedTimestamp": "1776849679",
"DelaySeconds": "0",
"LastModifiedTimestamp": "1776849679",
"MaximumMessageSize": "1048576",
"MessageRetentionPeriod": "345600",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"ReceiveMessageWaitTimeSeconds": "0",
"VisibilityTimeout": "180",
"SqsManagedSseEnabled": "true",
"KmsMasterKeyId": "alias/sqs-queue-cmk",
"RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\", \"maxReceiveCount\": 5}",
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesDelayed": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
}{
"Version": "2012-10-17",
"Id": "ingest-events-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucket",
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Condition": {
"ArnEquals": {"aws:SourceArn": "arn:aws:s3:::ingest-source-bucket"}
}
}
]
}
Attach SQS resource policy
source env.sh && source queues.env && \
POLICY=$(python3 -c "import json; print(json.dumps({'Policy': open('sqs-policy.json').read()}))") && \
aws sqs set-queue-attributes --queue-url "$Q_URL" --attributes "$POLICY" && \
echo "queue policy set"queue policy set [stdout] queue policy set
Create buckets with SSE-KMS default
source env.sh && source keys.env && \
aws s3api create-bucket --bucket ingest-source-bucket >/dev/null && \
aws s3api create-bucket --bucket ingest-dest-bucket >/dev/null && \
SRC_ENC=$(python3 -c "import json; print(json.dumps({'Rules':[{'ApplyServerSideEncryptionByDefault':{'SSEAlgorithm':'aws:kms','KMSMasterKeyID':'alias/source-bucket-cmk'},'BucketKeyEnabled':True}]}))") && \
DST_ENC=$(python3 -c "import json; print(json.dumps({'Rules':[{'ApplyServerSideEncryptionByDefault':{'SSEAlgorithm':'aws:kms','KMSMasterKeyID':'alias/dest-bucket-cmk'},'BucketKeyEnabled':True}]}))") && \
aws s3api put-bucket-encryption --bucket ingest-source-bucket --server-side-encryption-configuration "$SRC_ENC" && \
aws s3api put-bucket-encryption --bucket ingest-dest-bucket --server-side-encryption-configuration "$DST_ENC" && \
echo "--- source ---" && aws s3api get-bucket-encryption --bucket ingest-source-bucket && \
echo "--- dest ---" && aws s3api get-bucket-encryption --bucket ingest-dest-bucket--- source ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/source-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}
--- dest ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/dest-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}
[stdout]
--- source ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/source-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}
--- dest ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/dest-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}Configure S3 notification to SQS
source env.sh && source queues.env && \
NC=$(python3 -c "import json; print(json.dumps({'QueueConfigurations':[{'Id':'ingest-notif','QueueArn':'$Q_ARN','Events':['s3:ObjectCreated:*']}]}))") && \
aws s3api put-bucket-notification-configuration --bucket ingest-source-bucket --notification-configuration "$NC" && \
aws s3api get-bucket-notification-configuration --bucket ingest-source-bucket{
"QueueConfigurations": [
{
"Id": "ingest-notif",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
[stdout]
{
"QueueConfigurations": [
{
"Id": "ingest-notif",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}Resolve KMS key ARNs
source env.sh && source keys.env && \ SRC_KEY_ARN=$(aws kms describe-key --key-id "$SRC_KEY" --query 'KeyMetadata.Arn' --output text) && \ SQS_KEY_ARN=$(aws kms describe-key --key-id "$SQS_KEY" --query 'KeyMetadata.Arn' --output text) && \ DST_KEY_ARN=$(aws kms describe-key --key-id "$DST_KEY" --query 'KeyMetadata.Arn' --output text) && \ echo "SRC_KEY_ARN=$SRC_KEY_ARN" && echo "SQS_KEY_ARN=$SQS_KEY_ARN" && echo "DST_KEY_ARN=$DST_KEY_ARN" && \ echo "SRC_KEY_ARN=$SRC_KEY_ARN" >> keys.env && echo "SQS_KEY_ARN=$SQS_KEY_ARN" >> keys.env && echo "DST_KEY_ARN=$DST_KEY_ARN" >> keys.env
SRC_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb SQS_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/e17db9db-0f42-4b14-9cfe-4a47f088ef89 DST_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/386438ac-69b0-4a36-a1a5-18dd00163f19 [stdout] SRC_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb SQS_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/e17db9db-0f42-4b14-9cfe-4a47f088ef89 DST_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/386438ac-69b0-4a36-a1a5-18dd00163f19
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
Write lambda IAM policy with key ARNs
source env.sh && source keys.env && \
cat > lambda-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:*"
},
{
"Sid": "ReadSourceObjects",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
},
{
"Sid": "WriteDestObjects",
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
},
{
"Sid": "ConsumeQueue",
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
},
{
"Sid": "WriteDdb",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records"
},
{
"Sid": "KmsDecryptSourceBucket",
"Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": "$SRC_KEY_ARN"
},
{
"Sid": "KmsConsumeQueue",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "$SQS_KEY_ARN"
},
{
"Sid": "KmsEncryptDestBucket",
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:GenerateDataKey"],
"Resource": "$DST_KEY_ARN"
}
]
}
EOF
cat lambda-policy.json | python3 -c 'import json,sys; json.load(sys.stdin); print("json ok")'json ok [stdout] json ok
Create lambda role and inline policy
source env.sh && \ aws iam create-role --role-name image-ingest-lambda-role --assume-role-policy-document file://trust.json --query 'Role.Arn' --output text && \ aws iam put-role-policy --role-name image-ingest-lambda-role --policy-name image-ingest-lambda-policy --policy-document file://lambda-policy.json && \ ROLE_ARN=$(aws iam get-role --role-name image-ingest-lambda-role --query 'Role.Arn' --output text) && \ echo "ROLE_ARN=$ROLE_ARN" > role.env && cat role.env
arn:aws:iam::000000000000:role/image-ingest-lambda-role ROLE_ARN=arn:aws:iam::000000000000:role/image-ingest-lambda-role [stdout] arn:aws:iam::000000000000:role/image-ingest-lambda-role ROLE_ARN=arn:aws:iam::000000000000:role/image-ingest-lambda-role
import json
import logging
import os
import urllib.parse
from datetime import datetime, timezone
import boto3
log = logging.getLogger()
log.setLevel(logging.INFO)
DEST_BUCKET = os.environ["DEST_BUCKET"]
DEST_KMS_KEY = os.environ["DEST_KMS_KEY"]
DDB_TABLE = os.environ["DDB_TABLE"]
_endpoint = os.environ.get("AWS_ENDPOINT_URL") or None
s3 = boto3.client("s3", endpoint_url=_endpoint)
ddb = boto3.client("dynamodb", endpoint_url=_endpoint)
def _process_s3_record(s3_rec):
bucket = s3_rec["s3"]["bucket"]["name"]
raw_key = s3_rec["s3"]["object"]["key"]
key = urllib.parse.unquote_plus(raw_key)
obj = s3.get_object(Bucket=bucket, Key=key)
body = obj["Body"].read()
s3.put_object(
Bucket=DEST_BUCKET,
Key=f"processed/{key}",
Body=body,
ServerSideEncryption="aws:kms",
SSEKMSKeyId=DEST_KMS_KEY,
)
ddb.put_item(
TableName=DDB_TABLE,
Item={
"object_key": {"S": key},
"source_size": {"N": str(len(body))},
"processed_at": {"S": datetime.now(timezone.utc).isoformat()},
"status": {"S": "OK"},
},
)
def lambda_handler(event, context):
failures = []
for record in event.get("Records", []):
message_id = record["messageId"]
try:
body = json.loads(record["body"])
s3_records = body.get("Records") or []
if not s3_records:
raise ValueError("no s3 records in message body")
for s3_rec in s3_records:
_process_s3_record(s3_rec)
except Exception as exc:
log.exception("failed to process message %s: %s", message_id, exc)
failures.append({"itemIdentifier": message_id})
return {"batchItemFailures": failures}
Package lambda zip
zip -q lambda.zip handler.py && ls -la lambda.zip && unzip -l lambda.zip
-rw-r--r-- 1 root root 966 Apr 22 09:23 lambda.zip
Archive: lambda.zip
Length Date Time Name
--------- ---------- ----- ----
1791 2026-04-22 09:23 handler.py
--------- -------
1791 1 file
[stdout]
-rw-r--r-- 1 root root 966 Apr 22 09:23 lambda.zip
Archive: lambda.zip
Length Date Time Name
--------- ---------- ----- ----
1791 2026-04-22 09:23 handler.py
--------- -------
1791 1 fileDeploy lambda
source env.sh && source keys.env && source role.env && source queues.env && \
LAMBDA_ENV=$(python3 -c "
import json
print(json.dumps({'Variables':{
'DEST_BUCKET':'ingest-dest-bucket',
'DEST_KMS_KEY':'alias/dest-bucket-cmk',
'DDB_TABLE':'image-ingest-records',
'AWS_ENDPOINT_URL':'http://localstack:4566'
}}))") && \
aws lambda create-function \
--function-name image-ingest-consumer \
--runtime python3.11 \
--handler handler.lambda_handler \
--role "$ROLE_ARN" \
--zip-file fileb://lambda.zip \
--timeout 30 \
--memory-size 256 \
--environment "$LAMBDA_ENV" \
--query 'FunctionArn' --output text && \
aws lambda wait function-active-v2 --function-name image-ingest-consumer && \
echo "lambda ready"arn:aws:lambda:us-east-1:000000000000:function:image-ingest-consumer lambda ready [stdout] arn:aws:lambda:us-east-1:000000000000:function:image-ingest-consumer lambda ready
Create event source mapping with ReportBatchItemFailures
source env.sh && source queues.env && \
aws lambda create-event-source-mapping \
--function-name image-ingest-consumer \
--event-source-arn "$Q_ARN" \
--batch-size 10 \
--function-response-types ReportBatchItemFailures \
--query '{UUID:UUID,State:State,FRT:FunctionResponseTypes}' --output json{
"UUID": "d6d479ba-1fc0-45bf-aa4b-1ef05821fec8",
"State": "Creating",
"FRT": [
"ReportBatchItemFailures"
]
}
[stdout]
{
"UUID": "d6d479ba-1fc0-45bf-aa4b-1ef05821fec8",
"State": "Creating",
"FRT": [
"ReportBatchItemFailures"
]
}Wait for ESM enabled
source env.sh && \ for i in 1 2 3 4 5; do S=$(aws lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[0].State' --output text) echo "state: $S" [ "$S" = "Enabled" ] && break sleep 2 done
state: Enabled [stdout] state: Enabled
Smoke test: put plain-key object
source env.sh && source queues.env && \
echo "hello world" > /tmp/test1.txt && \
aws s3api put-object --bucket ingest-source-bucket --key "plain-key.txt" --body /tmp/test1.txt >/dev/null && \
echo "put done, waiting..." && \
for i in $(seq 1 30); do
COUNT=$(aws s3api list-objects-v2 --bucket ingest-dest-bucket --prefix "processed/plain-key.txt" --query 'length(Contents || `[]`)' --output text)
if [ "$COUNT" = "1" ]; then echo "dest has object after ${i}s"; break; fi
sleep 2
done && \
aws s3api list-objects-v2 --bucket ingest-dest-bucket --query 'Contents[].Key' && \
echo "--- ddb ---" && \
aws dynamodb get-item --table-name image-ingest-records --key '{"object_key":{"S":"plain-key.txt"}}' && \
echo "--- dlq depth ---" && \
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names ApproximateNumberOfMessagesput done, waiting...
dest has object after 7s
[
"processed/plain-key.txt"
]
--- ddb ---
{
"Item": {
"source_size": {
"N": "12"
},
"processed_at": {
"S": "2026-04-22T09:24:08.860886+00:00"
},
"object_key": {
"S": "plain-key.txt"
},
"status": {
"S": "OK"
}
}
}
--- dlq depth ---
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}
[stdout]
put done, waiting...
dest has object after 7s
[
"processed/plain-key.txt"
]
--- ddb ---
{
"Item": {
"source_size": {
"N": "12"
},
"processed_at": {
"S": "2026-04-22T09:24:08.860886+00:00"
},
"object_key": {
"S": "plain-key.txt"
},
"status": {
"S": "OK"
}
}
}
--- dlq depth ---
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}Test key with spaces
source env.sh && source queues.env && \
echo "spaces are fun" > /tmp/test2.txt && \
aws s3api put-object --bucket ingest-source-bucket --key "my cool file.txt" --body /tmp/test2.txt >/dev/null && \
echo "put done, waiting..." && \
for i in $(seq 1 30); do
GOT=$(aws s3api head-object --bucket ingest-dest-bucket --key "processed/my cool file.txt" 2>/dev/null && echo OK)
if [ -n "$GOT" ]; then echo "dest has object after ${i}s"; break; fi
sleep 2
done && \
echo "--- dest list ---" && \
aws s3api list-objects-v2 --bucket ingest-dest-bucket --query 'Contents[].Key' && \
echo "--- ddb ---" && \
aws dynamodb get-item --table-name image-ingest-records --key '{"object_key":{"S":"my cool file.txt"}}' && \
echo "--- dlq depth ---" && \
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names ApproximateNumberOfMessagesput done, waiting...
dest has object after 5s
--- dest list ---
[
"processed/my cool file.txt",
"processed/plain-key.txt"
]
--- ddb ---
{
"Item": {
"source_size": {
"N": "15"
},
"processed_at": {
"S": "2026-04-22T09:24:25.806207+00:00"
},
"object_key": {
"S": "my cool file.txt"
},
"status": {
"S": "OK"
}
}
}
--- dlq depth ---
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}
[stdout]
put done, waiting...
dest has object after 5s
--- dest list ---
[
"processed/my cool file.txt",
"processed/plain-key.txt"
]
--- ddb ---
{
"Item": {
"source_size": {
"N": "15"
},
"processed_at": {
"S": "2026-04-22T09:24:25.806207+00:00"
},
"object_key": {
"S": "my cool file.txt"
},
"status": {
"S": "OK"
}
}
}
--- dlq depth ---
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}import json
good_body = {
"Records": [
{
"s3": {
"bucket": {"name": "ingest-source-bucket"},
"object": {"key": "plain-key.txt"},
}
}
]
}
bad_body = {
"Records": [
{
"s3": {
"bucket": {"name": "ingest-source-bucket"},
"object": {"key": "does-not-exist-xyz.txt"},
}
}
]
}
event = {
"Records": [
{"messageId": "msg-good-1", "body": json.dumps(good_body)},
{"messageId": "msg-bad-1", "body": json.dumps(bad_body)},
]
}
print(json.dumps(event))
Invoke lambda with mixed batch
source env.sh && \ python3 batch_event.py > /tmp/batch-event.json && \ aws lambda invoke --function-name image-ingest-consumer --payload fileb:///tmp/batch-event.json --cli-binary-format raw-in-base64-out /tmp/batch-out.json && \ echo "--- response ---" && cat /tmp/batch-out.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- response ---
{"batchItemFailures": [{"itemIdentifier": "msg-bad-1"}]}
[stdout]
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
--- response ---
{"batchItemFailures": [{"itemIdentifier": "msg-bad-1"}]}Final verification
source env.sh && source queues.env && \
echo "=== event source mapping ===" && \
aws lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[0].{State:State,ResponseTypes:FunctionResponseTypes,BatchSize:BatchSize}' && \
echo "=== main queue attrs ===" && \
aws sqs get-queue-attributes --queue-url "$Q_URL" --attribute-names KmsMasterKeyId VisibilityTimeout RedrivePolicy --query Attributes && \
echo "=== DLQ depth ===" && \
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names ApproximateNumberOfMessages KmsMasterKeyId --query Attributes && \
echo "=== source bucket enc ===" && \
aws s3api get-bucket-encryption --bucket ingest-source-bucket --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault' && \
echo "=== dest bucket enc ===" && \
aws s3api get-bucket-encryption --bucket ingest-dest-bucket --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault' && \
echo "=== KMS aliases ===" && \
aws kms list-aliases --query "Aliases[?AliasName=='alias/source-bucket-cmk' || AliasName=='alias/sqs-queue-cmk' || AliasName=='alias/dest-bucket-cmk'].AliasName"=== event source mapping ===
{
"State": "Enabled",
"ResponseTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
=== main queue attrs ===
{
"KmsMasterKeyId": "alias/sqs-queue-cmk",
"VisibilityTimeout": "180",
"RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\", \"maxReceiveCount\": 5}"
}
=== DLQ depth ===
{
"ApproximateNumberOfMessages": "0",
"KmsMasterKeyId": "alias/sqs-queue-cmk"
}
=== source bucket enc ===
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/source-bucket-cmk"
}
=== dest bucket enc ===
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/dest-bucket-cmk"
}
=== KMS aliases ===
[
"alias/source-bucket-cmk",
"alias/sqs-queue-cmk",
"alias/dest-bucket-cmk"
]
[stdout]
=== event source mapping ===
{
"State": "Enabled",
"ResponseTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
=== main queue attrs ===
{
"KmsMasterKeyId": "alias/sqs-queue-cmk",
"VisibilityTimeout": "180",
"RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\", \"maxReceiveCount\": 5}"
}
=== DLQ depth ===
{
"ApproximateNumberOfMessages": "0",
"KmsMasterKeyId": "alias/sqs-queue-cmk"
}
=== source bucket enc ===
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/source-bucket-cmk"
}
=== dest bucket enc ===
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/dest-bucket-cmk"
}
=== KMS aliases ===
[
"alias/source-bucket-cmk",
"alias/sqs-queue-cmk",
"alias/dest-bucket-cmk"
]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/r5cae4Oe-K3f8nAR_sVTD/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 FAILED [ 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 PASSED [ 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 PASSED [ 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_source_and_dest_buckets_use_customer_cmks ________________
s3 = <botocore.client.S3 object at 0xffff90334ec0>
kms = <botocore.client.KMS object at 0xffff8f55d220>
def test_source_and_dest_buckets_use_customer_cmks(s3, kms):
"""Buckets use CMKs."""
src_cfg = s3.get_bucket_encryption(Bucket=SRC_BUCKET)
src_rules = src_cfg["ServerSideEncryptionConfiguration"]["Rules"]
assert src_rules, f"{SRC_BUCKET} has no SSE rules"
src_sse = src_rules[0]["ApplyServerSideEncryptionByDefault"]
assert src_sse["SSEAlgorithm"] == "aws:kms", (
f"{SRC_BUCKET} not using aws:kms: {src_sse}"
)
src_cmk_id = _resolve_key_id(kms, SRC_CMK_ALIAS)
> assert src_cmk_id in src_sse.get("KMSMasterKeyID", ""), (
f"{SRC_BUCKET} not using {SRC_CMK_ALIAS} ({src_cmk_id}), got "
f"{src_sse.get('KMSMasterKeyID')}"
)
E AssertionError: ingest-source-bucket not using alias/source-bucket-cmk (d96a6af1-8a26-45cd-b2ff-7daa04fdf5fb), got alias/source-bucket-cmk
E assert 'd96a6af1-8a26-45cd-b2ff-7daa04fdf5fb' in 'alias/source-bucket-cmk'
E + where 'alias/source-bucket-cmk' = <built-in method get of dict object at 0xffff8f482ec0>('KMSMasterKeyID', '')
E + where <built-in method get of dict object at 0xffff8f482ec0> = {'KMSMasterKeyID': 'alias/source-bucket-cmk', 'SSEAlgorithm': 'aws:kms'}.get
/tests/test_outputs.py:175: AssertionError
_______________ test_main_queue_and_dlq_exist_and_are_encrypted ________________
sqs = <botocore.client.SQS object at 0xffff8f4dcb00>
kms = <botocore.client.KMS object at 0xffff8f55d220>
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})"
)
E AssertionError: ingest-events encrypted with alias/sqs-queue-cmk, expected alias/sqs-queue-cmk (e17db9db-0f42-4b14-9cfe-4a47f088ef89)
E assert ('e17db9db-0f42-4b14-9cfe-4a47f088ef89' in 'alias/sqs-queue-cmk' or False)
E + where False = <built-in method endswith of str object at 0xffff8f35cef0>('e17db9db-0f42-4b14-9cfe-4a47f088ef89')
E + where <built-in method endswith of str object at 0xffff8f35cef0> = 'alias/sqs-queue-cmk'.endswith
/tests/test_outputs.py:199: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 58 warnings
/root/.cache/uv/archive-v0/r5cae4Oe-K3f8nAR_sVTD/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_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_main_queue_policy_allows_s3_service
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_lambda_handler_returns_correct_response_shape
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_source_and_dest_buckets_use_customer_cmks
FAILED ../tests/test_outputs.py::test_main_queue_and_dlq_exist_and_are_encrypted
================== 2 failed, 16 passed, 58 warnings in 12.07s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_7305e845ac184501. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_7305e845ac184501 · verifier authoritative; classifier explanatory.