tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check localstack connectivity and health
aws --endpoint-url=http://localstack:4566 sts get-caller-identity && echo "---" && curl -s http://localstack:4566/_localstack/health | jq .
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
{
"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": "running",
"support": "disabled",
"swf": "disabled",
"transcribe": "disabled"
},
"edition": "community",
"version": "4.13.1"
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
{
"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": "running",
"support": "disabled",
"swf": "disabled",
"transcribe": "disabled"
},
"edition": "community",
"version": "4.13.1"
}Create build directory and list app dir
mkdir -p /app/build && ls -la
total 12 drwxr-xr-x 1 root root 4096 Apr 23 06:01 . drwxr-xr-x 1 root root 4096 Apr 23 05:59 .. drwxr-xr-x 2 root root 4096 Apr 23 06:01 build [stdout] total 12 drwxr-xr-x 1 root root 4096 Apr 23 06:01 . drwxr-xr-x 1 root root 4096 Apr 23 05:59 .. drwxr-xr-x 2 root root 4096 Apr 23 06:01 build
Set up environment variables
export AWS_ENDPOINT_URL=http://localstack:4566 export AWS_DEFAULT_REGION=us-east-1 echo "export AWS_ENDPOINT_URL=http://localstack:4566" > /app/build/env.sh echo "export AWS_DEFAULT_REGION=us-east-1" >> /app/build/env.sh echo "export AWS_PAGER=''" >> /app/build/env.sh cat /app/build/env.sh
export AWS_ENDPOINT_URL=http://localstack:4566 export AWS_DEFAULT_REGION=us-east-1 export AWS_PAGER='' [stdout] export AWS_ENDPOINT_URL=http://localstack:4566 export AWS_DEFAULT_REGION=us-east-1 export AWS_PAGER=''
{
"Version": "2012-10-17",
"Id": "source-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableRootPermissions",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000: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"
],
"Resource": "*"
},
{
"Sid": "AllowLambdaDecryptSourceObjects",
"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": "EnableRootPermissions",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000: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"
],
"Resource": "*"
},
{
"Sid": "AllowS3ToEncryptMessagesForQueue",
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
"Resource": "*"
},
{
"Sid": "AllowLambdaDecryptQueueMessages",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"},
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Id": "dest-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableRootPermissions",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000: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"
],
"Resource": "*"
},
{
"Sid": "AllowLambdaEncryptDestObjects",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"},
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
Create 3 KMS keys and aliases
source /app/build/env.sh
SRC_KEY=$(aws kms create-key --description "source bucket CMK" --policy file:///app/build/kms-source.json --query 'KeyMetadata.KeyId' --output text)
SQS_KEY=$(aws kms create-key --description "sqs queue CMK" --policy file:///app/build/kms-sqs.json --query 'KeyMetadata.KeyId' --output text)
DEST_KEY=$(aws kms create-key --description "dest bucket CMK" --policy file:///app/build/kms-dest.json --query 'KeyMetadata.KeyId' --output text)
echo "SRC_KEY=$SRC_KEY"
echo "SQS_KEY=$SQS_KEY"
echo "DEST_KEY=$DEST_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 $DEST_KEY
echo "SRC_KEY=$SRC_KEY" > /app/build/ids.sh
echo "SQS_KEY=$SQS_KEY" >> /app/build/ids.sh
echo "DEST_KEY=$DEST_KEY" >> /app/build/ids.sh
aws kms list-aliases | jq '.Aliases[] | select(.AliasName | test("source-bucket|sqs-queue|dest-bucket")) | .AliasName'SRC_KEY=cfee14f6-ca89-4caa-8b60-c1526626a300 SQS_KEY=87f146b4-bc5f-420c-bdfc-d88f28da2254 DEST_KEY=b8b560df-2fa3-4d69-8009-6b56f9b54231 "alias/source-bucket-cmk" "alias/sqs-queue-cmk" "alias/dest-bucket-cmk" [stdout] SRC_KEY=cfee14f6-ca89-4caa-8b60-c1526626a300 SQS_KEY=87f146b4-bc5f-420c-bdfc-d88f28da2254 DEST_KEY=b8b560df-2fa3-4d69-8009-6b56f9b54231 "alias/source-bucket-cmk" "alias/sqs-queue-cmk" "alias/dest-bucket-cmk"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
Create Lambda IAM role
source /app/build/env.sh aws iam create-role --role-name image-ingest-lambda-role --assume-role-policy-document file:///app/build/trust.json --query 'Role.Arn' --output text
arn:aws:iam::000000000000:role/image-ingest-lambda-role [stdout] arn:aws:iam::000000000000:role/image-ingest-lambda-role
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
Create source and dest buckets with SSE-KMS defaults
source /app/build/env.sh
source /app/build/ids.sh
aws s3api create-bucket --bucket ingest-source-bucket
aws s3api create-bucket --bucket ingest-dest-bucket
# default SSE-KMS
cat > /app/build/src-sse.json <<EOF
{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"arn:aws:kms:us-east-1:000000000000:alias/source-bucket-cmk"},"BucketKeyEnabled":true}]}
EOF
cat > /app/build/dest-sse.json <<EOF
{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"arn:aws:kms:us-east-1:000000000000:alias/dest-bucket-cmk"},"BucketKeyEnabled":true}]}
EOF
aws s3api put-bucket-encryption --bucket ingest-source-bucket --server-side-encryption-configuration file:///app/build/src-sse.json
aws s3api put-bucket-encryption --bucket ingest-dest-bucket --server-side-encryption-configuration file:///app/build/dest-sse.json
aws s3api get-bucket-encryption --bucket ingest-source-bucket
aws s3api get-bucket-encryption --bucket ingest-dest-bucket{
"Location": "/ingest-source-bucket"
}
{
"Location": "/ingest-dest-bucket"
}
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:alias/source-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:alias/dest-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}
[stdout]
{
"Location": "/ingest-source-bucket"
}
{
"Location": "/ingest-dest-bucket"
}
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:alias/source-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:alias/dest-bucket-cmk"
},
"BucketKeyEnabled": true
}
]
}
}Create DynamoDB table
source /app/build/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.TableArn' --output text aws dynamodb describe-table --table-name image-ingest-records --query 'Table.TableStatus' --output text
arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records ACTIVE [stdout] arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records ACTIVE
Create DLQ and main SQS queue with SSE-KMS and redrive policy
source /app/build/env.sh
source /app/build/ids.sh
# DLQ
DLQ_URL=$(aws sqs create-queue \
--queue-name ingest-dlq \
--attributes KmsMasterKeyId=alias/sqs-queue-cmk \
--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"
# Main queue - visibility 60s (lambda timeout 30s), redrive to DLQ
MQ_URL=$(aws sqs create-queue \
--queue-name ingest-events \
--attributes "{\"KmsMasterKeyId\":\"alias/sqs-queue-cmk\",\"VisibilityTimeout\":\"60\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}" \
--query 'QueueUrl' --output text)
echo "MQ_URL=$MQ_URL"
MQ_ARN=$(aws sqs get-queue-attributes --queue-url $MQ_URL --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
echo "MQ_ARN=$MQ_ARN"
echo "DLQ_URL=$DLQ_URL" >> /app/build/ids.sh
echo "DLQ_ARN=$DLQ_ARN" >> /app/build/ids.sh
echo "MQ_URL=$MQ_URL" >> /app/build/ids.sh
echo "MQ_ARN=$MQ_ARN" >> /app/build/ids.sh
aws sqs get-queue-attributes --queue-url $MQ_URL --attribute-names All | jq '.Attributes | {KmsMasterKeyId,VisibilityTimeout,RedrivePolicy}'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
MQ_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events
MQ_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
{
"KmsMasterKeyId": "alias/sqs-queue-cmk",
"VisibilityTimeout": "60",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}"
}
[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
MQ_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events
MQ_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
{
"KmsMasterKeyId": "alias/sqs-queue-cmk",
"VisibilityTimeout": "60",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}"
}{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3SourceBucketToSendMessages",
"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"},
"StringEquals": {"aws:SourceAccount": "000000000000"}
}
}
]
}
Set SQS queue policy allowing S3 source bucket
source /app/build/env.sh
source /app/build/ids.sh
POLICY=$(cat /app/build/sqs-policy.json | jq -c . | jq -Rs .)
aws sqs set-queue-attributes --queue-url $MQ_URL --attributes "{\"Policy\":$POLICY}"
aws sqs get-queue-attributes --queue-url $MQ_URL --attribute-names Policy | jq -r '.Attributes.Policy' | jq .{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3SourceBucketToSendMessages",
"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"
},
"StringEquals": {
"aws:SourceAccount": "000000000000"
}
}
}
]
}
[stdout]
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3SourceBucketToSendMessages",
"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"
},
"StringEquals": {
"aws:SourceAccount": "000000000000"
}
}
}
]
}{
"QueueConfigurations": [
{
"Id": "on-object-created",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": ["s3:ObjectCreated:*"]
}
]
}
Wire S3 source bucket → SQS notification
source /app/build/env.sh aws s3api put-bucket-notification-configuration --bucket ingest-source-bucket --notification-configuration file:///app/build/notif.json aws s3api get-bucket-notification-configuration --bucket ingest-source-bucket
{
"QueueConfigurations": [
{
"Id": "on-object-created",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
[stdout]
{
"QueueConfigurations": [
{
"Id": "on-object-created",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}{
"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": "ReadFromSourceBucket",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
},
{
"Sid": "WriteToDestBucket",
"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": "WriteToDdb",
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records"
},
{
"Sid": "DecryptSourceObjects",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/SRC_KEY_PLACEHOLDER"
},
{
"Sid": "DecryptQueueMessages",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/SQS_KEY_PLACEHOLDER"
},
{
"Sid": "EncryptDestObjects",
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/DEST_KEY_PLACEHOLDER"
}
]
}
Attach scoped inline policy to lambda role
source /app/build/env.sh
source /app/build/ids.sh
sed -e "s|SRC_KEY_PLACEHOLDER|$SRC_KEY|" -e "s|SQS_KEY_PLACEHOLDER|$SQS_KEY|" -e "s|DEST_KEY_PLACEHOLDER|$DEST_KEY|" /app/build/lambda-policy.json > /app/build/lambda-policy-final.json
cat /app/build/lambda-policy-final.json | jq '.Statement[] | {Sid, Action, Resource}'
aws iam put-role-policy --role-name image-ingest-lambda-role --policy-name image-ingest-lambda-inline --policy-document file:///app/build/lambda-policy-final.json
aws iam get-role-policy --role-name image-ingest-lambda-role --policy-name image-ingest-lambda-inline --query 'PolicyDocument.Statement[].Sid'{
"Sid": "Logs",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/image-ingest-consumer:*"
}
{
"Sid": "ReadFromSourceBucket",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
}
{
"Sid": "WriteToDestBucket",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
}
{
"Sid": "ConsumeQueue",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
}
{
"Sid": "WriteToDdb",
"Action": [
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records"
}
{
"Sid": "DecryptSourceObjects",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/cfee14f6-ca89-4caa-8b60-c1526626a300"
}
{
"Sid": "DecryptQueueMessages",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/87f146b4-bc5f-420c-bdfc-d88f28da2254"
}
{
"Sid": "EncryptDestObjects",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b8b560df-2fa3-4d69-8009-6b56f9b54231"
}
[
"Logs",
"ReadFromSourceBucket",
"WriteToDestBucket",
"ConsumeQueue",
"WriteToDdb",
"DecryptSourceObjects",
"DecryptQueueMessages",
"EncryptDestObjects"
]
[stdout]
{
"Sid": "Logs",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/image-ingest-consumer:*"
}
{
"Sid": "ReadFromSourceBucket",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
}
{
"Sid": "WriteToDestBucket",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
}
{
"Sid": "ConsumeQueue",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
}
{
"Sid": "WriteToDdb",
"Action": [
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/image-ingest-records"
}
{
"Sid": "DecryptSourceObjects",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/cfee14f6-ca89-4caa-8b60-c1526626a300"
}
{
"Sid": "DecryptQueueMessages",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/87f146b4-bc5f-420c-bdfc-d88f28da2254"
}
{
"Sid": "EncryptDestObjects",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b8b560df-2fa3-4d69-8009-6b56f9b54231"
}
[
"Logs",
"ReadFromSourceBucket",
"WriteToDestBucket",
"ConsumeQueue",
"WriteToDdb",
"DecryptSourceObjects",
"DecryptQueueMessages",
"EncryptDestObjects"
]import json
import os
import urllib.parse
from datetime import datetime, timezone
import boto3
_endpoint = os.environ.get("AWS_ENDPOINT_URL") or None
_region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
s3 = boto3.client("s3", endpoint_url=_endpoint, region_name=_region)
ddb = boto3.client("dynamodb", endpoint_url=_endpoint, region_name=_region)
DEST_BUCKET = os.environ["DEST_BUCKET"]
DEST_KMS_KEY = os.environ["DEST_KMS_KEY"]
DDB_TABLE = os.environ["DDB_TABLE"]
def _process_record(record):
body = json.loads(record["body"])
s3_records = body.get("Records")
if not s3_records:
raise ValueError("no s3 records in sqs body")
for s3_rec in s3_records:
src_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=src_bucket, Key=key)
body_bytes = obj["Body"].read()
size = len(body_bytes)
s3.put_object(
Bucket=DEST_BUCKET,
Key=f"processed/{key}",
Body=body_bytes,
ServerSideEncryption="aws:kms",
SSEKMSKeyId=DEST_KMS_KEY,
)
ddb.put_item(
TableName=DDB_TABLE,
Item={
"object_key": {"S": key},
"source_size": {"N": str(size)},
"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.get("messageId")
try:
_process_record(record)
except Exception as exc:
print(f"record {message_id} failed: {exc!r}")
failures.append({"itemIdentifier": message_id})
return {"batchItemFailures": failures}
Package and deploy lambda function
source /app/build/env.sh
source /app/build/ids.sh
cd /app/build
rm -f handler.zip
zip -q handler.zip handler.py
ls -la handler.zip
DEST_KMS_KEY_ARN="arn:aws:kms:us-east-1:000000000000:alias/dest-bucket-cmk"
aws lambda create-function \
--function-name image-ingest-consumer \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/image-ingest-lambda-role \
--handler handler.lambda_handler \
--zip-file fileb:///app/build/handler.zip \
--timeout 30 \
--memory-size 256 \
--environment "Variables={AWS_ENDPOINT_URL=http://localstack:4566,DEST_BUCKET=ingest-dest-bucket,DEST_KMS_KEY=$DEST_KMS_KEY_ARN,DDB_TABLE=image-ingest-records}" \
--query 'FunctionArn' --output text-rw-r--r-- 1 root root 974 Apr 23 06:04 handler.zip arn:aws:lambda:us-east-1:000000000000:function:image-ingest-consumer [stdout] -rw-r--r-- 1 root root 974 Apr 23 06:04 handler.zip arn:aws:lambda:us-east-1:000000000000:function:image-ingest-consumer
Wait for lambda active
source /app/build/env.sh
# wait for lambda to be active
until aws lambda get-function --function-name image-ingest-consumer --query 'Configuration.State' --output text | grep -q Active; do sleep 1; done
aws lambda get-function --function-name image-ingest-consumer --query 'Configuration.{State:State,Timeout:Timeout}' --output json{
"State": "Active",
"Timeout": 30
}
[stdout]
{
"State": "Active",
"Timeout": 30
}Create event source mapping with ReportBatchItemFailures
source /app/build/env.sh source /app/build/ids.sh aws lambda create-event-source-mapping \ --function-name image-ingest-consumer \ --event-source-arn $MQ_ARN \ --batch-size 10 \ --function-response-types ReportBatchItemFailures \ --query 'UUID' --output text
1ce7f84f-33bd-46ae-b946-b20983a4b919 [stdout] 1ce7f84f-33bd-46ae-b946-b20983a4b919
Wait for ESM enabled
source /app/build/env.sh
aws lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[0].{State:State,FRT:FunctionResponseTypes}' --output json
# wait for it to enable
until aws lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[0].State' --output text | grep -q Enabled; do sleep 1; done
aws lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[0].{State:State,FRT:FunctionResponseTypes}' --output json{
"State": "Enabled",
"FRT": [
"ReportBatchItemFailures"
]
}
{
"State": "Enabled",
"FRT": [
"ReportBatchItemFailures"
]
}
[stdout]
{
"State": "Enabled",
"FRT": [
"ReportBatchItemFailures"
]
}
{
"State": "Enabled",
"FRT": [
"ReportBatchItemFailures"
]
}Smoke test basic key
source /app/build/env.sh
echo "hello world payload 1" > /tmp/a.txt
aws s3api put-object --bucket ingest-source-bucket --key plain.txt --body /tmp/a.txt --query 'ETag' --output text
sleep 5
aws s3api list-objects-v2 --bucket ingest-dest-bucket --query 'Contents[].Key'
aws dynamodb scan --table-name image-ingest-records --query 'Items[].{k:object_key.S,size:source_size.N,status:status.S}'"225f92be81fd679c49e4cc461ca82e65"
[
"processed/plain.txt"
]
[
{
"k": "plain.txt",
"size": "22",
"status": "OK"
}
]
[stdout]
"225f92be81fd679c49e4cc461ca82e65"
[
"processed/plain.txt"
]
[
{
"k": "plain.txt",
"size": "22",
"status": "OK"
}
]Verify SSE-KMS and test key with spaces
source /app/build/env.sh
aws s3api head-object --bucket ingest-dest-bucket --key processed/plain.txt --query '{enc:ServerSideEncryption,kms:SSEKMSKeyId}'
echo "image with spaces" > "/tmp/b with space.txt"
aws s3api put-object --bucket ingest-source-bucket --key "hello world/some file.png" --body "/tmp/b with space.txt" --query 'ETag' --output text
sleep 5
aws s3api list-objects-v2 --bucket ingest-dest-bucket --query 'Contents[].Key'
aws dynamodb scan --table-name image-ingest-records --query 'Items[].{k:object_key.S,size:source_size.N,status:status.S}'
aws sqs get-queue-attributes --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-dlq --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text{
"enc": "aws:kms",
"kms": "arn:aws:kms:us-east-1:000000000000:alias/dest-bucket-cmk"
}
"0c08c7db2d0e7dab249e35eb03c2ece2"
[
"processed/plain.txt"
]
[
{
"k": "plain.txt",
"size": "22",
"status": "OK"
}
]
0
[stdout]
{
"enc": "aws:kms",
"kms": "arn:aws:kms:us-east-1:000000000000:alias/dest-bucket-cmk"
}
"0c08c7db2d0e7dab249e35eb03c2ece2"
[
"processed/plain.txt"
]
[
{
"k": "plain.txt",
"size": "22",
"status": "OK"
}
]
0Recheck after longer wait
source /app/build/env.sh
sleep 8
aws s3api list-objects-v2 --bucket ingest-dest-bucket --query 'Contents[].Key'
aws dynamodb scan --table-name image-ingest-records --query 'Items[].{k:object_key.S}'
echo "---main queue depth---"
aws sqs get-queue-attributes --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text
echo "---dlq depth---"
aws sqs get-queue-attributes --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-dlq --attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages' --output text[
"processed/hello world/some file.png",
"processed/plain.txt"
]
[
{
"k": "plain.txt"
},
{
"k": "hello world/some file.png"
}
]
---main queue depth---
0
---dlq depth---
0
[stdout]
[
"processed/hello world/some file.png",
"processed/plain.txt"
]
[
{
"k": "plain.txt"
},
{
"k": "hello world/some file.png"
}
]
---main queue depth---
0
---dlq depth---
0{
"Records": [
{
"messageId": "good-msg-id",
"receiptHandle": "rh1",
"body": "{\"Records\":[{\"s3\":{\"bucket\":{\"name\":\"ingest-source-bucket\"},\"object\":{\"key\":\"plain.txt\"}}}]}",
"eventSource": "aws:sqs"
},
{
"messageId": "bad-msg-id",
"receiptHandle": "rh2",
"body": "not-json-at-all",
"eventSource": "aws:sqs"
}
]
}
Invoke handler with mixed good/bad batch
source /app/build/env.sh aws lambda invoke --function-name image-ingest-consumer --payload fileb:///app/build/partial-event.json /tmp/out.json --cli-binary-format raw-in-base64-out >/dev/null cat /tmp/out.json echo echo "---" jq . /tmp/out.json
{"batchItemFailures": [{"itemIdentifier": "bad-msg-id"}]}
---
{
"batchItemFailures": [
{
"itemIdentifier": "bad-msg-id"
}
]
}
[stdout]
{"batchItemFailures": [{"itemIdentifier": "bad-msg-id"}]}
---
{
"batchItemFailures": [
{
"itemIdentifier": "bad-msg-id"
}
]
}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/7YvzzIRHeSzayjRA0nQlt/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 0xffffa060fb60>
kms = <botocore.client.KMS object at 0xffff9ea5d3a0>
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 (cfee14f6-ca89-4caa-8b60-c1526626a300), got arn:aws:kms:us-east-1:000000000000:alias/source-bucket-cmk
E assert 'cfee14f6-ca89-4caa-8b60-c1526626a300' in 'arn:aws:kms:us-east-1:000000000000:alias/source-bucket-cmk'
E + where 'arn:aws:kms:us-east-1:000000000000:alias/source-bucket-cmk' = <built-in method get of dict object at 0xffff9e952b00>('KMSMasterKeyID', '')
E + where <built-in method get of dict object at 0xffff9e952b00> = {'KMSMasterKeyID': 'arn:aws:kms:us-east-1:000000000000: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 0xffff9e9bcc20>
kms = <botocore.client.KMS object at 0xffff9ea5d3a0>
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 (87f146b4-bc5f-420c-bdfc-d88f28da2254)
E assert ('87f146b4-bc5f-420c-bdfc-d88f28da2254' in 'alias/sqs-queue-cmk' or False)
E + where False = <built-in method endswith of str object at 0xffff9e8249b0>('87f146b4-bc5f-420c-bdfc-d88f28da2254')
E + where <built-in method endswith of str object at 0xffff9e8249b0> = 'alias/sqs-queue-cmk'.endswith
/tests/test_outputs.py:199: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 65 warnings
/root/.cache/uv/archive-v0/7YvzzIRHeSzayjRA0nQlt/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, 65 warnings in 19.89s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_1d55f0f1889741b0. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_1d55f0f1889741b0 · verifier authoritative; classifier explanatory.