seahaven-org-baseline/scripts/cfn-stack-decommission.sh

96 lines
5.1 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
#
# cfn-stack-decommission.sh — safely retire a CloudFormation/CDK stack.
#
# Reports first (default), acts only with --execute. The value is the pre-flight:
# it predicts what will ORPHAN (DeletionPolicy: Retain resources survive a stack
# delete) and what will BLOCK the delete (consumed exports, non-empty buckets),
# so you don't discover surviving tables/buckets after the fact.
#
# Built from the Day 4 LedgerFlow decommission, where 4 of 5 DynamoDB tables +
# 2 of 3 S3 buckets were RemovalPolicy.RETAIN and orphaned. See feedback memory
# `feedback_cfn_decommission_and_remediation`.
#
# Usage:
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
# scripts/cfn-stack-decommission.sh --account-id ID [--profile NAME] [--execute] STACK
#
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
# --account-id REQUIRED. Asserted against the credentials' actual account
# before anything runs. Stack names are NOT org-unique
# (seahaven-dynamodb-cmk exists in both mgmt and prod), so a
# name-only lookup with the wrong ambient profile would
# report — or with --execute, DELETE — the wrong account's
# stack and then purge its Retain orphans.
# (no --execute) REPORT only: termination protection, consumed exports,
# Retain resources (orphans-to-be), in-stack S3 buckets.
# --execute Disable termination protection, empty Delete-policy buckets,
# delete the stack, wait, then delete the Retain orphans.
#
set -euo pipefail
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
PROFILE_ARG=(); EXECUTE=0; STACK=""; EXPECTED_ACCOUNT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--profile) PROFILE_ARG=(--profile "$2"); shift 2 ;;
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
--account-id) EXPECTED_ACCOUNT="$2"; shift 2 ;;
--execute) EXECUTE=1; shift ;;
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*) STACK="$1"; shift ;;
esac
done
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
[[ -z "$STACK" ]] && { echo "usage: $0 --account-id ID [--profile NAME] [--execute] STACK" >&2; exit 2; }
[[ -z "$EXPECTED_ACCOUNT" ]] && { echo "ERROR: --account-id is required (stack names are not org-unique)." >&2; exit 2; }
aws_() { aws "${PROFILE_ARG[@]}" "$@"; }
R="us-east-1"
feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys.
2026-07-23 15:29:55 -04:00
CALLER_ACCOUNT="$(aws_ sts get-caller-identity --query Account --output text)"
if [[ "$CALLER_ACCOUNT" != "$EXPECTED_ACCOUNT" ]]; then
echo "ABORT: credentials resolve to account $CALLER_ACCOUNT, expected $EXPECTED_ACCOUNT." >&2
exit 1
fi
echo "== account: $CALLER_ACCOUNT (verified) =="
echo "== stack: $STACK =="
aws_ cloudformation describe-stacks --stack-name "$STACK" --region "$R" \
--query 'Stacks[0].{Status:StackStatus,TermProt:EnableTerminationProtection}' --output table
echo "-- consumed exports (any import BLOCKS the delete) --"
BLOCKED=0
for e in $(aws_ cloudformation list-exports --region "$R" \
--query "Exports[?ExportingStackId && contains(ExportingStackId,':stack/$STACK/')].Name" --output text 2>/dev/null); do
imp=$(aws_ cloudformation list-imports --export-name "$e" --region "$R" --query 'Imports' --output text 2>/dev/null || true)
if [[ -n "$imp" && "$imp" != "None" ]]; then echo " BLOCK: export $e imported by: $imp"; BLOCKED=1; fi
done
[[ $BLOCKED -eq 0 ]] && echo " none"
echo "-- DeletionPolicy: Retain resources (these ORPHAN, survive the delete) --"
TMP="$(aws_ cloudformation get-template --stack-name "$STACK" --region "$R" --query TemplateBody --output json)"
echo "$TMP" | python3 -c '
import json,sys
res=json.load(sys.stdin).get("Resources",{})
orphans=[(r.get("Type"),lid,r.get("Properties",{}).get("TableName") or r.get("Properties",{}).get("BucketName") or "")
for lid,r in res.items() if r.get("DeletionPolicy")=="Retain"]
[print(f" {t:<28} {lid} {name}") for t,lid,name in sorted(orphans)] or print(" none")
'
echo "-- in-stack S3 buckets (non-empty Delete-policy buckets block; check auto-delete) --"
for b in $(aws_ cloudformation list-stack-resources --stack-name "$STACK" --region "$R" \
--query "StackResourceSummaries[?ResourceType=='AWS::S3::Bucket'].PhysicalResourceId" --output text 2>/dev/null); do
n=$(aws_ s3api list-objects-v2 --bucket "$b" --max-items 1 --query 'KeyCount' --output text 2>/dev/null || echo "?")
v=$(aws_ s3api get-bucket-versioning --bucket "$b" --query 'Status' --output text 2>/dev/null || echo "-")
echo " $b objects~=$n versioning=$v"
done
if [[ $EXECUTE -eq 0 ]]; then
echo; echo "REPORT ONLY. Re-run with --execute to delete (after reviewing the orphans + blocks above)."
exit 0
fi
[[ $BLOCKED -eq 1 ]] && { echo "ABORT: a consumed export blocks the delete (see above)." >&2; exit 1; }
read -r -p "EXECUTE decommission of '$STACK'? [y/N] " ans; [[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; exit 0; }
aws_ cloudformation update-termination-protection --stack-name "$STACK" --no-enable-termination-protection --region "$R" >/dev/null 2>&1 || true
echo "deleting stack..."
aws_ cloudformation delete-stack --stack-name "$STACK" --region "$R"
aws_ cloudformation wait stack-delete-complete --stack-name "$STACK" --region "$R"
echo "stack deleted. Review the Retain orphans above and remove them with delete-table / delete-bucket"
echo "(versioned buckets: purge all versions + delete-markers first — see the iam-user-delete sibling pattern)."