tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check tooling and localstack health
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": "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": "disable
[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": "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": "disableCheck env vars
echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION AWS_REGION=$AWS_REGION"
AWS_ACCESS_KEY_ID=test AWS_DEFAULT_REGION=us-east-1 AWS_REGION= [stdout] AWS_ACCESS_KEY_ID=test AWS_DEFAULT_REGION=us-east-1 AWS_REGION=
Inspect working dir
ls /app/ 2>/dev/null && pwd
/app [stdout] /app
Verify localstack auth
aws --endpoint-url=http://localstack:4566 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 directory
mkdir -p /app/build && ls /app/
build [stdout] build
Create customer-managed KMS key
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS kms create-key --description "app-rotation-key" --key-usage ENCRYPT_DECRYPT --query 'KeyMetadata.{KeyId:KeyId,Arn:Arn}' --output json{
"KeyId": "f8467244-6aa8-4165-8e92-e83d2d52a5ce",
"Arn": "arn:aws:kms:us-east-1:000000000000:key/f8467244-6aa8-4165-8e92-e83d2d52a5ce"
}
[stdout]
{
"KeyId": "f8467244-6aa8-4165-8e92-e83d2d52a5ce",
"Arn": "arn:aws:kms:us-east-1:000000000000:key/f8467244-6aa8-4165-8e92-e83d2d52a5ce"
}Create KMS alias
export LS=http://localstack:4566 && \ KEY_ID=f8467244-6aa8-4165-8e92-e83d2d52a5ce && \ aws --endpoint-url=$LS kms create-alias --alias-name alias/app-rotation-key --target-key-id $KEY_ID && \ aws --endpoint-url=$LS kms describe-key --key-id alias/app-rotation-key --query 'KeyMetadata.Arn' --output text
arn:aws:kms:us-east-1:000000000000:key/f8467244-6aa8-4165-8e92-e83d2d52a5ce [stdout] arn:aws:kms:us-east-1:000000000000:key/f8467244-6aa8-4165-8e92-e83d2d52a5ce
Create secret encrypted with CMK
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS secretsmanager create-secret \
--name app/db/password \
--description "DB password for app" \
--secret-string '{"username":"app","password":"initial-password-seed"}' \
--kms-key-id alias/app-rotation-key \
--query 'ARN' --output textarn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-gjOQOb [stdout] arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-gjOQOb
Confirm secret details
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS secretsmanager describe-secret --secret-id app/db/password --query '{ARN:ARN,KmsKeyId:KmsKeyId}' --output json{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-gjOQOb",
"KmsKeyId": "alias/app-rotation-key"
}
[stdout]
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-gjOQOb",
"KmsKeyId": "alias/app-rotation-key"
}{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
import os
import json
import secrets
import string
import uuid
import boto3
ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localstack:4566")
SECRET_ID = os.environ["SECRET_ID"]
sm = boto3.client("secretsmanager", endpoint_url=ENDPOINT)
def _new_password(length: int = 28) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def handler(event, context):
desc = sm.describe_secret(SecretId=SECRET_ID)
current_version_id = None
for vid, stages in desc.get("VersionIdsToStages", {}).items():
if "AWSCURRENT" in stages:
current_version_id = vid
break
current = sm.get_secret_value(SecretId=SECRET_ID, VersionStage="AWSCURRENT")
try:
payload = json.loads(current["SecretString"])
except (ValueError, TypeError):
payload = {"username": "app", "password": ""}
new_password = _new_password()
while new_password == payload.get("password"):
new_password = _new_password()
payload["password"] = new_password
new_version_id = str(uuid.uuid4())
sm.put_secret_value(
SecretId=SECRET_ID,
ClientRequestToken=new_version_id,
SecretString=json.dumps(payload),
VersionStages=["AWSPENDING"],
)
kwargs = {
"SecretId": SECRET_ID,
"VersionStage": "AWSCURRENT",
"MoveToVersionId": new_version_id,
}
if current_version_id:
kwargs["RemoveFromVersionId"] = current_version_id
sm.update_secret_version_stage(**kwargs)
sm.update_secret_version_stage(
SecretId=SECRET_ID,
VersionStage="AWSPENDING",
RemoveFromVersionId=new_version_id,
)
return {
"secretId": SECRET_ID,
"newVersionId": new_version_id,
"previousVersionId": current_version_id,
}
import json
def handler(event, context):
detail = event.get("detail", {}) if isinstance(event, dict) else {}
secret_id = detail.get("secretId", "<unknown>")
version_id = detail.get("newVersionId", "<unknown>")
print(f"notified: rotation complete for {secret_id} version={version_id} event={json.dumps(event)}")
return {"ok": True}
Package lambdas
cd /app/build/rotator && zip -q /app/build/rotator.zip handler.py && cd /app/build/notifier && zip -q /app/build/notifier.zip handler.py && ls -la /app/build/*.zip
-rw-r--r-- 1 root root 378 Apr 22 06:48 /app/build/notifier.zip -rw-r--r-- 1 root root 903 Apr 22 06:48 /app/build/rotator.zip [stdout] -rw-r--r-- 1 root root 378 Apr 22 06:48 /app/build/notifier.zip -rw-r--r-- 1 root root 903 Apr 22 06:48 /app/build/rotator.zip
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "states.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SecretsRotation",
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-*"
},
{
"Sid": "UseCMK",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/__KMS_KEY_ID__"
},
{
"Sid": "WriteLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/secrets-rotator:*"
},
{
"Sid": "CreateLogGroup",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/secrets-rotator"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "WriteLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/notification-lambda:*"
},
{
"Sid": "CreateLogGroup",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/notification-lambda"
}
]
}
Fill KMS key ID into rotator policy
KEY_ID=f8467244-6aa8-4165-8e92-e83d2d52a5ce && \ sed -i "s/__KMS_KEY_ID__/$KEY_ID/g" /app/build/policy-rotator.json && \ cat /app/build/policy-rotator.json | head -40
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SecretsRotation",
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-*"
},
{
"Sid": "UseCMK",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/f8467244-6aa8-4165-8e92-e83d2d52a5ce"
},
{
"Sid": "WriteLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/secrets-rotator:*"
},
{
"Sid": "CreateLogGroup",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/secrets-rotator"
}
]
}
[stdout]
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SecretsRotation",
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:000000000000:secret:app/db/password-*"
},
{
"Sid": "UseCMK",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "arn:aws:kms:us-east-1:000000000000:key/f8467244-6aa8-4165-8e92-e83d2d52a5ce"
},
{
"Sid": "WriteLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/secrets-rotator:*"
},
{
"Sid": "CreateLogGroup",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/secrets-rotator"
}
]
}Create lambda roles with scoped policies
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS iam create-role --role-name secrets-rotator-role \ --assume-role-policy-document file:///app/build/trust-lambda.json \ --query 'Role.Arn' --output text && \ aws --endpoint-url=$LS iam put-role-policy --role-name secrets-rotator-role \ --policy-name rotator-inline --policy-document file:///app/build/policy-rotator.json && \ aws --endpoint-url=$LS iam create-role --role-name notification-lambda-role \ --assume-role-policy-document file:///app/build/trust-lambda.json \ --query 'Role.Arn' --output text && \ aws --endpoint-url=$LS iam put-role-policy --role-name notification-lambda-role \ --policy-name notifier-inline --policy-document file:///app/build/policy-notifier.json
arn:aws:iam::000000000000:role/secrets-rotator-role arn:aws:iam::000000000000:role/notification-lambda-role [stdout] arn:aws:iam::000000000000:role/secrets-rotator-role arn:aws:iam::000000000000:role/notification-lambda-role
Deploy rotator lambda
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS lambda create-function \
--function-name secrets-rotator \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/secrets-rotator-role \
--handler handler.handler \
--zip-file fileb:///app/build/rotator.zip \
--timeout 30 \
--environment "Variables={SECRET_ID=app/db/password,AWS_ENDPOINT_URL=http://localstack:4566}" \
--query '{Arn:FunctionArn,State:State}' --output json{
"Arn": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator",
"State": "Pending"
}
[stdout]
{
"Arn": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator",
"State": "Pending"
}Deploy notifier lambda
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS lambda create-function \
--function-name notification-lambda \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/notification-lambda-role \
--handler handler.handler \
--zip-file fileb:///app/build/notifier.zip \
--timeout 30 \
--query '{Arn:FunctionArn,State:State}' --output json{
"Arn": "arn:aws:lambda:us-east-1:000000000000:function:notification-lambda",
"State": "Pending"
}
[stdout]
{
"Arn": "arn:aws:lambda:us-east-1:000000000000:function:notification-lambda",
"State": "Pending"
}Wait for lambdas active
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS lambda wait function-active-v2 --function-name secrets-rotator && \ aws --endpoint-url=$LS lambda wait function-active-v2 --function-name notification-lambda && \ echo "both active"
both active [stdout] both active
Smoke test rotator
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS lambda invoke --function-name secrets-rotator --payload '{}' --cli-binary-format raw-in-base64-out /tmp/out.json && \
cat /tmp/out.json && echo && \
aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query 'SecretString' --output text && echo && \
aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSPREVIOUS --query 'SecretString' --output text{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
{"secretId": "app/db/password", "newVersionId": "cd060fdf-ee52-4189-812c-f4281f993217", "previousVersionId": "687d4b42-7279-4609-82b3-68e151deb50d"}
{"username": "app", "password": "nWb5pt0n2EOUbDtTWcCY2seupWZq"}
{"username":"app","password":"initial-password-seed"}
[stdout]
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
{"secretId": "app/db/password", "newVersionId": "cd060fdf-ee52-4189-812c-f4281f993217", "previousVersionId": "687d4b42-7279-4609-82b3-68e151deb50d"}
{"username": "app", "password": "nWb5pt0n2EOUbDtTWcCY2seupWZq"}
{"username":"app","password":"initial-password-seed"}Create custom EventBridge bus
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS events create-event-bus --name rotation-events --query 'EventBusArn' --output text
arn:aws:events:us-east-1:000000000000:event-bus/rotation-events [stdout] arn:aws:events:us-east-1:000000000000:event-bus/rotation-events
{
"source": ["rotation.pipeline"],
"detail-type": ["RotationComplete"]
}
Create rule on custom bus
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS events put-rule \ --name on-rotation-success \ --event-bus-name rotation-events \ --event-pattern file:///app/build/event-pattern.json \ --state ENABLED \ --query 'RuleArn' --output text
arn:aws:events:us-east-1:000000000000:rule/rotation-events/on-rotation-success [stdout] arn:aws:events:us-east-1:000000000000:rule/rotation-events/on-rotation-success
Wire rule target + lambda permission
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS events put-targets \
--event-bus-name rotation-events \
--rule on-rotation-success \
--targets 'Id=notifier,Arn=arn:aws:lambda:us-east-1:000000000000:function:notification-lambda' \
--query '{Failed:FailedEntryCount}' --output json && \
aws --endpoint-url=$LS lambda add-permission \
--function-name notification-lambda \
--statement-id allow-eb-rotation-events \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn arn:aws:events:us-east-1:000000000000:rule/rotation-events/on-rotation-success \
--query 'Statement' --output text | head -c 200{
"Failed": 0
}
{"Sid": "allow-eb-rotation-events", "Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:us-east-1:000000000000:function:notification-lambda", "Principal": {"Service": "ev
[stdout]
{
"Failed": 0
}
{"Sid": "allow-eb-rotation-events", "Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:us-east-1:000000000000:function:notification-lambda", "Principal": {"Service": "evProbe event pipeline
export LS=http://localstack:4566 && \
aws --endpoint-url=$LS events put-events --entries '[{"Source":"rotation.pipeline","DetailType":"RotationComplete","EventBusName":"rotation-events","Detail":"{\"secretId\":\"app/db/password\",\"newVersionId\":\"probe\"}"}]' --query '{Failed:FailedEntryCount}' --output json && \
sleep 3 && \
aws --endpoint-url=$LS logs describe-log-groups --log-group-name-prefix /aws/lambda/notification-lambda --query 'logGroups[].logGroupName' --output text && \
LG=/aws/lambda/notification-lambda && \
aws --endpoint-url=$LS logs filter-log-events --log-group-name $LG --filter-pattern "notified" --query 'events[].message' --output text | tail{
"Failed": 0
}
/aws/lambda/notification-lambda
START RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Version: $LATEST
notified: rotation complete for app/db/password version=probe event={"version": "0", "id": "c377552a-8938-4bd2-9250-64234e60d470", "detail-type": "RotationComplete", "source": "rotation.pipeline", "account": "000000000000", "time": "2026-04-22T06:49:53Z", "region": "us-east-1", "resources": [], "detail": {"secretId": "app/db/password", "newVersionId": "probe"}}
END RequestId: 867272c8-9243-4afc-a8bb-156057102e25
REPORT RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Duration: 11.06 ms Billed Duration: 12 ms Memory Size: 128 MB Max Memory Used: 128 MB
[stdout]
{
"Failed": 0
}
/aws/lambda/notification-lambda
START RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Version: $LATEST
notified: rotation complete for app/db/password version=probe event={"version": "0", "id": "c377552a-8938-4bd2-9250-64234e60d470", "detail-type": "RotationComplete", "source": "rotation.pipeline", "account": "000000000000", "time": "2026-04-22T06:49:53Z", "region": "us-east-1", "resources": [], "detail": {"secretId": "app/db/password", "newVersionId": "probe"}}
END RequestId: 867272c8-9243-4afc-a8bb-156057102e25
REPORT RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Duration: 11.06 ms Billed Duration: 12 ms Memory Size: 128 MB Max Memory Used: 128 MB{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvokeRotator",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator"
},
{
"Sid": "PutRotationEvents",
"Effect": "Allow",
"Action": "events:PutEvents",
"Resource": "arn:aws:events:us-east-1:000000000000:event-bus/rotation-events"
}
]
}
{
"Comment": "Rotate secret then publish RotationComplete",
"StartAt": "InvokeRotator",
"States": {
"InvokeRotator": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:000000000000:function:secrets-rotator",
"Payload": {}
},
"ResultSelector": {
"secretId.$": "$.Payload.secretId",
"newVersionId.$": "$.Payload.newVersionId",
"previousVersionId.$": "$.Payload.previousVersionId"
},
"ResultPath": "$.rotation",
"Next": "PublishRotationComplete"
},
"PublishRotationComplete": {
"Type": "Task",
"Resource": "arn:aws:states:::events:putEvents",
"Parameters": {
"Entries": [
{
"EventBusName": "rotation-events",
"Source": "rotation.pipeline",
"DetailType": "RotationComplete",
"Detail": {
"secretId.$": "$.rotation.secretId",
"newVersionId.$": "$.rotation.newVersionId",
"previousVersionId.$": "$.rotation.previousVersionId"
}
}
]
},
"End": true
}
}
}
Create child SM role
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS iam create-role --role-name rotation-child-role \ --assume-role-policy-document file:///app/build/trust-states.json \ --query 'Role.Arn' --output text && \ aws --endpoint-url=$LS iam put-role-policy --role-name rotation-child-role \ --policy-name child-inline --policy-document file:///app/build/policy-child.json
arn:aws:iam::000000000000:role/rotation-child-role [stdout] arn:aws:iam::000000000000:role/rotation-child-role
Create child state machine
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS stepfunctions create-state-machine \ --name rotation-child \ --definition file:///app/build/child-sm.json \ --role-arn arn:aws:iam::000000000000:role/rotation-child-role \ --query 'stateMachineArn' --output text
arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child [stdout] arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StartChild",
"Effect": "Allow",
"Action": "states:StartExecution",
"Resource": "arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child"
},
{
"Sid": "ControlChildExecutions",
"Effect": "Allow",
"Action": [
"states:DescribeExecution",
"states:StopExecution"
],
"Resource": "arn:aws:states:us-east-1:000000000000:execution:rotation-child:*"
},
{
"Sid": "ManagedRuleForSyncChildInvocation",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:PutTargets",
"events:DescribeRule"
],
"Resource": "arn:aws:events:us-east-1:000000000000:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule"
}
]
}
{
"Comment": "Entrypoint state machine: delegates rotation to child SM and waits for completion",
"StartAt": "RunChild",
"States": {
"RunChild": {
"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
}
}
}
Create parent SM + role
export LS=http://localstack:4566 && \ aws --endpoint-url=$LS iam create-role --role-name rotation-orchestrator-role \ --assume-role-policy-document file:///app/build/trust-states.json \ --query 'Role.Arn' --output text && \ aws --endpoint-url=$LS iam put-role-policy --role-name rotation-orchestrator-role \ --policy-name parent-inline --policy-document file:///app/build/policy-parent.json && \ aws --endpoint-url=$LS stepfunctions create-state-machine \ --name rotation-orchestrator \ --definition file:///app/build/parent-sm.json \ --role-arn arn:aws:iam::000000000000:role/rotation-orchestrator-role \ --query 'stateMachineArn' --output text
arn:aws:iam::000000000000:role/rotation-orchestrator-role arn:aws:states:us-east-1:000000000000:stateMachine:rotation-orchestrator [stdout] arn:aws:iam::000000000000:role/rotation-orchestrator-role arn:aws:states:us-east-1:000000000000:stateMachine:rotation-orchestrator
Start parent + poll until terminal or 90s
export LS=http://localstack:4566 && \
BEFORE_CURRENT=$(aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query 'SecretString' --output text) && \
echo "BEFORE AWSCURRENT: $BEFORE_CURRENT" && \
T0=$(date +%s) && \
EXEC_ARN=$(aws --endpoint-url=$LS stepfunctions start-execution \
--state-machine-arn arn:aws:states:us-east-1:000000000000:stateMachine:rotation-orchestrator \
--query 'executionArn' --output text) && \
echo "exec=$EXEC_ARN t0=$T0" && \
STATUS=RUNNING; \
while [ "$STATUS" = "RUNNING" ]; do \
NOW=$(date +%s); ELAPSED=$((NOW-T0)); \
if [ $ELAPSED -gt 90 ]; then echo "TIMEOUT at ${ELAPSED}s"; break; fi; \
sleep 2; \
STATUS=$(aws --endpoint-url=$LS stepfunctions describe-execution --execution-arn $EXEC_ARN --query 'status' --output text); \
echo "t=${ELAPSED}s status=$STATUS"; \
done && \
echo "FINAL=$STATUS"BEFORE AWSCURRENT: {"username": "app", "password": "nWb5pt0n2EOUbDtTWcCY2seupWZq"}
exec=arn:aws:states:us-east-1:000000000000:execution:rotation-orchestrator:2b2bc842-2226-4c65-b8a1-0ead475a4dde t0=1776840662
t=1s status=SUCCEEDED
FINAL=SUCCEEDED
[stdout]
BEFORE AWSCURRENT: {"username": "app", "password": "nWb5pt0n2EOUbDtTWcCY2seupWZq"}
exec=arn:aws:states:us-east-1:000000000000:execution:rotation-orchestrator:2b2bc842-2226-4c65-b8a1-0ead475a4dde t0=1776840662
t=1s status=SUCCEEDED
FINAL=SUCCEEDEDVerify rotation + logs
export LS=http://localstack:4566 && \ echo "=== AWSCURRENT ===" && \ aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query 'SecretString' --output text && \ echo "=== AWSPREVIOUS ===" && \ aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSPREVIOUS --query 'SecretString' --output text && \ echo "=== VersionIdsToStages ===" && \ aws --endpoint-url=$LS secretsmanager describe-secret --secret-id app/db/password --query 'VersionIdsToStages' --output json && \ echo "=== KmsKeyId on secret ===" && \ aws --endpoint-url=$LS secretsmanager describe-secret --secret-id app/db/password --query 'KmsKeyId' --output text && \ echo "=== Notifier logs (notified:) ===" && \ aws --endpoint-url=$LS logs filter-log-events --log-group-name /aws/lambda/notification-lambda --filter-pattern '"notified:"' --query 'events[].message' --output text
=== AWSCURRENT ===
{"username": "app", "password": "6Oz0XzG3GA3N2M0IrTVUXEzrY2OE"}
=== AWSPREVIOUS ===
{"username": "app", "password": "nWb5pt0n2EOUbDtTWcCY2seupWZq"}
=== VersionIdsToStages ===
{
"cd060fdf-ee52-4189-812c-f4281f993217": [
"AWSPREVIOUS"
],
"c4d22d28-be6f-49c7-90ae-a84299fa8a93": [
"AWSCURRENT"
]
}
=== KmsKeyId on secret ===
alias/app-rotation-key
=== Notifier logs (notified:) ===
START RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Version: $LATEST
notified: rotation complete for app/db/password version=probe event={"version": "0", "id": "c377552a-8938-4bd2-9250-64234e60d470", "detail-type": "RotationComplete", "source": "rotation.pipeline", "account": "000000000000", "time": "2026-04-22T06:49:53Z", "region": "us-east-1", "resources": [], "detail": {"secretId": "app/db/password", "newVersionId": "probe"}}
END RequestId: 867272c8-9243-4afc-a8bb-156057102e25
REPORT RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Duration: 11.06 ms Billed Duration: 12 ms Memory Size: 128 MB Max Memory Used: 128 MB
START RequestId: 81712371-9224-4faa-a9e8-00052f02a193 Version: $LATEST
notified: rotation complete for app/db/password version=c4d22d28-be6f-49c7-90ae-a84299fa8a93 event={"version": "0", "id": "52750665-0f0b-4c28-987a-63cee438b826", "detail-type": "RotationComplete", "source": "rotation.pipeline", "account": "000000000000", "time": "2026-04-22T06:51:04Z", "region": "us-east-1", "resources": ["arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child", "arn:aws:states:us-east-1:000000000000:execution:rotation-child:a14fe65a-7c22-400c-adb9-2c52e07365d3"], "detail": {"secretId": "app/db/password", "newVersionId": "c4d22d28-be6f-49c7-90ae-a84299fa8a93", "previousVersionId": "cd060fdf-ee52-4189-812c-f4281f993217"}}
END RequestId: 81712371-9224-4faa-a9e8-00052f02a193
REPORT RequestId: 81712371-9224-4faa-a9e8-00052f02a193 Duration: 9.58 ms Billed Duration: 10 ms Memory Size: 128 MB Max Memory Used: 128 MB
[stdout]
=== AWSCURRENT ===
{"username": "app", "password": "6Oz0XzG3GA3N2M0IrTVUXEzrY2OE"}
=== AWSPREVIOUS ===
{"username": "app", "password": "nWb5pt0n2EOUbDtTWcCY2seupWZq"}
=== VersionIdsToStages ===
{
"cd060fdf-ee52-4189-812c-f4281f993217": [
"AWSPREVIOUS"
],
"c4d22d28-be6f-49c7-90ae-a84299fa8a93": [
"AWSCURRENT"
]
}
=== KmsKeyId on secret ===
alias/app-rotation-key
=== Notifier logs (notified:) ===
START RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Version: $LATEST
notified: rotation complete for app/db/password version=probe event={"version": "0", "id": "c377552a-8938-4bd2-9250-64234e60d470", "detail-type": "RotationComplete", "source": "rotation.pipeline", "account": "000000000000", "time": "2026-04-22T06:49:53Z", "region": "us-east-1", "resources": [], "detail": {"secretId": "app/db/password", "newVersionId": "probe"}}
END RequestId: 867272c8-9243-4afc-a8bb-156057102e25
REPORT RequestId: 867272c8-9243-4afc-a8bb-156057102e25 Duration: 11.06 ms Billed Duration: 12 ms Memory Size: 128 MB Max Memory Used: 128 MB
START RequestId: 81712371-9224-4faa-a9e8-00052f02a193 Version: $LATEST
notified: rotation complete for app/db/password version=c4d22d28-be6f-49c7-90ae-a84299fa8a93 event={"version": "0", "id": "52750665-0f0b-4c28-987a-63cee438b826", "detail-type": "RotationComplete", "source": "rotation.pipeline", "account": "000000000000", "time": "2026-04-22T06:51:04Z", "region": "us-east-1", "resources": ["arn:aws:states:us-east-1:000000000000:stateMachine:rotation-child", "arn:aws:states:us-east-1:000000000000:execution:rotation-child:a14fe65a-7c22-400c-adb9-2c52e07365d3"], "detail": {"secretId": "app/db/password", "newVersionId": "c4d22d28-be6f-49c7-90ae-a84299fa8a93", "previousVersionId": "cd060fdf-ee52-4189-812c-f4281f993217"}}
END RequestId: 81712371-9224-4faa-a9e8-00052f02a193
REPORT RequestId: 81712371-9224-4faa-a9e8-00052f02a193 Duration: 9.58 ms Billed Duration: 10 ms Memory Size: 128 MB Max Memory Used: 128 MBSecond rotation: confirm password changes
export LS=http://localstack:4566 && \ P1=$(aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query 'SecretString' --output text) && \ aws --endpoint-url=$LS stepfunctions start-execution \ --state-machine-arn arn:aws:states:us-east-1:000000000000:stateMachine:rotation-orchestrator \ --query 'executionArn' --output text > /tmp/exec2 && \ sleep 3 && \ STATUS=$(aws --endpoint-url=$LS stepfunctions describe-execution --execution-arn $(cat /tmp/exec2) --query 'status' --output text) && \ P2=$(aws --endpoint-url=$LS secretsmanager get-secret-value --secret-id app/db/password --version-stage AWSCURRENT --query 'SecretString' --output text) && \ echo "parent#2=$STATUS"; echo "P1=$P1"; echo "P2=$P2"; \ [ "$P1" != "$P2" ] && echo "DIFF: ok" || echo "DIFF: FAIL"
parent#2=SUCCEEDED
P1={"username": "app", "password": "6Oz0XzG3GA3N2M0IrTVUXEzrY2OE"}
P2={"username": "app", "password": "86Ktk4AMNNoHmPCW39XbkBEoDktq"}
DIFF: ok
[stdout]
parent#2=SUCCEEDED
P1={"username": "app", "password": "6Oz0XzG3GA3N2M0IrTVUXEzrY2OE"}
P2={"username": "app", "password": "86Ktk4AMNNoHmPCW39XbkBEoDktq"}
DIFF: okNo 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/HEJl1MGEHORblBsDOmqkw/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 FAILED [ 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_kms_key_policy_grants_rotator_role ____________________
iam = <botocore.client.IAM object at 0xffff8a8cdb50>
kms = <botocore.client.KMS object at 0xffff8ae18350>
def test_kms_key_policy_grants_rotator_role(iam, kms):
role_arn = iam.get_role(RoleName=ROTATOR_ROLE)["Role"]["Arn"]
policy_str = kms.get_key_policy(KeyId=KEY_ALIAS, PolicyName="default")[
"Policy"
]
policy = json.loads(policy_str)
match = False
for st in policy.get("Statement", []):
if st.get("Effect") != "Allow":
continue
principal = st.get("Principal") or {}
if not isinstance(principal, dict):
continue
arns = set(_normalise_list(principal.get("AWS")))
if role_arn not in arns:
continue
actions = set(_normalise_list(st.get("Action")))
if _actions_cover(actions, REQUIRED_KMS_ACTIONS):
match = True
break
> assert match, (
f"KMS key policy on {KEY_ALIAS} has no Allow statement whose "
f"Principal.AWS includes {role_arn} and whose Action covers "
f"{sorted(REQUIRED_KMS_ACTIONS)}. Key policy: {policy_str}"
)
E AssertionError: KMS key policy on alias/app-rotation-key has no Allow statement whose Principal.AWS includes arn:aws:iam::000000000000:role/secrets-rotator-role and whose Action covers ['kms:Decrypt', 'kms:GenerateDataKey']. Key policy: {"Version": "2012-10-17", "Id": "key-default-1", "Statement": [{"Sid": "Enable IAM User Permissions", "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::000000000000:root"}, "Action": "kms:*", "Resource": "*"}]}
E assert False
/tests/test_state.py:322: AssertionError
_______________ test_rotator_lambda_allows_secretsmanager_invoke _______________
lmb = <botocore.client.Lambda object at 0xffff8ae19b80>
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/HEJl1MGEHORblBsDOmqkw/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 0xffff8ae19b80>
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/HEJl1MGEHORblBsDOmqkw/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 0xffff8ae19b80>
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 0xffff8a8cdb50>
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 0xffff8a8cdb50>
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/HEJl1MGEHORblBsDOmqkw/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_
… (truncated at 12,000 chars, full verifier log is in the trial artifacts)Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_7d45ce28f5be466e. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_7d45ce28f5be466e · verifier authoritative; classifier explanatory.