#!/usr/bin/env python3 """Emit a standalone teardown.sh from created_resources.json. The skill NEVER runs the generated teardown.sh — it is handed back to the user for review and manual invocation. The emitted script has its own safety layer: caller-identity check against the manifest's account, prefix guard on every delete, explicit --confirm requirement, --dry-run support, optional --delete-logs for CloudWatch log groups. Covers: - DynamoDB tables (bench-only config leaves DeletionProtection off — no two-step disable needed). - Lambda function. - IAM role (inline policy delete → role delete). - CloudWatch log groups (optional, off by default so Lambda logs survive for post-mortem). Usage: python3 generate_teardown.py \\ --manifest created_resources.json \\ --out teardown.sh """ from __future__ import annotations import argparse import json import sys from pathlib import Path TEMPLATE = r"""#!/usr/bin/env bash # Teardown script generated by amazon-dynamodb/scripts/generate_teardown.py. # # Deletes every resource created by one ddb-skill-bench run. Safe by default: # - Refuses unless --confirm is passed. # - Refuses if aws sts get-caller-identity returns a different account than # the manifest used at deploy time. # - Only touches resources whose name starts with the manifest's prefix. # - --dry-run lists what would be deleted without actually deleting. # - --delete-logs also deletes the Lambda CloudWatch log group (off by # default; logs are useful for post-mortem). # # Regenerate from manifest with: # python3 amazon-dynamodb/scripts/generate_teardown.py \ # --manifest created_resources.json --out teardown.sh set -euo pipefail MANIFEST_ACCOUNT='{account}' MANIFEST_REGION='{region}' MANIFEST_PREFIX='{prefix}' MANIFEST_RUN_ID='{run_id}' AWS_PROFILE_HINT='{profile_hint}' # Default AWS_PROFILE to the one the deploy used, so teardown is self-contained # (no need to export it first). An already-set AWS_PROFILE still wins. if [ -z "${{AWS_PROFILE:-}}" ] && [ -n "$AWS_PROFILE_HINT" ]; then export AWS_PROFILE="$AWS_PROFILE_HINT" fi TABLES=({tables_bash_array}) LAMBDA_FN='{lambda_fn}' LAMBDA_ROLE='{lambda_role}' LAMBDA_POLICY='{lambda_policy}' LAMBDA_LOG_GROUP='{lambda_log_group}' BANNER_SEP=$(printf '=%.0s' $(seq 1 72)) print_banner() {{ echo "$BANNER_SEP" echo "ddb-skill-bench teardown" echo " Account expected (from manifest): $MANIFEST_ACCOUNT" echo " Region: $MANIFEST_REGION" echo " Prefix: $MANIFEST_PREFIX" echo " Run ID: $MANIFEST_RUN_ID" echo " Tables: ${{#TABLES[@]}}" for t in "${{TABLES[@]}}"; do echo " - $t"; done if [[ -n "$LAMBDA_FN" ]]; then echo " Lambda: $LAMBDA_FN" fi if [[ -n "$LAMBDA_ROLE" ]]; then echo " IAM role: $LAMBDA_ROLE (policy: $LAMBDA_POLICY)" fi echo "$BANNER_SEP" }} DRY_RUN=0 CONFIRM=0 DELETE_LOGS=0 for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; --confirm) CONFIRM=1 ;; --delete-logs) DELETE_LOGS=1 ;; -h|--help) echo "Usage: $0 --confirm [--dry-run] [--delete-logs]" echo " --confirm required; actually delete resources" echo " --dry-run list what would be deleted; call no delete APIs" echo " --delete-logs also delete the Lambda CloudWatch log group" exit 0 ;; *) echo "unknown arg: $arg" >&2; exit 2 ;; esac done print_banner if [[ $CONFIRM -ne 1 ]]; then echo "Refusing to run without --confirm." >&2 echo "Pass --dry-run first to preview, then re-run with --confirm." >&2 exit 2 fi # --- Identity check --- caller_json=$(aws sts get-caller-identity --region "$MANIFEST_REGION" 2>&1) || {{ echo "aws sts get-caller-identity failed:" >&2 echo "$caller_json" >&2 echo "Set AWS_PROFILE (hint: $AWS_PROFILE_HINT) or run: aws sso login --profile $AWS_PROFILE_HINT" >&2 exit 2 }} caller_account=$(echo "$caller_json" | python3 -c "import sys, json; print(json.load(sys.stdin)['Account'])") if [[ "$caller_account" != "$MANIFEST_ACCOUNT" ]]; then echo "Account mismatch:" >&2 echo " current caller identity: $caller_account" >&2 echo " manifest expected: $MANIFEST_ACCOUNT" >&2 echo "Refusing to delete — wrong account." >&2 exit 2 fi echo "Identity check passed: account $caller_account matches manifest." # --- DynamoDB tables --- for table in "${{TABLES[@]}}"; do case "$table" in "$MANIFEST_PREFIX"*) ;; *) echo "SKIP: $table does not start with prefix $MANIFEST_PREFIX" >&2 continue ;; esac if [[ $DRY_RUN -eq 1 ]]; then echo "DRY-RUN: would delete table $table" continue fi echo "-> table: $table" if aws dynamodb delete-table \ --table-name "$table" \ --region "$MANIFEST_REGION" >/dev/null 2>&1; then # DeleteTable is ASYNCHRONOUS: a 200 means the delete was ACCEPTED, the # table then sits in DELETING for a short while. Wait until it is actually # gone before reporting success, so "deleted" matches reality and a caller # who re-checks immediately doesn't see a lingering DELETING table. printf " deleting" for _ in $(seq 1 60); do if aws dynamodb describe-table --table-name "$table" \ --region "$MANIFEST_REGION" >/dev/null 2>&1; then printf "." sleep 2 else break fi done if aws dynamodb describe-table --table-name "$table" --region "$MANIFEST_REGION" >/dev/null 2>&1; then echo " STILL PRESENT after wait — aborting" >&2 exit 1 fi echo " deleted" else # ResourceNotFound is tolerable (already deleted); anything else is fatal. if aws dynamodb describe-table --table-name "$table" --region "$MANIFEST_REGION" >/dev/null 2>&1; then echo " FAILED; table still exists — aborting" >&2 exit 1 else echo " already gone" fi fi done # --- Lambda function --- if [[ -n "$LAMBDA_FN" ]]; then case "$LAMBDA_FN" in "$MANIFEST_PREFIX"*) ;; *) echo "SKIP: lambda $LAMBDA_FN does not start with prefix $MANIFEST_PREFIX" >&2 LAMBDA_FN="" ;; esac fi if [[ -n "$LAMBDA_FN" ]]; then if [[ $DRY_RUN -eq 1 ]]; then echo "DRY-RUN: would delete Lambda $LAMBDA_FN" else echo "-> lambda: $LAMBDA_FN" aws lambda delete-function \ --function-name "$LAMBDA_FN" \ --region "$MANIFEST_REGION" >/dev/null 2>&1 || echo " already gone" echo " deleted" fi fi # --- IAM role (inline policy first, then role) --- if [[ -n "$LAMBDA_ROLE" ]]; then case "$LAMBDA_ROLE" in "$MANIFEST_PREFIX"*) ;; *) echo "SKIP: role $LAMBDA_ROLE does not start with prefix $MANIFEST_PREFIX" >&2 LAMBDA_ROLE="" ;; esac fi if [[ -n "$LAMBDA_ROLE" ]]; then if [[ $DRY_RUN -eq 1 ]]; then echo "DRY-RUN: would delete IAM role $LAMBDA_ROLE (policy $LAMBDA_POLICY)" else echo "-> iam role: $LAMBDA_ROLE" if [[ -n "$LAMBDA_POLICY" ]]; then aws iam delete-role-policy \ --role-name "$LAMBDA_ROLE" \ --policy-name "$LAMBDA_POLICY" >/dev/null 2>&1 || true fi aws iam delete-role --role-name "$LAMBDA_ROLE" >/dev/null 2>&1 || echo " already gone" echo " deleted" fi fi # --- CloudWatch log group (optional) --- if [[ $DELETE_LOGS -eq 1 && -n "$LAMBDA_LOG_GROUP" ]]; then case "$LAMBDA_LOG_GROUP" in /aws/lambda/"$MANIFEST_PREFIX"*) ;; *) echo "SKIP: log group $LAMBDA_LOG_GROUP does not match prefix" >&2 LAMBDA_LOG_GROUP="" ;; esac fi if [[ $DELETE_LOGS -eq 1 && -n "$LAMBDA_LOG_GROUP" ]]; then if [[ $DRY_RUN -eq 1 ]]; then echo "DRY-RUN: would delete log group $LAMBDA_LOG_GROUP" else echo "-> log group: $LAMBDA_LOG_GROUP" aws logs delete-log-group \ --log-group-name "$LAMBDA_LOG_GROUP" \ --region "$MANIFEST_REGION" >/dev/null 2>&1 || echo " already gone" echo " deleted" fi fi if [[ $DRY_RUN -eq 1 ]]; then echo "Dry run complete — no resources deleted." else echo "Teardown complete. Verify in the console that no ddb-skill-bench" echo "resources remain under prefix $MANIFEST_PREFIX." fi """ def _die(msg: str, code: int = 2) -> None: print(f"ERROR: {msg}", file=sys.stderr) sys.exit(code) def main(): p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--manifest", required=True, help="path to created_resources.json") p.add_argument("--out", required=True, help="path to write teardown.sh") args = p.parse_args() mp = Path(args.manifest) if not mp.exists(): _die(f"manifest not found: {mp}") manifest = json.loads(mp.read_text()) prefix = manifest.get("prefix") if not prefix or not prefix.startswith("ddb-skill-bench-"): _die( f"manifest prefix {prefix!r} is missing or does not start with " "'ddb-skill-bench-'. Refusing to generate teardown." ) tables = [t.get("name") for t in (manifest.get("tables") or []) if t.get("name")] bad = [t for t in tables if not t.startswith(prefix)] if bad: _die( f"manifest contains tables whose names don't match prefix " f"{prefix!r}: {bad}. Refusing to generate teardown." ) lam = manifest.get("lambda") or {} lambda_fn = lam.get("function_name", "") lambda_role = lam.get("role_name", "") lambda_policy = lam.get("policy_name", "") if lambda_fn and not lambda_fn.startswith(prefix): _die( f"manifest lambda function_name {lambda_fn!r} does not start with " f"prefix {prefix!r}. Refusing." ) if lambda_role and not lambda_role.startswith(prefix): _die( f"manifest lambda role_name {lambda_role!r} does not start with " f"prefix {prefix!r}. Refusing." ) lambda_log_group = f"/aws/lambda/{lambda_fn}" if lambda_fn else "" tables_bash_array = " ".join(f"'{t}'" for t in tables) # Use the AWS CLI profile recorded at deploy time so the sso-login hint # in the teardown script points at the right profile, not the IAM account # alias (which is a different concept). content = TEMPLATE.format( account=manifest.get("account", ""), region=manifest.get("region", ""), prefix=prefix, run_id=manifest.get("run_id", ""), profile_hint=manifest.get("aws_profile") or manifest.get("alias") or "", tables_bash_array=tables_bash_array, lambda_fn=lambda_fn, lambda_role=lambda_role, lambda_policy=lambda_policy, lambda_log_group=lambda_log_group, ) out = Path(args.out) out.write_text(content) out.chmod(0o755) print(f"Teardown script written to: {out}") print() print("Review the script, then:") print(f" bash {out} --dry-run # preview — calls no delete APIs") print(f" bash {out} --confirm # delete the tables, Lambda, and IAM role") print( f" bash {out} --confirm --delete-logs # also delete the Lambda's " "CloudWatch log group (its run logs are gone for good)" ) if __name__ == "__main__": main()