1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197#!/usr/bin/env bash
#
# Tears down the AWS infrastructure Terraform provisioned (infrastructure/),
# to stop it accruing charges. See "Cleanup" in QUICKSTART.md.
#
# THIS PERMANENTLY DELETES ALL SCAN DATA, AUDITS, AND USERS. Only run this
# against a disposable test/eval stack.
#
# Usage: scripts/destroy-app.sh [--yes] [--skip-final-snapshot] [--destroy-bootstrap]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
INFRA_DIR="$REPO_ROOT/infrastructure"
AUTO_YES=0
SKIP_FINAL_SNAPSHOT=0
DESTROY_BOOTSTRAP=0
usage() {
cat <<EOF
Usage: $0 [options]
Destroys the main Terraform stack (RDS, Hasura/ECS, Lambdas, bastion,
VPC/NAT, S3+CloudFront x2, Cognito, Secrets Manager) plus the Chromium
Lambda layer (published out-of-band by deploy-app.sh, not Terraform-
managed). Permanently deletes all data.
Options:
--yes Skip the interactive confirmation prompt and pass
-auto-approve to terraform destroy (for CI/scripted use)
--skip-final-snapshot Skip RDS's final snapshot (terraform -var db_skip_final_snapshot=true)
--destroy-bootstrap Also tear down the bootstrap remote-state stack
(S3 state bucket + DynamoDB lock table) afterward.
Its ongoing cost is a few cents/month; most people
leave it. Once destroyed, this Terraform config can
no longer manage the stack it just tore down. Safe
to run on its own (e.g. --yes --destroy-bootstrap
with nothing else) if the main stack is already gone.
-h, --help Show this help
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--yes) AUTO_YES=1; shift ;;
--skip-final-snapshot) SKIP_FINAL_SNAPSHOT=1; shift ;;
--destroy-bootstrap) DESTROY_BOOTSTRAP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$1"; }
warn() { printf '\033[1;33m%s\033[0m\n' "$1"; }
die() { echo "Error: $1" >&2; exit 1; }
require() {
command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not found in PATH. $2"
}
require terraform "Install: https://developer.hashicorp.com/terraform/install"
require aws "Install: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
require jq "Install: apt install jq / brew install jq"
log "Reading Terraform state"
cd "$INFRA_DIR"
# Check state directly, not a specific output: after an interrupted destroy
# retry (credential expiry, etc.), resources destroyed earlier in that run
# โ including ones an output like lambda_function_names depends on โ are
# already gone from state, which made that output vanish while dozens of
# other resources (RDS, ECS, VPC, ...) were still very much alive and
# costing money. Whether *anything* remains is the only question that
# actually matters here.
MAIN_STATE_EMPTY=0
[ -n "$(terraform state list)" ] || MAIN_STATE_EMPTY=1
if [ "$MAIN_STATE_EMPTY" = 1 ] && [ "$DESTROY_BOOTSTRAP" = 0 ]; then
die "Terraform state is empty โ nothing to destroy."
fi
if [ "$MAIN_STATE_EMPTY" = 1 ]; then
log "Main stack's state is already empty โ skipping straight to --destroy-bootstrap"
else
TF_OUT="$(terraform output -json)"
out() { echo "$TF_OUT" | jq -r "$1"; }
FRONTEND_BUCKET="$(out '.frontend_bucket_name.value')"
FRONTEND_URL="$(out '.frontend_url.value')"
RDS_INSTANCE_ID="$(out '.rds_instance_id.value')"
DB_PASSWORD_SECRET_ARN="$(out '.db_password_secret_arn.value')"
HASURA_ADMIN_SECRET_ARN="$(out '.hasura_admin_secret_arn.value')"
WEBHOOK_SECRET_ARN="$(out '.webhook_secret_arn.value')"
SSO_CONFIG_SECRET_ARN="$(out '.sso_config_secret_arn.value')"
SCAN_HTML_FN="$(out '.lambda_function_names.value.scan_html')"
warn "This will PERMANENTLY DESTROY the Equalify stack at:"
warn " $FRONTEND_URL"
warn "All scan data, audits, and users will be unrecoverable."
if [ "$AUTO_YES" = 0 ]; then
read -r -p 'Type "destroy" to continue: ' CONFIRM
[ "$CONFIRM" = "destroy" ] || die "Confirmation did not match. Aborted, nothing was destroyed."
fi
log "Emptying frontend S3 bucket: $FRONTEND_BUCKET"
if [ -n "$FRONTEND_BUCKET" ] && [ "$FRONTEND_BUCKET" != "null" ]; then
aws s3 rm "s3://$FRONTEND_BUCKET" --recursive || true
fi
log "Disabling RDS deletion protection: $RDS_INSTANCE_ID"
aws rds modify-db-instance --db-instance-identifier "$RDS_INSTANCE_ID" \
--no-deletion-protection --apply-immediately >/dev/null
echo "Waiting for $RDS_INSTANCE_ID to leave 'modifying' state..."
for _ in $(seq 1 60); do
STATUS="$(aws rds describe-db-instances --db-instance-identifier "$RDS_INSTANCE_ID" \
--query 'DBInstances[0].DBInstanceStatus' --output text)"
[ "$STATUS" != "modifying" ] && break
sleep 10
done
log "Running terraform destroy"
DESTROY_ARGS=()
[ "$SKIP_FINAL_SNAPSHOT" = 1 ] && DESTROY_ARGS+=(-var db_skip_final_snapshot=true)
[ "$AUTO_YES" = 1 ] && DESTROY_ARGS+=(-auto-approve)
if [ "${#DESTROY_ARGS[@]}" -eq 0 ]; then
terraform destroy
else
terraform destroy "${DESTROY_ARGS[@]}"
fi
log "Force-deleting Secrets Manager entries (skipping their 7-day recovery window)"
for arn in "$DB_PASSWORD_SECRET_ARN" "$HASURA_ADMIN_SECRET_ARN" "$WEBHOOK_SECRET_ARN" "$SSO_CONFIG_SECRET_ARN"; do
[ -n "$arn" ] && [ "$arn" != "null" ] || continue
aws secretsmanager delete-secret --secret-id "$arn" --force-delete-without-recovery >/dev/null 2>&1 || true
done
# scripts/deploy-app.sh publishes this out-of-band via `aws lambda
# publish-layer-version` โ it's not a Terraform resource (the function's
# `layers` attribute is in lifecycle.ignore_changes precisely so Terraform
# never fights that), so `terraform destroy` above has no idea it exists
# and would otherwise leave every version of it orphaned in the account
# indefinitely.
if [ -n "$SCAN_HTML_FN" ] && [ "$SCAN_HTML_FN" != "null" ]; then
CHROMIUM_LAYER_NAME="${SCAN_HTML_FN}-chromium"
log "Deleting Chromium layer versions: $CHROMIUM_LAYER_NAME (not Terraform-managed)"
VERSIONS="$(aws lambda list-layer-versions --layer-name "$CHROMIUM_LAYER_NAME" \
--query 'LayerVersions[].Version' --output text 2>/dev/null || true)"
for v in $VERSIONS; do
aws lambda delete-layer-version --layer-name "$CHROMIUM_LAYER_NAME" --version-number "$v" >/dev/null 2>&1 || true
done
fi
fi
if [ "$DESTROY_BOOTSTRAP" = 1 ]; then
log "Tearing down the bootstrap remote-state stack"
cd "$INFRA_DIR/bootstrap"
BOOT_OUT="$(terraform output -json)"
STATE_BUCKET="$(echo "$BOOT_OUT" | jq -r '.state_bucket_name.value')"
if [ "$AUTO_YES" = 0 ]; then
warn "This also deletes the Terraform state bucket ($STATE_BUCKET) and lock table."
warn "This config will no longer be able to manage the stack just destroyed."
read -r -p 'Type "destroy bootstrap" to continue: ' CONFIRM2
[ "$CONFIRM2" = "destroy bootstrap" ] || die "Confirmation did not match. Bootstrap stack left in place."
fi
# aws_s3_bucket.terraform_state has prevent_destroy=true, so it can't be
# destroyed via terraform. Untrack it (this does NOT touch the real
# bucket) so `terraform destroy` can remove everything else, then delete
# the bucket by hand below.
terraform state rm \
aws_s3_bucket.terraform_state \
aws_s3_bucket_versioning.terraform_state \
aws_s3_bucket_server_side_encryption_configuration.terraform_state \
aws_s3_bucket_public_access_block.terraform_state \
>/dev/null
if [ "$AUTO_YES" = 1 ]; then
terraform destroy -auto-approve
else
terraform destroy
fi
echo "Emptying and deleting state bucket: $STATE_BUCKET"
aws s3api list-object-versions --bucket "$STATE_BUCKET" --output json \
| jq -r '(.Versions // [])[], (.DeleteMarkers // [])[] | "\(.Key)\t\(.VersionId)"' \
| while IFS=$'\t' read -r key vid; do
aws s3api delete-object --bucket "$STATE_BUCKET" --key "$key" --version-id "$vid" >/dev/null
done
aws s3api delete-bucket --bucket "$STATE_BUCKET"
fi
log "Done."