Skip to content

Commit 167fabf

Browse files
committed
fix(aws-lambda): isolate smoke cleanup ownership
1 parent d1a1e0d commit 167fabf

3 files changed

Lines changed: 154 additions & 36 deletions

File tree

examples/aws-lambda/scripts/_aws-isolation.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
#!/usr/bin/env bash
22
# AWS resource-name isolation and failed-deploy discovery helpers.
33

4+
hf_new_smoke_run_id() {
5+
local seconds seed digest
6+
seconds=$(date +%s)
7+
seed="${seconds}:$$:${RANDOM}:${BASHPID:-$$}"
8+
digest=$(printf '%s' "$seed" | sha256sum | awk '{print substr($1,1,16)}')
9+
printf '%s-%s\n' "$seconds" "$digest"
10+
}
11+
12+
hf_sam_deploy_bucket_name() {
13+
local account_id="$1" region="$2" run_id="$3" digest
14+
digest=$(printf '%s' "$run_id" | sha256sum | awk '{print substr($1,1,20)}')
15+
printf 'hf-sam-%s-%s-%s\n' "$account_id" "$region" "$digest"
16+
}
17+
418
hf_derive_project_name() {
519
local stack_name="$1" prefix digest
620
prefix=$(printf '%s' "$stack_name" |
@@ -83,6 +97,52 @@ hf_assert_deploy_isolation() {
8397
--query "logGroups[?logGroupName=='$states_log'].logGroupName" --output text
8498
}
8599

100+
# Atomically reserve the exact stack name before SAM can create or update it.
101+
# CloudFormation's create-stack call is the compare-and-set: only one concurrent
102+
# smoke run can acquire a name that both preflight checks observed as absent.
103+
hf_reserve_smoke_stack() {
104+
local stack_name="$1" run_id="$2"
105+
aws cloudformation create-stack \
106+
--stack-name "$stack_name" \
107+
--template-body \
108+
'{"Resources":{"SmokeOwnershipHandle":{"Type":"AWS::CloudFormation::WaitConditionHandle"}}}' \
109+
--tags "Key=HyperframesSmokeRun,Value=$run_id" >/dev/null &&
110+
aws cloudformation wait stack-create-complete --stack-name "$stack_name"
111+
}
112+
113+
# Print "owned" when the stack has this run's ownership tag and
114+
# "absent" when there is no stack. Any foreign/missing tag or AWS API error
115+
# fails closed so a cleanup trap cannot delete a concurrent run's resources.
116+
hf_stack_ownership_status() {
117+
local stack_name="$1" run_id="$2" output_file error_file owner status detail
118+
output_file=$(mktemp)
119+
error_file=$(mktemp)
120+
if aws cloudformation describe-stacks \
121+
--stack-name "$stack_name" \
122+
--query "Stacks[0].Tags[?Key=='HyperframesSmokeRun'].Value | [0]" \
123+
--output text >"$output_file" 2>"$error_file"; then
124+
owner=$(tr -d '\r\n' <"$output_file")
125+
rm -f "$output_file" "$error_file"
126+
if [ "$owner" != "$run_id" ]; then
127+
echo "ERROR: refusing cleanup: stack ownership is '${owner:-missing}', expected '$run_id'" >&2
128+
return 3
129+
fi
130+
printf 'owned\n'
131+
return
132+
else
133+
status=$?
134+
fi
135+
if hf_known_absent "does not exist" "$error_file"; then
136+
rm -f "$output_file" "$error_file"
137+
printf 'absent\n'
138+
return
139+
fi
140+
detail=$(tr '\n' ' ' <"$error_file" | cut -c1-240)
141+
echo "ERROR: could not verify stack ownership (exit=$status): $detail" >&2
142+
rm -f "$output_file" "$error_file"
143+
return 2
144+
}
145+
86146
# Return a JSON object with any physical resources CloudFormation managed to
87147
# create, even when stack outputs were never populated. A genuinely absent
88148
# stack is an empty result; auth/network/query failures are errors.

examples/aws-lambda/scripts/aws-isolation.test.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ case "$MOCK_MODE:$operation" in
3737
echo "AccessDenied: credentials expired" >&2
3838
exit 253
3939
;;
40+
owned:"cloudformation describe-stacks")
41+
printf 'run-a\n'
42+
;;
43+
foreign:"cloudformation describe-stacks")
44+
printf 'run-b\n'
45+
;;
46+
ownership-missing:"cloudformation describe-stacks")
47+
echo "ValidationError: Stack with id smoke does not exist" >&2
48+
exit 255
49+
;;
50+
reserve:"cloudformation create-stack"|reserve:"cloudformation wait")
51+
exit 0
52+
;;
4053
discovery:"cloudformation list-stack-resources")
4154
cat <<'JSON'
4255
{"StackResourceSummaries":[
@@ -62,6 +75,14 @@ name_b=$(hf_derive_project_name "hyperframes-lambda-smoke-a-very-long-shared-pre
6275
[ "$name_a" != "$name_b" ]
6376
[ "${#name_a}" -le 49 ]
6477
[ "${#name_b}" -le 49 ]
78+
run_a=$(hf_new_smoke_run_id)
79+
run_b=$(hf_new_smoke_run_id)
80+
[ "$run_a" != "$run_b" ]
81+
bucket_a=$(hf_sam_deploy_bucket_name "767398024897" "us-east-2" "$run_a")
82+
bucket_b=$(hf_sam_deploy_bucket_name "767398024897" "us-east-2" "$run_b")
83+
[ "$bucket_a" != "$bucket_b" ]
84+
[ "${#bucket_a}" -le 63 ]
85+
[ "${#bucket_b}" -le 63 ]
6586

6687
MOCK_MODE=absent PATH="$WORK/bin:$PATH" \
6788
hf_assert_deploy_isolation "smoke" "$name_a"
@@ -79,6 +100,19 @@ if MOCK_MODE=auth PATH="$WORK/bin:$PATH" \
79100
fi
80101
grep -q "could not prove" "$WORK/auth-error"
81102

103+
[ "$(MOCK_MODE=owned PATH="$WORK/bin:$PATH" \
104+
hf_stack_ownership_status "smoke" "run-a")" = "owned" ]
105+
[ "$(MOCK_MODE=ownership-missing PATH="$WORK/bin:$PATH" \
106+
hf_stack_ownership_status "smoke" "run-a")" = "absent" ]
107+
if MOCK_MODE=foreign PATH="$WORK/bin:$PATH" \
108+
hf_stack_ownership_status "smoke" "run-a" 2>"$WORK/foreign-error"; then
109+
echo "expected foreign stack ownership to fail closed" >&2
110+
exit 1
111+
fi
112+
grep -q "refusing cleanup" "$WORK/foreign-error"
113+
MOCK_MODE=reserve PATH="$WORK/bin:$PATH" \
114+
hf_reserve_smoke_stack "smoke" "run-a"
115+
82116
discovered=$(MOCK_MODE=discovery PATH="$WORK/bin:$PATH" \
83117
hf_discover_stack_resources "smoke")
84118
[ "$(jq -r .renderBucket <<<"$discovered")" = "physical-render-bucket" ]

examples/aws-lambda/scripts/smoke.sh

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
# --fixture <name> (default: mp4-h264-sdr)
2828
# --chunk-counts <list> (default: 2,4,8)
2929
# --psnr-threshold <db> (default: fixture meta.json minPsnr)
30-
# --stack-name <name> (default: hyperframes-lambda-smoke-<timestamp>)
30+
# --stack-name <name> (default: hyperframes-lambda-smoke-<unique-run-id>)
3131
# --region <region> (default: $AWS_REGION or us-east-1)
3232
# --profile <name> (default: $AWS_PROFILE, otherwise the AWS
3333
# default profile resolution chain)
@@ -78,7 +78,8 @@ CHUNK_COUNTS="${CHUNK_COUNTS:-2,4,8}"
7878
# calibrated for that content/runtime boundary. Override it via
7979
# --psnr-threshold (or PSNR_THRESHOLD) for a stricter experiment.
8080
PSNR_THRESHOLD="${PSNR_THRESHOLD-}"
81-
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-$(date +%s)}"
81+
SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}"
82+
STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}"
8283
AWS_REGION="${AWS_REGION:-us-east-1}"
8384
AWS_PROFILE="${AWS_PROFILE:-}"
8485
PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}"
@@ -106,7 +107,7 @@ Flags:
106107
--fixture <name> fixture under packages/producer/tests/distributed/ (default: mp4-h264-sdr)
107108
--chunk-counts <list> comma-separated chunk counts to benchmark (default: 2,4,8)
108109
--psnr-threshold <db> PSNR floor (default: fixture meta.json minPsnr)
109-
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<timestamp>)
110+
--stack-name <name> SAM stack name (default: hyperframes-lambda-smoke-<unique-run-id>)
110111
--region <region> AWS region (default: $AWS_REGION or us-east-1)
111112
--profile <name> AWS profile (default: $AWS_PROFILE)
112113
--plan-protocol <v1|v2|both> plan transport(s) to compare (default: v1)
@@ -241,42 +242,57 @@ cleanup_and_exit() {
241242
else
242243
echo "→ Tearing down stack $STACK_NAME"
243244
local cleanup_identity_ok=true
245+
local stack_cleanup_allowed=false
246+
local ownership_status=""
244247
local discovery_errors=()
245248
if ! aws sts get-caller-identity >/dev/null; then
246249
cleanup_identity_ok=false
247250
echo "ERROR: AWS identity check failed before cleanup; absence cannot be trusted" >&2
248251
fi
249-
local discovered
250-
if discovered=$(hf_discover_stack_resources "$STACK_NAME"); then
251-
if [ -z "$BUCKET" ]; then
252-
BUCKET=$(jq -r '.renderBucket' <<<"$discovered")
252+
if [ "$cleanup_identity_ok" = true ]; then
253+
if ownership_status=$(hf_stack_ownership_status "$STACK_NAME" "$SMOKE_RUN_ID"); then
254+
if [ "$ownership_status" = "owned" ]; then
255+
stack_cleanup_allowed=true
256+
else
257+
echo "→ Stack is absent; skipping stack-scoped destructive cleanup"
258+
fi
259+
else
260+
discovery_errors+=("verification-error:cloudformation-stack-ownership")
253261
fi
254-
if [ -z "$STATE_MACHINE_ARN" ]; then
255-
STATE_MACHINE_ARN=$(jq -r '.stateMachineArn' <<<"$discovered")
256-
fi
257-
else
258-
discovery_errors+=("verification-error:cloudformation-resource-discovery")
259262
fi
260-
if [ -n "$BUCKET" ]; then
261-
if ! hf_delete_s3_bucket_completely "$BUCKET"; then
262-
echo "WARN: failed to purge/delete retained render bucket s3://$BUCKET" >&2
263+
if [ "$stack_cleanup_allowed" = true ]; then
264+
local discovered
265+
if discovered=$(hf_discover_stack_resources "$STACK_NAME"); then
266+
if [ -z "$BUCKET" ]; then
267+
BUCKET=$(jq -r '.renderBucket' <<<"$discovered")
268+
fi
269+
if [ -z "$STATE_MACHINE_ARN" ]; then
270+
STATE_MACHINE_ARN=$(jq -r '.stateMachineArn' <<<"$discovered")
271+
fi
272+
else
273+
discovery_errors+=("verification-error:cloudformation-resource-discovery")
263274
fi
264-
fi
265-
if ! (cd "$SAM_DIR" && sam delete \
266-
--stack-name "$STACK_NAME" \
267-
--region "$AWS_REGION" \
268-
--no-prompts); then
269-
echo "WARN: sam delete failed for $STACK_NAME" >&2
270-
fi
271-
if ! aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"; then
272-
echo "WARN: CloudFormation did not confirm stack deletion for $STACK_NAME" >&2
273-
fi
274-
if aws logs describe-log-groups \
275-
--log-group-name-prefix "/aws/lambda/${PROJECT_NAME}-render" \
276-
--query "logGroups[?logGroupName=='/aws/lambda/${PROJECT_NAME}-render'].logGroupName" \
277-
--output text | grep -q .; then
278-
if ! aws logs delete-log-group --log-group-name "/aws/lambda/${PROJECT_NAME}-render"; then
279-
echo "WARN: failed to delete Lambda log group" >&2
275+
if [ -n "$BUCKET" ]; then
276+
if ! hf_delete_s3_bucket_completely "$BUCKET"; then
277+
echo "WARN: failed to purge/delete retained render bucket s3://$BUCKET" >&2
278+
fi
279+
fi
280+
if ! (cd "$SAM_DIR" && sam delete \
281+
--stack-name "$STACK_NAME" \
282+
--region "$AWS_REGION" \
283+
--no-prompts); then
284+
echo "WARN: sam delete failed for $STACK_NAME" >&2
285+
fi
286+
if ! aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"; then
287+
echo "WARN: CloudFormation did not confirm stack deletion for $STACK_NAME" >&2
288+
fi
289+
if aws logs describe-log-groups \
290+
--log-group-name-prefix "/aws/lambda/${PROJECT_NAME}-render" \
291+
--query "logGroups[?logGroupName=='/aws/lambda/${PROJECT_NAME}-render'].logGroupName" \
292+
--output text | grep -q .; then
293+
if ! aws logs delete-log-group --log-group-name "/aws/lambda/${PROJECT_NAME}-render"; then
294+
echo "WARN: failed to delete Lambda log group" >&2
295+
fi
280296
fi
281297
fi
282298
if [ -n "$SAM_DEPLOY_BUCKET" ]; then
@@ -382,18 +398,25 @@ if ! aws sts get-caller-identity --output text >/dev/null 2>&1; then
382398
exit 1
383399
fi
384400

385-
# This check runs before cleanup is armed or any resource is created. A name
386-
# collision must never route through cleanup, because cleanup is destructive.
401+
# This check runs before cleanup is armed or any resource is created.
387402
echo "→ Pre-flight: proving exact AWS resource names are unused"
388403
if ! hf_assert_deploy_isolation "$STACK_NAME" "$PROJECT_NAME"; then
389404
echo "ERROR: refusing to reuse or clean resources not created by this run." >&2
390405
exit 1
391406
fi
392407

393-
# From this point onward, unexpected failures must tear down resources created
394-
# by this exact, preflight-isolated run.
408+
# Arm cleanup before atomically reserving the stack name. If another smoke run
409+
# wins the create-stack race, ownership verification prevents this run from
410+
# touching it. If this run wins, every later destructive action requires the
411+
# same ownership tag.
395412
trap 'cleanup_and_exit $?' EXIT
396413

414+
echo "→ Pre-flight: atomically reserving stack name for this smoke run"
415+
if ! hf_reserve_smoke_stack "$STACK_NAME" "$SMOKE_RUN_ID"; then
416+
echo "ERROR: could not reserve stack name; another run may have won the race." >&2
417+
cleanup_and_exit 1
418+
fi
419+
397420
mkdir -p "$ARTIFACT_DIR/renders"
398421

399422
# ── 1. Build the handler ZIP ──────────────────────────────────────────────
@@ -419,7 +442,7 @@ echo "→ SAM deploy (stack=$STACK_NAME, region=$AWS_REGION)"
419442
# at its default makes concurrent smoke stacks overwrite/collide. A dedicated
420443
# SAM bucket also lets teardown remove every object created by this run.
421444
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
422-
SAM_DEPLOY_BUCKET="hf-sam-${ACCOUNT_ID}-${AWS_REGION}-$(date +%s)-$$"
445+
SAM_DEPLOY_BUCKET=$(hf_sam_deploy_bucket_name "$ACCOUNT_ID" "$AWS_REGION" "$SMOKE_RUN_ID")
423446
if [ "$AWS_REGION" = "us-east-1" ]; then
424447
aws s3api create-bucket --bucket "$SAM_DEPLOY_BUCKET" >/dev/null
425448
else
@@ -434,6 +457,7 @@ if ! (cd "$SAM_DIR" && sam deploy \
434457
--capabilities CAPABILITY_IAM \
435458
--no-confirm-changeset \
436459
--no-fail-on-empty-changeset \
460+
--tags "HyperframesSmokeRun=$SMOKE_RUN_ID" \
437461
--parameter-overrides \
438462
"ProjectName=$PROJECT_NAME" \
439463
ChromeSource=sparticuz \

0 commit comments

Comments
 (0)