tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check working directory and localstack health
ls /app && echo "---" && curl -s http://localstack:4566/_localstack/health | head -100
---
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "disabled", "dynamodbstreams": "disabled", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "disabled", "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": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "disabled", "stepfunctions": "available", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}
[stdout]
---
{"services": {"acm": "disabled", "apigateway": "disabled", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "disabled", "dynamodbstreams": "disabled", "ec2": "disabled", "es": "disabled", "events": "available", "firehose": "disabled", "iam": "available", "kinesis": "disabled", "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": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "disabled", "stepfunctions": "available", "sts": "available", "support": "disabled", "swf": "disabled", "transcribe": "disabled"}, "edition": "community", "version": "4.13.1"}Check tool versions
aws --version && python3 --version && which jq zip curl
aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3 /usr/bin/jq /usr/bin/zip /usr/bin/curl [stdout] aws-cli/2.34.33 Python/3.14.4 Linux/6.10.14-linuxkit exe/aarch64.ubuntu.24 Python 3.12.3 /usr/bin/jq /usr/bin/zip /usr/bin/curl
Verify AWS CLI works against localstack
export AWS_ENDPOINT_URL=http://localstack:4566 && aws sts get-caller-identity
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}Create build workspace
mkdir -p /app/build && cd /app/build && ls
(Bash completed with no output)
import os
import json
import secrets
import string
import boto3
ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localstack:4566")
sm = boto3.client("secretsmanager", endpoint_url=ENDPOINT)
def _new_password(length=32):
alphabet = string.ascii_letters + string.digits + "-_"
return "".join(secrets.choice(alphabet) for _ in range(length))
def handler(event, context):
secret_id = event["SecretId"]
new_value = _new_password()
put = sm.put_secret_value(
SecretId=secret_id,
SecretString=json.dumps({"password": new_value}),
)
return {
"SecretId": secret_id,
"VersionId": put["VersionId"],
}
import json
def handler(event, context):
detail = event.get("detail", {})
secret_id = detail.get("SecretId", "?")
version_id = detail.get("VersionId", "?")
print(f"notified: rotation complete secret={secret_id} version={version_id}")
return {"ok": True}
Package lambda zips
zip -q rotator.zip rotator.py && zip -q notifier.zip notifier.py && ls -la *.zip
-rw-r--r-- 1 root root 336 Apr 22 06:56 notifier.zip -rw-r--r-- 1 root root 534 Apr 22 06:56 rotator.zip [stdout] -rw-r--r-- 1 root root 336 Apr 22 06:56 notifier.zip -rw-r--r-- 1 root root 534 Apr 22 06:56 rotator.zip
Confirm build directory
pwd
/app/build [stdout] /app/build
export AWS_ENDPOINT_URL=http://localstack:4566 export AWS_DEFAULT_REGION=us-east-1 export AWS_REGION=us-east-1 export ACCOUNT_ID=000000000000
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
{
"Version": "2012-10-17",
"Id": "app-rotation-key-policy",
"Statement": [
{
"Sid": "RootAccountAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "SecretsManagerUse",
"Effect": "Allow",
"Principal": {"Service": "secretsmanager.amazonaws.com"},
"Action": [
"kms:Decrypt",
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*"
},
{
"Sid": "RotatorLambdaUse",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/secrets-rotator-role"},
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "*"
}
]
}
Create KMS key + alias
source env.sh && \ KEY_ID=$(aws kms create-key --description "app rotation cmk" --key-usage ENCRYPT_DECRYPT --policy file://kms-policy.json --query 'KeyMetadata.KeyId' --output text) && \ echo "KEY_ID=$KEY_ID" && \ aws kms create-alias --alias-name alias/app-rotation-key --target-key-id $KEY_ID && \ echo "$KEY_ID" > .key_id
KEY_ID=2efc9431-2ba0-47b1-aefc-c4e676454263 [stdout] KEY_ID=2efc9431-2ba0-47b1-aefc-c4e676454263
Create secret encrypted with CMK
source env.sh && \
aws secretsmanager create-secret \
--name app/db/password \
--kms-key-id alias/app-rotation-key \
--secret-string '{"password":"initial-placeholder"}' \
--query 'ARN' --output text > .secret_arn && \
cat .secret_arnarn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-dvLUCR [stdout] arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-dvLUCR
Create custom EventBridge bus
source env.sh && \ aws events create-event-bus --name rotation-events --query 'EventBusArn' --output text > .bus_arn && \ cat .bus_arn
arn:aws:events:us-east-1:000000000000:event-bus/rotation-events [stdout] arn:aws:events:us-east-1:000000000000:event-bus/rotation-events
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
Read saved ARNs
source env.sh && cat .secret_arn && echo && cat .key_id
arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-dvLUCR 2efc9431-2ba0-47b1-aefc-c4e676454263 [stdout] arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-dvLUCR 2efc9431-2ba0-47b1-aefc-c4e676454263
{
"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/secrets-rotator:*"
},
{
"Sid": "RotateSecret",
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-*"
},
{
"Sid": "KmsForSecret",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/KEY_ID_PLACEHOLDER"
}
]
}
Create rotator role + inline policy
source env.sh && \ KEY_ID=$(cat .key_id) && \ sed -i "s|KEY_ID_PLACEHOLDER|$KEY_ID|" rotator-policy.json && \ aws iam create-role --role-name secrets-rotator-role --assume-role-policy-document file://lambda-trust.json --query 'Role.Arn' --output text > .rotator_role_arn && \ aws iam put-role-policy --role-name secrets-rotator-role --policy-name rotator-inline --policy-document file://rotator-policy.json && \ cat .rotator_role_arn
arn:aws:iam::000000000000:role/secrets-rotator-role [stdout] arn:aws:iam::000000000000:role/secrets-rotator-role
Create rotator lambda
source env.sh && \
aws lambda create-function \
--function-name secrets-rotator \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/secrets-rotator-role \
--handler rotator.handler \
--timeout 30 \
--environment 'Variables={AWS_ENDPOINT_URL=http://localstack:4566}' \
--zip-file fileb://rotator.zip \
--query 'FunctionArn' --output text > .rotator_arn && \
cat .rotator_arnarn:aws:lambda:us-east-1:000000000000:function:secrets-rotator [stdout] arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator
Wait for rotator to be Active
source env.sh && \
for i in 1 2 3 4 5; do \
STATE=$(aws lambda get-function --function-name secrets-rotator --query 'Configuration.State' --output text); \
echo "state=$STATE"; \
[ "$STATE" = "Active" ] && break; \
sleep 1; \
donestate=Active [stdout] state=Active
{
"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/notification-lambda:*"
}
]
}
Create notification lambda + role
source env.sh && \
aws iam create-role --role-name notification-lambda-role --assume-role-policy-document file://lambda-trust.json --query 'Role.Arn' --output text && \
aws iam put-role-policy --role-name notification-lambda-role --policy-name notifier-inline --policy-document file://notifier-policy.json && \
aws lambda create-function \
--function-name notification-lambda \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/notification-lambda-role \
--handler notifier.handler \
--timeout 15 \
--zip-file fileb://notifier.zip \
--query 'FunctionArn' --output text > .notifier_arn && \
cat .notifier_arnarn:aws:iam::000000000000:role/notification-lambda-role arn:aws:lambda:us-east-1:000000000000:function:notification-lambda [stdout] arn:aws:iam::000000000000:role/notification-lambda-role arn:aws:lambda:us-east-1:000000000000:function:notification-lambda
Wait for notifier active
source env.sh && \
for i in 1 2 3 4 5; do \
STATE=$(aws lambda get-function --function-name notification-lambda --query 'Configuration.State' --output text); \
[ "$STATE" = "Active" ] && { echo "ready"; break; }; \
sleep 1; \
doneready [stdout] ready
{"source":["rotation.pipeline"],"detail-type":["RotationComplete"]}
Create EB rule + lambda permission + target
source env.sh && \
aws events put-rule \
--name on-rotation-success \
--event-bus-name rotation-events \
--event-pattern file://rule-pattern.json \
--state ENABLED \
--query 'RuleArn' --output text > .rule_arn && \
cat .rule_arn && \
aws lambda add-permission \
--function-name notification-lambda \
--statement-id allow-rule-on-rotation-success \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn "$(cat .rule_arn)" \
--query 'Statement' --output text > /dev/null && \
aws events put-targets \
--rule on-rotation-success \
--event-bus-name rotation-events \
--targets "Id=1,Arn=$(cat .notifier_arn)"arn:aws:events:us-east-1:000000000000:rule/rotation-events/on-rotation-success
{
"FailedEntryCount": 0,
"FailedEntries": []
}
[stdout]
arn:aws:events:us-east-1:000000000000:rule/rotation-events/on-rotation-success
{
"FailedEntryCount": 0,
"FailedEntries": []
}{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "states.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Comment": "Rotate secret then emit RotationComplete event",
"StartAt": "Rotate",
"States": {
"Rotate": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator",
"Payload": {
"SecretId": "app/db/password"
}
},
"ResultPath": "$.rotation",
"Next": "Notify"
},
"Notify": {
"Type": "Task",
"Resource": "arn:aws:states:::events:putEvents",
"Parameters": {
"Entries": [
{
"EventBusName": "rotation-events",
"Source": "rotation.pipeline",
"DetailType": "RotationComplete",
"Detail": {
"SecretId.$": "$.rotation.Payload.SecretId",
"VersionId.$": "$.rotation.Payload.VersionId"
}
}
]
},
"End": true
}
}
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvokeRotator",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator"
},
{
"Sid": "PutEventsOnBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/rotation-events"
}
]
}
Create child SM role
source env.sh && \ aws iam create-role --role-name rotation-child-role --assume-role-policy-document file://sfn-trust.json --query 'Role.Arn' --output text > .child_role_arn && \ aws iam put-role-policy --role-name rotation-child-role --policy-name child-inline --policy-document file://child-sm-policy.json && \ cat .child_role_arn
arn:aws:iam::000000000000:role/rotation-child-role [stdout] arn:aws:iam::000000000000:role/rotation-child-role
Create child state machine
source env.sh && \
aws stepfunctions create-state-machine \
--name rotation-child \
--definition file://child-sm.json \
--role-arn arn:aws:iam::000000000000:role/rotation-child-role \
--type STANDARD \
--query 'stateMachineArn' --output text > .child_sm_arn && \
cat .child_sm_arnarn:aws:states:us-east-1:000000000000:stateMachine:rotation-child [stdout] arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child
{
"Comment": "Hands off to the child rotation SM and waits for completion",
"StartAt": "InvokeChild",
"States": {
"InvokeChild": {
"Type": "Task",
"Resource": "arn:aws:states:::states:startExecution.sync:2",
"Parameters": {
"StateMachineArn": "arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child",
"Input": {}
},
"End": true
}
}
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StartAndControlChild",
"Effect": "Allow",
"Action": [
"states:StartExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child"
},
{
"Sid": "DescribeStopChildExecutions",
"Effect": "Allow",
"Action": [
"states:DescribeExecution",
"states:StopExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:execution:rotation-child:*"
},
{
"Sid": "ManagedRuleForSyncInvoke",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:PutTargets",
"events:DescribeRule"
],
"Resource": "arn:aws:events:us-east-1:000000000000:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule"
}
]
}
Create parent SM role
source env.sh && \ aws iam create-role --role-name rotation-orchestrator-role --assume-role-policy-document file://sfn-trust.json --query 'Role.Arn' --output text > .parent_role_arn && \ aws iam put-role-policy --role-name rotation-orchestrator-role --policy-name parent-inline --policy-document file://parent-sm-policy.json && \ cat .parent_role_arn
arn:aws:iam::000000000000:role/rotation-orchestrator-role [stdout] arn:aws:iam::000000000000:role/rotation-orchestrator-role
Create parent state machine
source env.sh && \
aws stepfunctions create-state-machine \
--name rotation-orchestrator \
--definition file://parent-sm.json \
--role-arn arn:aws:iam::000000000000:role/rotation-orchestrator-role \
--type STANDARD \
--query 'stateMachineArn' --output text > .parent_sm_arn && \
cat .parent_sm_arnarn:aws:states:us-east-1:000000000000:stateMachine:rotation-orchestrator [stdout] arn:aws:states:us-east-1:000000000000:stateMachine:rotation-orchestrator
Start parent execution
source env.sh && \ BEFORE=$(aws secretsmanager get-secret-value --secret-id app/db/password --query SecretString --output text) && \ echo "before: $BEFORE" && \ EXEC_ARN=$(aws stepfunctions start-execution --state-machine-arn $(cat .parent_sm_arn) --query 'executionArn' --output text) && \ echo "exec: $EXEC_ARN" && \ echo "$EXEC_ARN" > .exec_arn
before: {"password":"initial-placeholder"}
exec: arn:aws:states:us-east-1:000000000000:execution:rotation-orchestrator:a1e51b31-6337-4f75-846a-157fc85b022c
[stdout]
before: {"password":"initial-placeholder"}
exec: arn:aws:states:us-east-1:000000000000:execution:rotation-orchestrator:a1e51b31-6337-4f75-846a-157fc85b022cPoll execution status up to 90s
source env.sh && \
EXEC_ARN=$(cat .exec_arn) && \
START=$(date +%s) && \
while :; do \
STATUS=$(aws stepfunctions describe-execution --execution-arn "$EXEC_ARN" --query status --output text); \
NOW=$(date +%s); \
echo "t=$((NOW-START))s status=$STATUS"; \
[ "$STATUS" != "RUNNING" ] && break; \
[ $((NOW-START)) -ge 90 ] && { echo "TIMEOUT"; break; }; \
sleep 2; \
donet=1s status=SUCCEEDED [stdout] t=1s status=SUCCEEDED
Verify secret version stages
source env.sh && \ echo "== current ==" && \ aws secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query '[VersionId,SecretString]' --output text && \ echo "== previous ==" && \ aws secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSPREVIOUS --query '[VersionId,SecretString]' --output text && \ echo "== versions ==" && \ aws secretsmanager list-secret-version-ids --secret-id app/db/password --query 'Versions[].[VersionId,VersionStages]' --output text
== current ==
b6a86b83-4ac4-4ce5-b62a-712417e4b85d {"password": "9jfpPrmOZVtXSHvLlEPdhsEj5nFORfHf"}
== previous ==
60f16566-c9d4-497b-a77d-aca07e1a6596 {"password":"initial-placeholder"}
== versions ==
b6a86b83-4ac4-4ce5-b62a-712417e4b85d
AWSCURRENT
60f16566-c9d4-497b-a77d-aca07e1a6596
AWSPREVIOUS
[stdout]
== current ==
b6a86b83-4ac4-4ce5-b62a-712417e4b85d {"password": "9jfpPrmOZVtXSHvLlEPdhsEj5nFORfHf"}
== previous ==
60f16566-c9d4-497b-a77d-aca07e1a6596 {"password":"initial-placeholder"}
== versions ==
b6a86b83-4ac4-4ce5-b62a-712417e4b85d
AWSCURRENT
60f16566-c9d4-497b-a77d-aca07e1a6596
AWSPREVIOUSCheck notification lambda logs
source env.sh && \
for i in $(seq 1 15); do \
LINES=$(aws logs tail /aws/lambda/notification-lambda --since 5m 2>/dev/null | grep -c '^.*notified:' || true); \
echo "attempt=$i lines=$LINES"; \
[ "$LINES" -ge 1 ] && break; \
sleep 2; \
done && \
echo "---" && \
aws logs tail /aws/lambda/notification-lambda --since 5m | grep 'notified:'attempt=1 lines=1 --- 2026-04-22T07:00:47.676000+00:00 2026/04/22/[$LATEST]396b28b992170ff16a630cf3399e223a notified: rotation complete secret=app/db/password version=b6a86b83-4ac4-4ce5-b62a-712417e4b85d [stdout] attempt=1 lines=1 --- 2026-04-22T07:00:47.676000+00:00 2026/04/22/[$LATEST]396b28b992170ff16a630cf3399e223a notified: rotation complete secret=app/db/password version=b6a86b83-4ac4-4ce5-b62a-712417e4b85d
Second rotation and differ-check
source env.sh && \
EXEC2=$(aws stepfunctions start-execution --state-machine-arn $(cat .parent_sm_arn) --query 'executionArn' --output text) && \
while :; do \
S=$(aws stepfunctions describe-execution --execution-arn "$EXEC2" --query status --output text); \
[ "$S" != "RUNNING" ] && { echo "status=$S"; break; }; \
sleep 1; \
done && \
echo "== current after 2nd rotation ==" && \
aws secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query 'SecretString' --output text && \
echo "== previous (should be first-rotation value) ==" && \
aws secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSPREVIOUS --query 'SecretString' --output textstatus=SUCCEEDED
== current after 2nd rotation ==
{"password": "_dbzFp032UGA6vsrTasqDNu0kN5HJD1r"}
== previous (should be first-rotation value) ==
{"password": "9jfpPrmOZVtXSHvLlEPdhsEj5nFORfHf"}
[stdout]
status=SUCCEEDED
== current after 2nd rotation ==
{"password": "_dbzFp032UGA6vsrTasqDNu0kN5HJD1r"}
== previous (should be first-rotation value) ==
{"password": "9jfpPrmOZVtXSHvLlEPdhsEj5nFORfHf"}Dump role policy docs for verifier review
source env.sh && \ echo "== rotator role inline =="; \ aws iam get-role-policy --role-name secrets-rotator-role --policy-name rotator-inline --query 'PolicyDocument' --output json; \ echo "== child SM role inline =="; \ aws iam get-role-policy --role-name rotation-child-role --policy-name child-inline --query 'PolicyDocument' --output json; \ echo "== parent SM role inline =="; \ aws iam get-role-policy --role-name rotation-orchestrator-role --policy-name parent-inline --query 'PolicyDocument' --output json; \ echo "== notifier role inline =="; \ aws iam get-role-policy --role-name notification-lambda-role --policy-name notifier-inline --query 'PolicyDocument' --output json
== rotator role inline ==
{
"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/secrets-rotator:*"
},
{
"Sid": "RotateSecret",
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-*"
},
{
"Sid": "KmsForSecret",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/2efc9431-2ba0-47b1-aefc-c4e676454263"
}
]
}
== child SM role inline ==
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvokeRotator",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator"
},
{
"Sid": "PutEventsOnBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/rotation-events"
}
]
}
== parent SM role inline ==
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StartAndControlChild",
"Effect": "Allow",
"Action": [
"states:StartExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child"
},
{
"Sid": "DescribeStopChildExecutions",
"Effect": "Allow",
"Action": [
"states:DescribeExecution",
"states:StopExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:execution:rotation-child:*"
},
{
"Sid": "ManagedRuleForSyncInvoke",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:PutTargets",
"events:DescribeRule"
],
"Resource": "arn:aws:events:us-east-1:000000000000:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule"
}
]
}
== notifier role inline ==
{
"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/notification-lambda:*"
}
]
}
[stdout]
== rotator role inline ==
{
"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/secrets-rotator:*"
},
{
"Sid": "RotateSecret",
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-*"
},
{
"Sid": "KmsForSecret",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/2efc9431-2ba0-47b1-aefc-c4e676454263"
}
]
}
== child SM role inline ==
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvokeRotator",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator"
},
{
"Sid": "PutEventsOnBus",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/rotation-events"
}
]
}
== parent SM role inline ==
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StartAndControlChild",
"Effect": "Allow",
"Action": [
"states:StartExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child"
},
{
"Sid": "DescribeStopChildExecutions",
"Effect": "Allow",
"Action": [
"states:DescribeExecution",
"states:StopExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:execution:rotation-child:*"
},
{
"Sid": "ManagedRuleForSyncInvoke",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:PutTargets",
"events:DescribeRule"
],
"Resource": "arn:aws:events:us-east-1:000000000000:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule"
}
]
}
== notifier role inline ==
{
"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/notification-lambda:*"
}
]
}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/8KvybffdT6PTU98U8NA-I/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 15 items
../tests/test_state.py::test_aws_cli_binary_is_real PASSED [ 6%]
../tests/test_state.py::test_localstack_reachable PASSED [ 13%]
../tests/test_state.py::test_secret_exists_and_uses_customer_cmk PASSED [ 20%]
../tests/test_state.py::test_rotation_lambda_exists PASSED [ 26%]
../tests/test_state.py::test_notification_lambda_exists PASSED [ 33%]
../tests/test_state.py::test_parent_and_child_state_machines_exist PASSED [ 40%]
../tests/test_state.py::test_event_bus_and_rule_exist PASSED [ 46%]
../tests/test_state.py::test_rule_target_points_at_notification_lambda PASSED [ 53%]
../tests/test_state.py::test_rotator_role_grants_kms_actions PASSED [ 60%]
../tests/test_state.py::test_kms_key_policy_grants_rotator_role PASSED [ 66%]
../tests/test_state.py::test_rotator_lambda_allows_secretsmanager_invoke FAILED [ 73%]
../tests/test_state.py::test_parent_role_grants_sync2_managed_rule_perms FAILED [ 80%]
../tests/test_state.py::test_child_role_can_rotate_and_publish FAILED [ 86%]
../tests/test_state.py::test_notification_lambda_allows_eventbridge_invoke PASSED [ 93%]
../tests/test_state.py::test_end_to_end_parent_execution_rotates_and_notifies PASSED [100%]
=================================== FAILURES ===================================
_______________ test_rotator_lambda_allows_secretsmanager_invoke _______________
lmb = <botocore.client.Lambda object at 0xffffa9793020>
def test_rotator_lambda_allows_secretsmanager_invoke(lmb):
try:
policy_doc = json.loads(
> lmb.get_policy(FunctionName=ROTATOR_FUNCTION)["Policy"]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
/tests/test_state.py:332:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/root/.cache/uv/archive-v0/8KvybffdT6PTU98U8NA-I/lib/python3.12/site-packages/botocore/client.py:569: in _api_call
return self._make_api_call(operation_name, kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <botocore.client.Lambda object at 0xffffa9793020>
operation_name = 'GetPolicy', api_params = {'FunctionName': 'secrets-rotator'}
def _make_api_call(self, operation_name, api_params):
operation_model = self._service_model.operation_model(operation_name)
service_name = self._service_model.service_name
history_recorder.record(
'API_CALL',
{
'service': service_name,
'operation': operation_name,
'params': api_params,
},
)
if operation_model.deprecated:
logger.debug(
'Warning: %s.%s() is deprecated', service_name, operation_name
)
request_context = {
'client_region': self.meta.region_name,
'client_config': self.meta.config,
'has_streaming_input': operation_model.has_streaming_input,
'auth_type': operation_model.resolved_auth_type,
'unsigned_payload': operation_model.unsigned_payload,
}
api_params = self._emit_api_params(
api_params=api_params,
operation_model=operation_model,
context=request_context,
)
(
endpoint_url,
additional_headers,
properties,
) = self._resolve_endpoint_ruleset(
operation_model, api_params, request_context
)
if properties:
# Pass arbitrary endpoint info with the Request
# for use during construction.
request_context['endpoint_properties'] = properties
request_dict = self._convert_to_request_dict(
api_params=api_params,
operation_model=operation_model,
endpoint_url=endpoint_url,
context=request_context,
headers=additional_headers,
)
resolve_checksum_context(request_dict, operation_model, api_params)
service_id = self._service_model.service_id.hyphenize()
handler, event_response = self.meta.events.emit_until_response(
f'before-call.{service_id}.{operation_name}',
model=operation_model,
params=request_dict,
request_signer=self._request_signer,
context=request_context,
)
if event_response is not None:
http, parsed_response = event_response
else:
maybe_compress_request(
self.meta.config, request_dict, operation_model
)
apply_request_checksum(request_dict)
http, parsed_response = self._make_request(
operation_model, request_dict, request_context
)
self.meta.events.emit(
f'after-call.{service_id}.{operation_name}',
http_response=http,
parsed=parsed_response,
model=operation_model,
context=request_context,
)
if http.status_code >= 300:
error_info = parsed_response.get("Error", {})
error_code = error_info.get("QueryErrorCode") or error_info.get(
"Code"
)
error_class = self.exceptions.from_code(error_code)
> raise error_class(parsed_response, operation_name)
E botocore.errorfactory.ResourceNotFoundException: An error occurred (ResourceNotFoundException) when calling the GetPolicy operation: The resource you requested does not exist.
/root/.cache/uv/archive-v0/8KvybffdT6PTU98U8NA-I/lib/python3.12/site-packages/botocore/client.py:1023: ResourceNotFoundException
During handling of the above exception, another exception occurred:
lmb = <botocore.client.Lambda object at 0xffffa9793020>
def test_rotator_lambda_allows_secretsmanager_invoke(lmb):
try:
policy_doc = json.loads(
lmb.get_policy(FunctionName=ROTATOR_FUNCTION)["Policy"]
)
except ClientError as e:
> pytest.fail(
f"Lambda {ROTATOR_FUNCTION} has no resource-based policy , "
f"secretsmanager.amazonaws.com cannot invoke it: {e}"
)
E Failed: Lambda secrets-rotator has no resource-based policy , secretsmanager.amazonaws.com cannot invoke it: An error occurred (ResourceNotFoundException) when calling the GetPolicy operation: The resource you requested does not exist.
/tests/test_state.py:335: Failed
_______________ test_parent_role_grants_sync2_managed_rule_perms _______________
iam = <botocore.client.IAM object at 0xffffa92cda00>
def test_parent_role_grants_sync2_managed_rule_perms(iam):
"""The .sync:2 integration registers an EventBridge-managed callback
rule. Parent role needs events:PutRule/PutTargets/DescribeRule plus
full states:* on the child."""
found_events = False
found_states = False
for _pname, st in _inline_statements(iam, PARENT_ROLE):
actions = set(_normalise_list(st.get("Action")))
if _actions_cover(actions, REQUIRED_PARENT_EVENTS_ACTIONS):
found_events = True
if _actions_cover(actions, REQUIRED_PARENT_STATES_ACTIONS):
found_states = True
assert found_events, (
f"{PARENT_ROLE} missing events:PutRule/PutTargets/DescribeRule. "
f".sync:2 uses an EventBridge-managed callback rule; without "
f"these the parent cannot set up the callback."
)
> assert found_states, (
f"{PARENT_ROLE} missing states:StartExecution/DescribeExecution/"
f"StopExecution. .sync:2 requires all three on the child SM."
)
E AssertionError: rotation-orchestrator-role missing states:StartExecution/DescribeExecution/StopExecution. .sync:2 requires all three on the child SM.
E assert False
/tests/test_state.py:377: AssertionError
____________________ test_child_role_can_rotate_and_publish ____________________
iam = <botocore.client.IAM object at 0xffffa92cda00>
def test_child_role_can_rotate_and_publish(iam):
found_rotate = False
found_putevents = False
for _pname, st in _inline_statements(iam, CHILD_ROLE):
actions = set(_normalise_list(st.get("Action")))
if _actions_cover(actions, {"secretsmanager:RotateSecret"}):
found_rotate = True
if _actions_cover(actions, {"events:PutEvents"}):
found_putevents = True
> assert found_rotate, (
f"{CHILD_ROLE} lacks secretsmanager:RotateSecret , the child "
f"state machine cannot trigger rotation."
)
E AssertionError: rotation-child-role lacks secretsmanager:RotateSecret , the child state machine cannot trigger rotation.
E assert False
/tests/test_state.py:392: AssertionError
=============================== warnings summary ===============================
test_state.py: 33 warnings
/root/.cache/uv/archive-v0/8KvybffdT6PTU98U8NA-I/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_state.py::test_aws_cli_binary_is_real
PASSED ../tests/test_state.py::test_localstack_reachable
PASSED ../tests/test_state.py::test_secret_exists_and_uses_customer_cmk
PASSED ../tests/test_state.py::test_rotation_lambda_exists
PASSED ../tests/test_state.py::test_notification_lambda_exists
PASSED ../tests/test_state.py::test_parent_and_child_state_machines_exist
PASSED ../tests/test_state.py::test_event_bus_and_rule_exist
PASSED ../tests/test_state.py::test_rule_target_points_at_notification_lambda
PASSED ../tests/test_state.py::test_rotator_role_grants_kms_actions
PASSED ../tests/test_state.py::test_kms_key_policy_grants_rotator_role
PASSED ../tests/test_state.py::test_notification_lambda_allows_eventbridge_invoke
PASSED ../tests/test_state.py::test_end_to_end_parent_execution_rotates_and_notifies
FAILED ../tests/test_state.py::test_rotator_lambda_allows_secretsmanager_invoke
FAILED ../tests/test_state.py::test_parent_role_grants_sync2_managed_rule_perms
FAILED ../tests/test_state.py::test_child_role_can_rotate_and_publish - Asser...
================== 3 failed, 12 passed, 33 warnings in 7.21s ===================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_90921d8ab99e49c6. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_90921d8ab99e49c6 · verifier authoritative; classifier explanatory.