tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check environment and localstack health
ls /app && aws --version && python3 --version && curl -s http://localstack:4566/_localstack/health | head -c 500
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3
{"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]
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24
Python 3.12.3
{"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": "disaCheck environment variables and tools
echo "--- env ---" env | grep -E "AWS_|LOCALSTACK" | sort echo "--- endpoint sanity ---" aws --endpoint-url=http://localstack:4566 sts get-caller-identity echo "--- working dir ---" ls -la /app echo "--- tools ---" which zip jq boto3 2>&1 || true python3 -c "import boto3; print(boto3.__version__)"
--- env ---
AWS_ACCESS_KEY_ID=test
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_PAGER=
AWS_SECRET_ACCESS_KEY=test
--- endpoint sanity ---
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
--- working dir ---
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 23 05:48 ..
--- tools ---
/usr/bin/zip
/usr/bin/jq
1.35.99
[stdout]
--- env ---
AWS_ACCESS_KEY_ID=test
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_PAGER=
AWS_SECRET_ACCESS_KEY=test
--- endpoint sanity ---
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
--- working dir ---
total 8
drwxr-xr-x 2 root root 4096 Apr 21 20:05 .
drwxr-xr-x 1 root root 4096 Apr 23 05:48 ..
--- tools ---
/usr/bin/zip
/usr/bin/jq
1.35.99Create directories
mkdir -p /app/policies /app/handler /app/build echo "000000000000" > /app/.account cat /app/.account
000000000000 [stdout] 000000000000
import json
import os
import urllib.parse
from datetime import datetime, timezone
import boto3
DEST_BUCKET = os.environ["DEST_BUCKET"]
TABLE_NAME = os.environ["TABLE_NAME"]
DEST_KMS_KEY_ID = os.environ["DEST_KMS_KEY_ID"]
_endpoint = os.environ.get("AWS_ENDPOINT_URL")
_session_kwargs = {"endpoint_url": _endpoint} if _endpoint else {}
s3 = boto3.client("s3", **_session_kwargs)
ddb = boto3.client("dynamodb", **_session_kwargs)
def _process_message(record):
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:
src_bucket = s3_rec["s3"]["bucket"]["name"]
raw_key = s3_rec["s3"]["object"]["key"]
object_key = urllib.parse.unquote_plus(raw_key)
resp = s3.get_object(Bucket=src_bucket, Key=object_key)
data = resp["Body"].read()
source_size = len(data)
s3.put_object(
Bucket=DEST_BUCKET,
Key=f"processed/{object_key}",
Body=data,
ServerSideEncryption="aws:kms",
SSEKMSKeyId=DEST_KMS_KEY_ID,
)
ddb.put_item(
TableName=TABLE_NAME,
Item={
"object_key": {"S": object_key},
"source_size": {"N": str(source_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", "unknown")
try:
_process_message(record)
except Exception as exc:
print(f"record {message_id} failed: {type(exc).__name__}: {exc}")
failures.append({"itemIdentifier": message_id})
return {"batchItemFailures": failures}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Create lambda role
set -euo pipefail cd /app # Create the IAM role first so we can reference it in KMS policies aws iam create-role \ --role-name image-ingest-lambda-role \ --assume-role-policy-document file:///app/policies/lambda-trust.json >/dev/null ROLE_ARN=$(aws iam get-role --role-name image-ingest-lambda-role --query 'Role.Arn' --output text) echo "ROLE_ARN=$ROLE_ARN" echo "$ROLE_ARN" > /app/.role_arn
ROLE_ARN=arn:aws:iam::000000000000:role/image-ingest-lambda-role [stdout] ROLE_ARN=arn:aws:iam::000000000000:role/image-ingest-lambda-role
{
"Version": "2012-10-17",
"Id": "source-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableAccountRootAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:root" },
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleDecryptForSourceReads",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role" },
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
},
{
"Sid": "AllowS3ServiceUseForBucketEncryption",
"Effect": "Allow",
"Principal": { "Service": "s3.amazonaws.com" },
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
}
]
}
{
"Version": "2012-10-17",
"Id": "sqs-queue-cmk-policy",
"Statement": [
{
"Sid": "EnableAccountRootAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:root" },
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowS3ToEncryptMessagesToQueue",
"Effect": "Allow",
"Principal": { "Service": "s3.amazonaws.com" },
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleDecryptForReceive",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role" },
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
{
"Version": "2012-10-17",
"Id": "dest-bucket-cmk-policy",
"Statement": [
{
"Sid": "EnableAccountRootAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:root" },
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "AllowLambdaRoleEncryptForDestWrites",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role" },
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*"
},
{
"Sid": "AllowS3ServiceUseForBucketEncryption",
"Effect": "Allow",
"Principal": { "Service": "s3.amazonaws.com" },
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
}
]
}
Create 3 KMS keys + aliases
set -euo pipefail cd /app SRC_KEY_ID=$(aws kms create-key \ --description "source bucket cmk for image ingest" \ --key-usage ENCRYPT_DECRYPT \ --policy file:///app/policies/source-bucket-cmk-policy.json \ --query 'KeyMetadata.KeyId' --output text) echo "SRC_KEY_ID=$SRC_KEY_ID" aws kms create-alias --alias-name alias/source-bucket-cmk --target-key-id "$SRC_KEY_ID" SQS_KEY_ID=$(aws kms create-key \ --description "sqs queue cmk for image ingest" \ --key-usage ENCRYPT_DECRYPT \ --policy file:///app/policies/sqs-queue-cmk-policy.json \ --query 'KeyMetadata.KeyId' --output text) echo "SQS_KEY_ID=$SQS_KEY_ID" aws kms create-alias --alias-name alias/sqs-queue-cmk --target-key-id "$SQS_KEY_ID" DST_KEY_ID=$(aws kms create-key \ --description "dest bucket cmk for image ingest" \ --key-usage ENCRYPT_DECRYPT \ --policy file:///app/policies/dest-bucket-cmk-policy.json \ --query 'KeyMetadata.KeyId' --output text) echo "DST_KEY_ID=$DST_KEY_ID" aws kms create-alias --alias-name alias/dest-bucket-cmk --target-key-id "$DST_KEY_ID" SRC_KEY_ARN=$(aws kms describe-key --key-id alias/source-bucket-cmk --query 'KeyMetadata.Arn' --output text) SQS_KEY_ARN=$(aws kms describe-key --key-id alias/sqs-queue-cmk --query 'KeyMetadata.Arn' --output text) DST_KEY_ARN=$(aws kms describe-key --key-id alias/dest-bucket-cmk --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" cat > /app/.vars <<EOF SRC_KEY_ID=$SRC_KEY_ID SQS_KEY_ID=$SQS_KEY_ID DST_KEY_ID=$DST_KEY_ID SRC_KEY_ARN=$SRC_KEY_ARN SQS_KEY_ARN=$SQS_KEY_ARN DST_KEY_ARN=$DST_KEY_ARN EOF cat /app/.vars
SRC_KEY_ID=b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ID=52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ID=b02b4d35-bbf3-4093-8c51-87e8e956a657 SRC_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657 SRC_KEY_ID=b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ID=52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ID=b02b4d35-bbf3-4093-8c51-87e8e956a657 SRC_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657 [stdout] SRC_KEY_ID=b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ID=52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ID=b02b4d35-bbf3-4093-8c51-87e8e956a657 SRC_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657 SRC_KEY_ID=b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ID=52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ID=b02b4d35-bbf3-4093-8c51-87e8e956a657 SRC_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334 SQS_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed DST_KEY_ARN=arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657
Create DDB table
set -euo pipefail 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 aws dynamodb describe-table --table-name image-ingest-records --query 'Table.TableStatus' --output text
ACTIVE ACTIVE [stdout] ACTIVE ACTIVE
Create S3 buckets with SSE-KMS
set -euo pipefail
source /app/.vars
aws s3api create-bucket --bucket ingest-source-bucket >/dev/null
aws s3api create-bucket --bucket ingest-dest-bucket >/dev/null
cat > /tmp/src-enc.json <<EOF
{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "$SRC_KEY_ARN"
},
"BucketKeyEnabled": true
}
]
}
EOF
cat > /tmp/dst-enc.json <<EOF
{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "$DST_KEY_ARN"
},
"BucketKeyEnabled": true
}
]
}
EOF
aws s3api put-bucket-encryption --bucket ingest-source-bucket --server-side-encryption-configuration file:///tmp/src-enc.json
aws s3api put-bucket-encryption --bucket ingest-dest-bucket --server-side-encryption-configuration file:///tmp/dst-enc.json
echo "--- source bucket enc ---"
aws s3api get-bucket-encryption --bucket ingest-source-bucket
echo "--- dest bucket enc ---"
aws s3api get-bucket-encryption --bucket ingest-dest-bucket--- source bucket enc ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334"
},
"BucketKeyEnabled": true
}
]
}
}
--- dest bucket enc ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
},
"BucketKeyEnabled": true
}
]
}
}
[stdout]
--- source bucket enc ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334"
},
"BucketKeyEnabled": true
}
]
}
}
--- dest bucket enc ---
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
},
"BucketKeyEnabled": true
}
]
}
}Create DLQ and main SQS queue
set -euo pipefail
source /app/.vars
# DLQ first
DLQ_URL=$(aws sqs create-queue \
--queue-name ingest-dlq \
--attributes "{\"KmsMasterKeyId\":\"$SQS_KEY_ARN\",\"KmsDataKeyReusePeriodSeconds\":\"300\",\"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"
# Main queue: visibility 180s (6x lambda timeout 30s), redrive to DLQ maxReceive=5
REDRIVE=$(jq -nc --arg dlq "$DLQ_ARN" '{deadLetterTargetArn: $dlq, maxReceiveCount: "5"}')
echo "REDRIVE=$REDRIVE"
MAIN_URL=$(aws sqs create-queue \
--queue-name ingest-events \
--attributes "{\"KmsMasterKeyId\":\"$SQS_KEY_ARN\",\"KmsDataKeyReusePeriodSeconds\":\"300\",\"VisibilityTimeout\":\"180\",\"MessageRetentionPeriod\":\"345600\",\"RedrivePolicy\":$(echo $REDRIVE | jq -Rs .)}" \
--query 'QueueUrl' --output text)
echo "MAIN_URL=$MAIN_URL"
MAIN_ARN=$(aws sqs get-queue-attributes --queue-url "$MAIN_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
echo "MAIN_ARN=$MAIN_ARN"
cat >> /app/.vars <<EOF
DLQ_URL=$DLQ_URL
DLQ_ARN=$DLQ_ARN
MAIN_URL=$MAIN_URL
MAIN_ARN=$MAIN_ARN
EOF
echo "--- main queue attrs ---"
aws sqs get-queue-attributes --queue-url "$MAIN_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
REDRIVE={"deadLetterTargetArn":"arn:aws:sqs:us-east-1:000000000000:ingest-dlq","maxReceiveCount":"5"}
MAIN_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events
MAIN_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
--- main queue attrs ---
{
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed",
"VisibilityTimeout": "180",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}\n"
}
[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
REDRIVE={"deadLetterTargetArn":"arn:aws:sqs:us-east-1:000000000000:ingest-dlq","maxReceiveCount":"5"}
MAIN_URL=http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/ingest-events
MAIN_ARN=arn:aws:sqs:us-east-1:000000000000:ingest-events
--- main queue attrs ---
{
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed",
"VisibilityTimeout": "180",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}\n"
}Re-set clean redrive policy
set -euo pipefail
source /app/.vars
REDRIVE='{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:000000000000:ingest-dlq","maxReceiveCount":"5"}'
ATTRS=$(jq -n --arg redrive "$REDRIVE" '{RedrivePolicy: $redrive}')
aws sqs set-queue-attributes --queue-url "$MAIN_URL" --attributes "$ATTRS"
aws sqs get-queue-attributes --queue-url "$MAIN_URL" --attribute-names RedrivePolicy VisibilityTimeout KmsMasterKeyId{
"Attributes": {
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}",
"VisibilityTimeout": "180",
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
}
}
[stdout]
{
"Attributes": {
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"5\"}",
"VisibilityTimeout": "180",
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
}
}{
"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": "ReadFromMainQueue",
"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": "WriteDestObjects",
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
},
{
"Sid": "WriteIngestRecord",
"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": "__SRC_KEY_ARN__"
},
{
"Sid": "DecryptQueueMessages",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "__SQS_KEY_ARN__"
},
{
"Sid": "EncryptDestObjects",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "__DST_KEY_ARN__"
}
]
}
Render and attach lambda inline policy
set -euo pipefail
source /app/.vars
# Substitute key ARNs (no wildcards)
sed -e "s|__SRC_KEY_ARN__|$SRC_KEY_ARN|g" \
-e "s|__SQS_KEY_ARN__|$SQS_KEY_ARN|g" \
-e "s|__DST_KEY_ARN__|$DST_KEY_ARN|g" \
/app/policies/lambda-inline.json > /app/policies/lambda-inline.rendered.json
# Sanity: no wildcards left in kms resources
jq '.Statement[] | select(.Action | tostring | contains("kms:")) | {Sid, Resource}' /app/policies/lambda-inline.rendered.json
aws iam put-role-policy \
--role-name image-ingest-lambda-role \
--policy-name image-ingest-lambda-inline \
--policy-document file:///app/policies/lambda-inline.rendered.json
aws iam get-role-policy --role-name image-ingest-lambda-role --policy-name image-ingest-lambda-inline --query 'PolicyDocument.Statement[].Sid'{
"Sid": "DecryptSourceObjects",
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334"
}
{
"Sid": "DecryptQueueMessages",
"Resource": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
}
{
"Sid": "EncryptDestObjects",
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
[
"Logs",
"ReadFromMainQueue",
"ReadSourceObjects",
"WriteDestObjects",
"WriteIngestRecord",
"DecryptSourceObjects",
"DecryptQueueMessages",
"EncryptDestObjects"
]
[stdout]
{
"Sid": "DecryptSourceObjects",
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334"
}
{
"Sid": "DecryptQueueMessages",
"Resource": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
}
{
"Sid": "EncryptDestObjects",
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
[
"Logs",
"ReadFromMainQueue",
"ReadSourceObjects",
"WriteDestObjects",
"WriteIngestRecord",
"DecryptSourceObjects",
"DecryptQueueMessages",
"EncryptDestObjects"
]{
"Version": "2012-10-17",
"Id": "ingest-events-queue-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucketSendMessage",
"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"
}
}
}
]
}
Set SQS queue policy for S3 source
set -euo pipefail
source /app/.vars
POLICY_JSON=$(cat /app/policies/main-queue-policy.json | jq -c .)
ATTRS=$(jq -n --arg p "$POLICY_JSON" '{Policy: $p}')
aws sqs set-queue-attributes --queue-url "$MAIN_URL" --attributes "$ATTRS"
aws sqs get-queue-attributes --queue-url "$MAIN_URL" --attribute-names Policy | jq '.Attributes.Policy | fromjson'{
"Version": "2012-10-17",
"Id": "ingest-events-queue-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucketSendMessage",
"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"
}
}
}
]
}
[stdout]
{
"Version": "2012-10-17",
"Id": "ingest-events-queue-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucketSendMessage",
"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"
}
}
}
]
}Configure S3 -> SQS notification
set -euo pipefail
source /app/.vars
cat > /tmp/notif.json <<EOF
{
"QueueConfigurations": [
{
"Id": "ingest-source-to-queue",
"QueueArn": "$MAIN_ARN",
"Events": ["s3:ObjectCreated:*"]
}
]
}
EOF
aws s3api put-bucket-notification-configuration \
--bucket ingest-source-bucket \
--notification-configuration file:///tmp/notif.json
aws s3api get-bucket-notification-configuration --bucket ingest-source-bucket{
"QueueConfigurations": [
{
"Id": "ingest-source-to-queue",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
[stdout]
{
"QueueConfigurations": [
{
"Id": "ingest-source-to-queue",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}Zip + deploy lambda
set -euo pipefail
source /app/.vars
cd /app/handler
zip -qr /app/build/handler.zip handler.py
ls -la /app/build/handler.zip
aws lambda create-function \
--function-name image-ingest-consumer \
--runtime python3.11 \
--handler handler.lambda_handler \
--role "$(cat /app/.role_arn)" \
--timeout 30 \
--memory-size 256 \
--zip-file fileb:///app/build/handler.zip \
--environment "Variables={DEST_BUCKET=ingest-dest-bucket,TABLE_NAME=image-ingest-records,DEST_KMS_KEY_ID=$DST_KEY_ARN}" \
--query '{FunctionName:FunctionName,State:State,LastUpdateStatus:LastUpdateStatus}' --output json
# Wait until Active
for i in 1 2 3 4 5 6 7 8 9 10; do
STATE=$(aws lambda get-function --function-name image-ingest-consumer --query 'Configuration.State' --output text)
LUS=$(aws lambda get-function --function-name image-ingest-consumer --query 'Configuration.LastUpdateStatus' --output text)
echo "state=$STATE last_update=$LUS"
[ "$STATE" = "Active" ] && [ "$LUS" = "Successful" ] && break
sleep 2
done-rw-r--r-- 1 root root 988 Apr 23 05:53 /app/build/handler.zip
{
"FunctionName": "image-ingest-consumer",
"State": "Pending",
"LastUpdateStatus": null
}
state=Pending last_update=None
state=Pending last_update=Successful
state=Active last_update=Successful
[stdout]
-rw-r--r-- 1 root root 988 Apr 23 05:53 /app/build/handler.zip
{
"FunctionName": "image-ingest-consumer",
"State": "Pending",
"LastUpdateStatus": null
}
state=Pending last_update=None
state=Pending last_update=Successful
state=Active last_update=SuccessfulCreate SQS event source mapping
set -euo pipefail
source /app/.vars
aws lambda create-event-source-mapping \
--function-name image-ingest-consumer \
--event-source-arn "$MAIN_ARN" \
--batch-size 10 \
--maximum-batching-window-in-seconds 2 \
--function-response-types ReportBatchItemFailures \
--query '{UUID:UUID,State:State,FunctionResponseTypes:FunctionResponseTypes}' --output json
sleep 2
aws lambda list-event-source-mappings --function-name image-ingest-consumer \
--query 'EventSourceMappings[].{UUID:UUID,State:State,EventSourceArn:EventSourceArn,FunctionResponseTypes:FunctionResponseTypes,BatchSize:BatchSize}'{
"UUID": "ff8d15b9-8867-4cf1-a382-90cc3f101940",
"State": "Creating",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
]
}
[
{
"UUID": "ff8d15b9-8867-4cf1-a382-90cc3f101940",
"State": "Enabled",
"EventSourceArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
]
[stdout]
{
"UUID": "ff8d15b9-8867-4cf1-a382-90cc3f101940",
"State": "Creating",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
]
}
[
{
"UUID": "ff8d15b9-8867-4cf1-a382-90cc3f101940",
"State": "Enabled",
"EventSourceArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
]End-to-end test with spaced key
set -euo pipefail
source /app/.vars
TEST_KEY="hello world/my photo 01.png"
echo "content-for-ingest-test" > /tmp/test.bin
# PUT with a spaced key
aws s3api put-object \
--bucket ingest-source-bucket \
--key "$TEST_KEY" \
--body /tmp/test.bin
echo "--- waiting for processing ---"
DEST_KEY="processed/$TEST_KEY"
DEADLINE=$((SECONDS+60))
OBJ_OK=0
DDB_OK=0
while [ $SECONDS -lt $DEADLINE ]; do
if [ $OBJ_OK -eq 0 ]; then
aws s3api head-object --bucket ingest-dest-bucket --key "$DEST_KEY" >/tmp/head.json 2>/dev/null && OBJ_OK=1 || true
fi
if [ $DDB_OK -eq 0 ]; then
aws dynamodb get-item --table-name image-ingest-records \
--key "{\"object_key\":{\"S\":\"$TEST_KEY\"}}" > /tmp/item.json 2>/dev/null
if jq -e '.Item.object_key.S' /tmp/item.json >/dev/null 2>&1; then DDB_OK=1; fi
fi
[ $OBJ_OK -eq 1 ] && [ $DDB_OK -eq 1 ] && break
sleep 2
done
echo "OBJ_OK=$OBJ_OK DDB_OK=$DDB_OK elapsed=${SECONDS}s"
echo "--- head-object ---"
cat /tmp/head.json | jq '{ServerSideEncryption, SSEKMSKeyId, ContentLength}'
echo "--- ddb item ---"
cat /tmp/item.json | jq .Item{
"ETag": "\"22f8e88d782793e2fd7da9755d426f39\"",
"ChecksumCRC64NVME": "LGiKpQyTkJA=",
"ChecksumType": "FULL_OBJECT",
"ServerSideEncryption": "aws:kms",
"SSEKMSKeyId": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334",
"BucketKeyEnabled": true
}
--- waiting for processing ---
OBJ_OK=1 DDB_OK=1 elapsed=4s
--- head-object ---
{
"ServerSideEncryption": "aws:kms",
"SSEKMSKeyId": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657",
"ContentLength": 24
}
--- ddb item ---
{
"source_size": {
"N": "24"
},
"processed_at": {
"S": "2026-04-23T05:54:16.636794+00:00"
},
"object_key": {
"S": "hello world/my photo 01.png"
},
"status": {
"S": "OK"
}
}
[stdout]
{
"ETag": "\"22f8e88d782793e2fd7da9755d426f39\"",
"ChecksumCRC64NVME": "LGiKpQyTkJA=",
"ChecksumType": "FULL_OBJECT",
"ServerSideEncryption": "aws:kms",
"SSEKMSKeyId": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334",
"BucketKeyEnabled": true
}
--- waiting for processing ---
OBJ_OK=1 DDB_OK=1 elapsed=4s
--- head-object ---
{
"ServerSideEncryption": "aws:kms",
"SSEKMSKeyId": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657",
"ContentLength": 24
}
--- ddb item ---
{
"source_size": {
"N": "24"
},
"processed_at": {
"S": "2026-04-23T05:54:16.636794+00:00"
},
"object_key": {
"S": "hello world/my photo 01.png"
},
"status": {
"S": "OK"
}
}Verify DLQ empty after success
set -euo pipefail source /app/.vars echo "--- DLQ depth ---" aws sqs get-queue-attributes --queue-url "$DLQ_URL" \ --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \ --query Attributes echo "--- main queue depth ---" aws sqs get-queue-attributes --queue-url "$MAIN_URL" \ --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \ --query Attributes
--- DLQ depth ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- main queue depth ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "1"
}
[stdout]
--- DLQ depth ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- main queue depth ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "1"
}Recheck queue depths
set -euo pipefail source /app/.vars sleep 5 aws sqs get-queue-attributes --queue-url "$MAIN_URL" \ --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \ --query Attributes aws sqs get-queue-attributes --queue-url "$DLQ_URL" \ --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \ --query Attributes
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "1"
}
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
[stdout]
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "1"
}
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}Check lambda logs
set -euo pipefail LG=$(aws logs describe-log-groups --log-group-name-prefix /aws/lambda/image-ingest-consumer --query 'logGroups[0].logGroupName' --output text) echo "LG=$LG" STREAMS=$(aws logs describe-log-streams --log-group-name "$LG" --order-by LastEventTime --descending --max-items 1 --query 'logStreams[0].logStreamName' --output text) echo "STREAM=$STREAMS" aws logs get-log-events --log-group-name "$LG" --log-stream-name "$STREAMS" --limit 40 --query 'events[].message' --output text
Exit code 254 LG=/aws/lambda/image-ingest-consumer STREAM=2026/04/23/[$LATEST]050eb3a70654e95e0db2539d5d6534d4 None aws: [ERROR]: An error occurred (ResourceNotFoundException) when calling the GetLogEvents operation: The specified log stream does not exist. [error] tool reported failure
List all log streams
set -euo pipefail LG=/aws/lambda/image-ingest-consumer aws logs describe-log-streams --log-group-name "$LG" --order-by LastEventTime --descending --max-items 5 --query 'logStreams[].logStreamName' --output json
[
"2026/04/23/[$LATEST]050eb3a70654e95e0db2539d5d6534d4",
"2026/04/23/[$LATEST]c7fb7e953a68f287b24ba6655a3714c1"
]
[stdout]
[
"2026/04/23/[$LATEST]050eb3a70654e95e0db2539d5d6534d4",
"2026/04/23/[$LATEST]c7fb7e953a68f287b24ba6655a3714c1"
]Read earlier log stream
set -euo pipefail LG=/aws/lambda/image-ingest-consumer # Proper escaping for the $ in stream names STREAM='2026/04/23/[$LATEST]c7fb7e953a68f287b24ba6655a3714c1' aws logs get-log-events --log-group-name "$LG" --log-stream-name "$STREAM" --limit 40 --query 'events[].message' --output text
START RequestId: ffedee3f-aa3b-4bfc-a182-02e7be745805 Version: $LATEST record 9873afed-6edb-41ea-b8f5-f0d3f50f7f0d failed: ValueError: no s3 records in message body END RequestId: ffedee3f-aa3b-4bfc-a182-02e7be745805 REPORT RequestId: ffedee3f-aa3b-4bfc-a182-02e7be745805 Duration: 8.15 ms Billed Duration: 9 ms Memory Size: 256 MB Max Memory Used: 256 MB [stdout] START RequestId: ffedee3f-aa3b-4bfc-a182-02e7be745805 Version: $LATEST record 9873afed-6edb-41ea-b8f5-f0d3f50f7f0d failed: ValueError: no s3 records in message body END RequestId: ffedee3f-aa3b-4bfc-a182-02e7be745805 REPORT RequestId: ffedee3f-aa3b-4bfc-a182-02e7be745805 Duration: 8.15 ms Billed Duration: 9 ms Memory Size: 256 MB Max Memory Used: 256 MB
/app/handler/handler.py
def _process_message(record):
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:def _process_message(record):
body = json.loads(record["body"])
# S3 sends an s3:TestEvent when a bucket notification is first configured.
# It has no Records , ack it silently rather than routing it to the DLQ.
if body.get("Event") == "s3:TestEvent":
return
s3_records = body.get("Records") or []
if not s3_records:
return
for s3_rec in s3_records:Redeploy lambda and purge queues
set -euo pipefail
source /app/.vars
cd /app/handler
rm -f /app/build/handler.zip
zip -qr /app/build/handler.zip handler.py
aws lambda update-function-code \
--function-name image-ingest-consumer \
--zip-file fileb:///app/build/handler.zip \
--query '{State:State,LastUpdateStatus:LastUpdateStatus}' --output json
for i in 1 2 3 4 5 6 7 8 9 10; do
LUS=$(aws lambda get-function --function-name image-ingest-consumer --query 'Configuration.LastUpdateStatus' --output text)
[ "$LUS" = "Successful" ] && break
sleep 1
done
echo "update status=$LUS"
# Purge both queues to clear stuck TestEvent and any leftover messages
aws sqs purge-queue --queue-url "$MAIN_URL" || true
aws sqs purge-queue --queue-url "$DLQ_URL" || true
sleep 3
echo "--- main queue after purge ---"
aws sqs get-queue-attributes --queue-url "$MAIN_URL" --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible --query Attributes
echo "--- DLQ after purge ---"
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible --query Attributes{
"State": "Active",
"LastUpdateStatus": "InProgress"
}
update status=Successful
--- main queue after purge ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- DLQ after purge ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
[stdout]
{
"State": "Active",
"LastUpdateStatus": "InProgress"
}
update status=Successful
--- main queue after purge ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
--- DLQ after purge ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}Fresh end-to-end with new spaced key
set -euo pipefail
source /app/.vars
TEST_KEY="spaced dir/another file with spaces.png"
printf 'hello from fresh test' > /tmp/test2.bin
aws s3api put-object \
--bucket ingest-source-bucket \
--key "$TEST_KEY" \
--body /tmp/test2.bin >/dev/null
DEST_KEY="processed/$TEST_KEY"
DEADLINE=$((SECONDS+60))
OBJ_OK=0
DDB_OK=0
while [ $SECONDS -lt $DEADLINE ]; do
if [ $OBJ_OK -eq 0 ]; then
aws s3api head-object --bucket ingest-dest-bucket --key "$DEST_KEY" >/tmp/head.json 2>/dev/null && OBJ_OK=1 || true
fi
if [ $DDB_OK -eq 0 ]; then
aws dynamodb get-item --table-name image-ingest-records \
--key "{\"object_key\":{\"S\":\"$TEST_KEY\"}}" > /tmp/item.json 2>/dev/null
jq -e '.Item.object_key.S' /tmp/item.json >/dev/null 2>&1 && DDB_OK=1 || true
fi
[ $OBJ_OK -eq 1 ] && [ $DDB_OK -eq 1 ] && break
sleep 2
done
echo "OBJ_OK=$OBJ_OK DDB_OK=$DDB_OK elapsed=${SECONDS}s"
echo "--- list dest bucket ---"
aws s3api list-objects-v2 --bucket ingest-dest-bucket --query 'Contents[].{Key:Key,Size:Size}' --output json
echo "--- ddb scan count ---"
aws dynamodb scan --table-name image-ingest-records --select COUNT --query Count
sleep 5
echo "--- queue depths ---"
aws sqs get-queue-attributes --queue-url "$MAIN_URL" --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible --query Attributes
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible --query AttributesOBJ_OK=1 DDB_OK=1 elapsed=4s
--- list dest bucket ---
[
{
"Key": "processed/hello world/my photo 01.png",
"Size": 24
},
{
"Key": "processed/spaced dir/another file with spaces.png",
"Size": 21
}
]
--- ddb scan count ---
2
--- queue depths ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
[stdout]
OBJ_OK=1 DDB_OK=1 elapsed=4s
--- list dest bucket ---
[
{
"Key": "processed/hello world/my photo 01.png",
"Size": 24
},
{
"Key": "processed/spaced dir/another file with spaces.png",
"Size": 21
}
]
--- ddb scan count ---
2
--- queue depths ---
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}
{
"ApproximateNumberOfMessages": "0",
"ApproximateNumberOfMessagesNotVisible": "0"
}import json, sys
good_body = json.dumps({
"Records": [{
"s3": {
"bucket": {"name": "ingest-source-bucket"},
"object": {"key": "spaced+dir/another+file+with+spaces.png"}
}
}]
})
bad_body = json.dumps({
"Records": [{
"s3": {
"bucket": {"name": "ingest-source-bucket"},
"object": {"key": "does-not-exist/nope.png"}
}
}]
})
event = {
"Records": [
{
"messageId": "good-msg-aaaa",
"receiptHandle": "rh-good",
"body": good_body,
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"awsRegion": "us-east-1",
},
{
"messageId": "bad-msg-bbbb",
"receiptHandle": "rh-bad",
"body": bad_body,
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"awsRegion": "us-east-1",
},
]
}
with open("/tmp/batch_event.json", "w") as f:
json.dump(event, f)
print("wrote /tmp/batch_event.json")
Invoke lambda with partial-batch event
set -euo pipefail
python3 /tmp/batch_event.py
aws lambda invoke \
--function-name image-ingest-consumer \
--payload fileb:///tmp/batch_event.json \
--cli-binary-format raw-in-base64-out \
/tmp/invoke_resp.json \
--query '{StatusCode:StatusCode,FunctionError:FunctionError}' --output json
echo "--- response body ---"
cat /tmp/invoke_resp.json
echo
echo
echo "--- parsed ---"
jq . /tmp/invoke_resp.jsonwrote /tmp/batch_event.json
{
"StatusCode": 200,
"FunctionError": null
}
--- response body ---
{"batchItemFailures": [{"itemIdentifier": "bad-msg-bbbb"}]}
--- parsed ---
{
"batchItemFailures": [
{
"itemIdentifier": "bad-msg-bbbb"
}
]
}
[stdout]
wrote /tmp/batch_event.json
{
"StatusCode": 200,
"FunctionError": null
}
--- response body ---
{"batchItemFailures": [{"itemIdentifier": "bad-msg-bbbb"}]}
--- parsed ---
{
"batchItemFailures": [
{
"itemIdentifier": "bad-msg-bbbb"
}
]
}Full resource verification
set -euo pipefail
source /app/.vars
echo "============ RESOURCE SUMMARY ============"
echo
echo "--- KMS aliases ---"
aws 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 "--- KMS key policy snippets (Principals only) ---"
for alias in alias/source-bucket-cmk alias/sqs-queue-cmk alias/dest-bucket-cmk; do
echo ">>> $alias"
aws kms get-key-policy --key-id "$alias" --policy-name default --query Policy --output text | jq '.Statement[] | {Sid, Principal, Action}'
done
echo
echo "--- S3 buckets ---"
aws s3api list-buckets --query 'Buckets[].Name' --output json
echo ">>> source enc:"
aws s3api get-bucket-encryption --bucket ingest-source-bucket --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault'
echo ">>> dest enc:"
aws s3api get-bucket-encryption --bucket ingest-dest-bucket --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault'
echo ">>> source notification:"
aws s3api get-bucket-notification-configuration --bucket ingest-source-bucket
echo
echo "--- SQS queues ---"
echo ">>> main:"
aws sqs get-queue-attributes --queue-url "$MAIN_URL" --attribute-names QueueArn KmsMasterKeyId VisibilityTimeout RedrivePolicy Policy | jq '.Attributes | {QueueArn, KmsMasterKeyId, VisibilityTimeout, RedrivePolicy: (.RedrivePolicy|fromjson), Policy: (.Policy|fromjson)}'
echo ">>> dlq:"
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names QueueArn KmsMasterKeyId | jq '.Attributes'
echo
echo "--- DDB ---"
aws dynamodb describe-table --table-name image-ingest-records --query 'Table.{TableName:TableName,KeySchema:KeySchema,AttrDefs:AttributeDefinitions,Status:TableStatus}'
echo
echo "--- Lambda ---"
aws lambda get-function-configuration --function-name image-ingest-consumer --query '{FunctionName:FunctionName,Runtime:Runtime,Handler:Handler,Timeout:Timeout,Role:Role,Env:Environment.Variables}'
echo
echo "--- ESM ---"
aws lambda list-event-source-mappings --function-name image-ingest-consumer --query 'EventSourceMappings[].{State:State,EventSourceArn:EventSourceArn,FunctionResponseTypes:FunctionResponseTypes,BatchSize:BatchSize}'
echo
echo "--- IAM role policy ---"
aws iam get-role-policy --role-name image-ingest-lambda-role --policy-name image-ingest-lambda-inline --query 'PolicyDocument.Statement[].{Sid:Sid,Action:Action,Resource:Resource}'============ RESOURCE SUMMARY ============
--- KMS aliases ---
---------------------------------------------------------------------
| ListAliases |
+--------------------------+----------------------------------------+
| alias/source-bucket-cmk | b4edf839-7089-45b6-a18a-ec17a5cdb334 |
| alias/sqs-queue-cmk | 52086cfa-71dd-43e1-8cdc-0884d9628fed |
| alias/dest-bucket-cmk | b02b4d35-bbf3-4093-8c51-87e8e956a657 |
+--------------------------+----------------------------------------+
--- KMS key policy snippets (Principals only) ---
>>> alias/source-bucket-cmk
{
"Sid": "EnableAccountRootAdmin",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*"
}
{
"Sid": "AllowLambdaRoleDecryptForSourceReads",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
]
}
{
"Sid": "AllowS3ServiceUseForBucketEncryption",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
]
}
>>> alias/sqs-queue-cmk
{
"Sid": "EnableAccountRootAdmin",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*"
}
{
"Sid": "AllowS3ToEncryptMessagesToQueue",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
]
}
{
"Sid": "AllowLambdaRoleDecryptForReceive",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
]
}
>>> alias/dest-bucket-cmk
{
"Sid": "EnableAccountRootAdmin",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*"
}
{
"Sid": "AllowLambdaRoleEncryptForDestWrites",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
]
}
{
"Sid": "AllowS3ServiceUseForBucketEncryption",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
]
}
--- S3 buckets ---
[
"ingest-dest-bucket",
"ingest-source-bucket"
]
>>> source enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334"
}
>>> dest enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
>>> source notification:
{
"QueueConfigurations": [
{
"Id": "ingest-source-to-queue",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
--- SQS queues ---
>>> main:
{
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed",
"VisibilityTimeout": "180",
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:us-east-1:000000000000:ingest-dlq",
"maxReceiveCount": "5"
},
"Policy": {
"Version": "2012-10-17",
"Id": "ingest-events-queue-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucketSendMessage",
"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"
}
}
}
]
}
}
>>> dlq:
{
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-dlq",
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
}
--- DDB ---
{
"TableName": "image-ingest-records",
"KeySchema": [
{
"AttributeName": "object_key",
"KeyType": "HASH"
}
],
"AttrDefs": [
{
"AttributeName": "object_key",
"AttributeType": "S"
}
],
"Status": "ACTIVE"
}
--- Lambda ---
{
"FunctionName": "image-ingest-consumer",
"Runtime": "python3.11",
"Handler": "handler.lambda_handler",
"Timeout": 30,
"Role": "arn:aws:iam::000000000000:role/image-ingest-lambda-role",
"Env": {
"DEST_BUCKET": "ingest-dest-bucket",
"TABLE_NAME": "image-ingest-records",
"DEST_KMS_KEY_ID": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
}
--- ESM ---
[
{
"State": "Enabled",
"EventSourceArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
]
--- IAM role policy ---
[
{
"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": "ReadFromMainQueue",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
},
{
"Sid": "ReadSourceObjects",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
},
{
"Sid": "WriteDestObjects",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
},
{
"Sid": "WriteIngestRecord",
"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/b4edf839-7089-45b6-a18a-ec17a5cdb334"
},
{
"Sid": "DecryptQueueMessages",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
},
{
"Sid": "EncryptDestObjects",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
]
[stdout]
============ RESOURCE SUMMARY ============
--- KMS aliases ---
---------------------------------------------------------------------
| ListAliases |
+--------------------------+----------------------------------------+
| alias/source-bucket-cmk | b4edf839-7089-45b6-a18a-ec17a5cdb334 |
| alias/sqs-queue-cmk | 52086cfa-71dd-43e1-8cdc-0884d9628fed |
| alias/dest-bucket-cmk | b02b4d35-bbf3-4093-8c51-87e8e956a657 |
+--------------------------+----------------------------------------+
--- KMS key policy snippets (Principals only) ---
>>> alias/source-bucket-cmk
{
"Sid": "EnableAccountRootAdmin",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*"
}
{
"Sid": "AllowLambdaRoleDecryptForSourceReads",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
]
}
{
"Sid": "AllowS3ServiceUseForBucketEncryption",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
]
}
>>> alias/sqs-queue-cmk
{
"Sid": "EnableAccountRootAdmin",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*"
}
{
"Sid": "AllowS3ToEncryptMessagesToQueue",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
]
}
{
"Sid": "AllowLambdaRoleDecryptForReceive",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
]
}
>>> alias/dest-bucket-cmk
{
"Sid": "EnableAccountRootAdmin",
"Principal": {
"AWS": "arn:aws:iam::000000000000:root"
},
"Action": "kms:*"
}
{
"Sid": "AllowLambdaRoleEncryptForDestWrites",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/image-ingest-lambda-role"
},
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
]
}
{
"Sid": "AllowS3ServiceUseForBucketEncryption",
"Principal": {
"Service": "s3.amazonaws.com"
},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
]
}
--- S3 buckets ---
[
"ingest-dest-bucket",
"ingest-source-bucket"
]
>>> source enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b4edf839-7089-45b6-a18a-ec17a5cdb334"
}
>>> dest enc:
{
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
>>> source notification:
{
"QueueConfigurations": [
{
"Id": "ingest-source-to-queue",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
--- SQS queues ---
>>> main:
{
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed",
"VisibilityTimeout": "180",
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:us-east-1:000000000000:ingest-dlq",
"maxReceiveCount": "5"
},
"Policy": {
"Version": "2012-10-17",
"Id": "ingest-events-queue-policy",
"Statement": [
{
"Sid": "AllowS3SourceBucketSendMessage",
"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"
}
}
}
]
}
}
>>> dlq:
{
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-dlq",
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
}
--- DDB ---
{
"TableName": "image-ingest-records",
"KeySchema": [
{
"AttributeName": "object_key",
"KeyType": "HASH"
}
],
"AttrDefs": [
{
"AttributeName": "object_key",
"AttributeType": "S"
}
],
"Status": "ACTIVE"
}
--- Lambda ---
{
"FunctionName": "image-ingest-consumer",
"Runtime": "python3.11",
"Handler": "handler.lambda_handler",
"Timeout": 30,
"Role": "arn:aws:iam::000000000000:role/image-ingest-lambda-role",
"Env": {
"DEST_BUCKET": "ingest-dest-bucket",
"TABLE_NAME": "image-ingest-records",
"DEST_KMS_KEY_ID": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
}
--- ESM ---
[
{
"State": "Enabled",
"EventSourceArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"FunctionResponseTypes": [
"ReportBatchItemFailures"
],
"BatchSize": 10
}
]
--- IAM role policy ---
[
{
"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": "ReadFromMainQueue",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:000000000000:ingest-events"
},
{
"Sid": "ReadSourceObjects",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::ingest-source-bucket/*"
},
{
"Sid": "WriteDestObjects",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::ingest-dest-bucket/processed/*"
},
{
"Sid": "WriteIngestRecord",
"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/b4edf839-7089-45b6-a18a-ec17a5cdb334"
},
{
"Sid": "DecryptQueueMessages",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/52086cfa-71dd-43e1-8cdc-0884d9628fed"
},
{
"Sid": "EncryptDestObjects",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/b02b4d35-bbf3-4093-8c51-87e8e956a657"
}
]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/kzJ3NXRw8oUp52ep44Pgd/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 PASSED [ 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 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_lambda_handler_returns_correct_response_shape ______________
lmb = <botocore.client.Lambda object at 0xffffa8786c90>
sqs = <botocore.client.SQS object at 0xffffa8a2c500>
s3 = <botocore.client.S3 object at 0xffffa8d834a0>
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-ba98c786 in batchItemFailures; got ids=[]. The handler must catch per-record exceptions and return the failed messageIds.
E assert 'msg-bad-ba98c786' in []
/tests/test_outputs.py:522: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 62 warnings
/root/.cache/uv/archive-v0/kzJ3NXRw8oUp52ep44Pgd/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_main_queue_and_dlq_exist_and_are_encrypted
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_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_lambda_handler_returns_correct_response_shape
================== 1 failed, 17 passed, 62 warnings in 12.04s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_02ced5438e3b445b. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_02ced5438e3b445b · verifier authoritative; classifier explanatory.