From 8bf9c409c3fd9f23ac07dfc48ebc6f37aade9a8e Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Tue, 25 Aug 2026 15:38:33 +0000 Subject: [PATCH 1/7] fix: warn before removing shared BPA exclusion Warn during destructive review when a stack-owned VPC BPA exclusion may be removed while another managed deployment remains in the VPC. Retained reused-VPC exclusions and unshared VPCs stay silent. Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 90 +++++++++++++++++++++++++++++++++++++ uninstall.sh | 73 ++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/test-uninstall-bpa.sh diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh new file mode 100644 index 0000000..8aa057f --- /dev/null +++ b/tests/test-uninstall-bpa.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Regression tests for uninstall.sh's shared-VPC BPA warning. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UNINSTALL="${UNINSTALL_OVERRIDE:-${ROOT}/uninstall.sh}" +PASS=0 +FAIL=0 + +pass() { + printf '✓ %s\n' "$1" + PASS=$((PASS + 1)) +} + +fail() { + printf '✗ %s\n' "$1" >&2 + FAIL=$((FAIL + 1)) +} + +assert_contains() { + local name="$1" expected="$2" actual="$3" + if grep -Fq -- "$expected" <<< "$actual"; then + pass "$name" + else + fail "$name (missing: ${expected})" + fi +} + +assert_not_contains() { + local name="$1" unexpected="$2" actual="$3" + if grep -Fq -- "$unexpected" <<< "$actual"; then + fail "$name (unexpected: ${unexpected})" + else + pass "$name" + fi +} + +# Source the functions without running uninstall.sh's main entrypoint. +# shellcheck disable=SC1090 +source <(sed '$d' "$UNINSTALL") + +SCAN_REGION="us-east-1" +VPC_IDS=("vpc-target") +WATERMARKS=("target") + +aws() { + local service="$1" operation="$2" + case "${FAKE_SCENARIO}:${service}:${operation}" in + shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks) + printf 'stack-target\n' + ;; + shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources) + if [[ "$*" == *"ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'"* ]]; then + case "$FAKE_SCENARIO" in + retained) printf 'ExistingVpcBpaExclusion\tbpa-retained\n' ;; + *) printf 'VpcBpaExclusion\tbpa-owned\n' ;; + esac + else + printf 'vpc-target\n' + fi + ;; + shared:ec2:describe-instances|retained:ec2:describe-instances) + printf 'i-target\ttarget\ni-other\tother\n' + ;; + single:ec2:describe-instances) + printf 'i-target\ttarget\n' + ;; + *) + printf 'unexpected fake AWS call: %s %s\n' "$service" "$operation" >&2 + return 1 + ;; + esac +} + +FAKE_SCENARIO=shared +output=$(warn_shared_vpc_bpa 0) +assert_contains "shared VPC warning names the VPC" "VPC vpc-target is shared" "$output" +assert_contains "shared VPC warning names the exclusion" "bpa-owned" "$output" +assert_contains "shared VPC warning gives remediation" "CreateVpcBpaExclusion=true" "$output" + +FAKE_SCENARIO=retained +output=$(warn_shared_vpc_bpa 0) +assert_not_contains "retained exclusion suppresses warning" "VPC vpc-target is shared" "$output" + +FAKE_SCENARIO=single +output=$(warn_shared_vpc_bpa 0) +assert_not_contains "unshared VPC suppresses warning" "VPC vpc-target is shared" "$output" + +printf '\nPassed: %d Failed: %d\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/uninstall.sh b/uninstall.sh index 6d86249..6fef44a 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -232,6 +232,78 @@ select_targets() { # ============================================================================ # Phase: Confirmation # ============================================================================ +# Print the lifecycle of the BPA exclusion owned by the stack selected for a VPC. +# The output is one of: owned:, retained:, or none. +# Reused-VPC exclusions use the retained logical resource and must not trigger a +# warning when their creating stack is removed. +vpc_bpa_lifecycle() { + local vpc_id="$1" + local stack_name stack_vpc resources + local stacks + stacks=$(aws cloudformation list-stacks \ + --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ + --region "$SCAN_REGION" \ + --query 'StackSummaries[].StackName' --output text 2>/dev/null || echo "") + + for stack_name in $stacks; do + stack_vpc=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ + --output text 2>/dev/null || echo "") + [[ "$stack_vpc" == "$vpc_id" ]] || continue + + resources=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'].[LogicalResourceId,PhysicalResourceId]" \ + --output text 2>/dev/null || echo "") + while IFS=$'\t' read -r logical_id exclusion_id; do + case "$logical_id" in + VpcBpaExclusion) echo "owned:${exclusion_id:-unknown}"; return 0 ;; + ExistingVpcBpaExclusion) echo "retained:${exclusion_id:-unknown}"; return 0 ;; + esac + done <<< "$resources" + return 0 + done + + echo "none" +} + +# Return success when a non-selected Lowkey deployment still has a managed EC2 +# instance in the target VPC. The watermark is the deployment identity used by +# the installer and is intentionally preferred over a raw instance count. +vpc_has_other_lowkey_deployment() { + local vpc_id="$1" selected_watermark="$2" + local rows watermark + rows=$(aws ec2 describe-instances \ + --filters "Name=vpc-id,Values=${vpc_id}" "Name=tag:loki:managed,Values=true" \ + --region "$SCAN_REGION" \ + --query 'Reservations[].Instances[?State.Name!=`terminated`].[InstanceId, Tags[?Key==`loki:watermark`].Value|[0]]' \ + --output text 2>/dev/null || echo "") + + while IFS=$'\t' read -r _ watermark; do + [[ -z "$watermark" || "$watermark" == "None" ]] && continue + [[ "$watermark" != "$selected_watermark" ]] && return 0 + done <<< "$rows" + return 1 +} + +warn_shared_vpc_bpa() { + local idx="$1" + local vpc_id="${VPC_IDS[$idx]}" selected_watermark="${WATERMARKS[$idx]}" + local lifecycle exclusion_id + + lifecycle=$(vpc_bpa_lifecycle "$vpc_id") + [[ "$lifecycle" == owned:* ]] || return 0 + vpc_has_other_lowkey_deployment "$vpc_id" "$selected_watermark" || return 0 + + exclusion_id="${lifecycle#owned:}" + echo "" + warn "VPC ${vpc_id} is shared by multiple Lowkey deployments." + echo " Removing ${selected_watermark} may delete VPC-wide BPA exclusion ${exclusion_id}." + echo " Remaining deployment(s) may lose internet ingress and egress." + echo " Recreate an allow-bidirectional exclusion, or redeploy the remaining stack with CreateVpcBpaExclusion=true." +} + confirm_destruction() { echo "" echo -e " ${RED}${BOLD}╔══════════════════════════════════════════════════════╗${NC}" @@ -244,6 +316,7 @@ confirm_destruction() { for i in "${TARGETS[@]}"; do echo -e " ${RED}✗${NC} ${VPC_IDS[$i]} (${WATERMARKS[$i]}) — VPC, EC2, IAM, all resources" print_teardown_plan "$i" + warn_shared_vpc_bpa "$i" done echo "" From 10fbcbde804ca4257e992ec5ae9c09d611042911 Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Tue, 25 Aug 2026 16:18:27 +0000 Subject: [PATCH 2/7] fix: fail closed on shared VPC inspection errors Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 12 ++++++++++-- uninstall.sh | 28 +++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh index 8aa057f..4e3f52d 100644 --- a/tests/test-uninstall-bpa.sh +++ b/tests/test-uninstall-bpa.sh @@ -46,10 +46,10 @@ WATERMARKS=("target") aws() { local service="$1" operation="$2" case "${FAKE_SCENARIO}:${service}:${operation}" in - shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks) + shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks) printf 'stack-target\n' ;; - shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources) + shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources) if [[ "$*" == *"ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'"* ]]; then case "$FAKE_SCENARIO" in retained) printf 'ExistingVpcBpaExclusion\tbpa-retained\n' ;; @@ -65,6 +65,9 @@ aws() { single:ec2:describe-instances) printf 'i-target\ttarget\n' ;; + failed-scan:ec2:describe-instances) + return 1 + ;; *) printf 'unexpected fake AWS call: %s %s\n' "$service" "$operation" >&2 return 1 @@ -86,5 +89,10 @@ FAKE_SCENARIO=single output=$(warn_shared_vpc_bpa 0) assert_not_contains "unshared VPC suppresses warning" "VPC vpc-target is shared" "$output" +FAKE_SCENARIO=failed-scan +output=$(warn_shared_vpc_bpa 0) +assert_contains "failed instance scan warns instead of suppressing" "Could not verify whether VPC vpc-target has another Lowkey deployment" "$output" +assert_contains "failed instance scan treats VPC as potentially shared" "VPC vpc-target may be shared by multiple Lowkey deployments" "$output" + printf '\nPassed: %d Failed: %d\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/uninstall.sh b/uninstall.sh index 6fef44a..df974d4 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -274,11 +274,13 @@ vpc_bpa_lifecycle() { vpc_has_other_lowkey_deployment() { local vpc_id="$1" selected_watermark="$2" local rows watermark - rows=$(aws ec2 describe-instances \ + if ! rows=$(aws ec2 describe-instances \ --filters "Name=vpc-id,Values=${vpc_id}" "Name=tag:loki:managed,Values=true" \ --region "$SCAN_REGION" \ --query 'Reservations[].Instances[?State.Name!=`terminated`].[InstanceId, Tags[?Key==`loki:watermark`].Value|[0]]' \ - --output text 2>/dev/null || echo "") + --output text 2>/dev/null); then + return 2 + fi while IFS=$'\t' read -r _ watermark; do [[ -z "$watermark" || "$watermark" == "None" ]] && continue @@ -290,15 +292,31 @@ vpc_has_other_lowkey_deployment() { warn_shared_vpc_bpa() { local idx="$1" local vpc_id="${VPC_IDS[$idx]}" selected_watermark="${WATERMARKS[$idx]}" - local lifecycle exclusion_id + local lifecycle exclusion_id shared_status sharing_description lifecycle=$(vpc_bpa_lifecycle "$vpc_id") [[ "$lifecycle" == owned:* ]] || return 0 - vpc_has_other_lowkey_deployment "$vpc_id" "$selected_watermark" || return 0 + if vpc_has_other_lowkey_deployment "$vpc_id" "$selected_watermark"; then + shared_status=0 + else + shared_status=$? + fi + case "$shared_status" in + 1) return 0 ;; + 2) + echo "" + warn "Could not verify whether VPC ${vpc_id} has another Lowkey deployment." + warn "Treating the VPC as shared; review the deployment before continuing." + sharing_description="may be shared by multiple" + ;; + *) + sharing_description="is shared by multiple" + ;; + esac exclusion_id="${lifecycle#owned:}" echo "" - warn "VPC ${vpc_id} is shared by multiple Lowkey deployments." + warn "VPC ${vpc_id} ${sharing_description} Lowkey deployments." echo " Removing ${selected_watermark} may delete VPC-wide BPA exclusion ${exclusion_id}." echo " Remaining deployment(s) may lose internet ingress and egress." echo " Recreate an allow-bidirectional exclusion, or redeploy the remaining stack with CreateVpcBpaExclusion=true." From 96b8b0132331d2394fdb5e6b5871ee69b16c32fa Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Tue, 25 Aug 2026 16:26:07 +0000 Subject: [PATCH 3/7] fix: fail closed on CloudFormation scan errors Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 22 ++++++++++++++ uninstall.sh | 59 ++++++++++++++++++++++++++++--------- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh index 4e3f52d..57b3f69 100644 --- a/tests/test-uninstall-bpa.sh +++ b/tests/test-uninstall-bpa.sh @@ -35,6 +35,15 @@ assert_not_contains() { fi } +assert_status() { + local name="$1" expected="$2" actual="$3" + if [[ "$actual" -eq "$expected" ]]; then + pass "$name" + else + fail "$name (expected exit ${expected}, got ${actual})" + fi +} + # Source the functions without running uninstall.sh's main entrypoint. # shellcheck disable=SC1090 source <(sed '$d' "$UNINSTALL") @@ -46,6 +55,9 @@ WATERMARKS=("target") aws() { local service="$1" operation="$2" case "${FAKE_SCENARIO}:${service}:${operation}" in + failed-lifecycle:cloudformation:list-stacks) + return 1 + ;; shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks) printf 'stack-target\n' ;; @@ -94,5 +106,15 @@ output=$(warn_shared_vpc_bpa 0) assert_contains "failed instance scan warns instead of suppressing" "Could not verify whether VPC vpc-target has another Lowkey deployment" "$output" assert_contains "failed instance scan treats VPC as potentially shared" "VPC vpc-target may be shared by multiple Lowkey deployments" "$output" +FAKE_SCENARIO=failed-lifecycle +output=$(warn_shared_vpc_bpa 0) +assert_contains "failed lifecycle scan warns instead of suppressing" "Could not verify whether VPC vpc-target has a stack-owned BPA exclusion" "$output" +set +e +output=$(try_delete_cfn_stack "vpc-target") +delete_status=$? +set -e +assert_status "failed lifecycle scan blocks CloudFormation deletion" 2 "$delete_status" +assert_contains "failed lifecycle deletion names refusal" "refusing to remove it" "$output" + printf '\nPassed: %d Failed: %d\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/uninstall.sh b/uninstall.sh index df974d4..617fb56 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -240,22 +240,28 @@ vpc_bpa_lifecycle() { local vpc_id="$1" local stack_name stack_vpc resources local stacks - stacks=$(aws cloudformation list-stacks \ + if ! stacks=$(aws cloudformation list-stacks \ --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ --region "$SCAN_REGION" \ - --query 'StackSummaries[].StackName' --output text 2>/dev/null || echo "") + --query 'StackSummaries[].StackName' --output text 2>/dev/null); then + return 2 + fi for stack_name in $stacks; do - stack_vpc=$(aws cloudformation describe-stack-resources \ + if ! stack_vpc=$(aws cloudformation describe-stack-resources \ --stack-name "$stack_name" --region "$SCAN_REGION" \ --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ - --output text 2>/dev/null || echo "") + --output text 2>/dev/null); then + return 2 + fi [[ "$stack_vpc" == "$vpc_id" ]] || continue - resources=$(aws cloudformation describe-stack-resources \ + if ! resources=$(aws cloudformation describe-stack-resources \ --stack-name "$stack_name" --region "$SCAN_REGION" \ --query "StackResources[?ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'].[LogicalResourceId,PhysicalResourceId]" \ - --output text 2>/dev/null || echo "") + --output text 2>/dev/null); then + return 2 + fi while IFS=$'\t' read -r logical_id exclusion_id; do case "$logical_id" in VpcBpaExclusion) echo "owned:${exclusion_id:-unknown}"; return 0 ;; @@ -292,9 +298,19 @@ vpc_has_other_lowkey_deployment() { warn_shared_vpc_bpa() { local idx="$1" local vpc_id="${VPC_IDS[$idx]}" selected_watermark="${WATERMARKS[$idx]}" - local lifecycle exclusion_id shared_status sharing_description + local lifecycle exclusion_id lifecycle_status shared_status sharing_description - lifecycle=$(vpc_bpa_lifecycle "$vpc_id") + if lifecycle=$(vpc_bpa_lifecycle "$vpc_id"); then + : + else + lifecycle_status=$? + if [[ "$lifecycle_status" -eq 2 ]]; then + echo "" + warn "Could not verify whether VPC ${vpc_id} has a stack-owned BPA exclusion." + warn "Treating the VPC as potentially shared; review the deployment before continuing." + fi + return 0 + fi [[ "$lifecycle" == owned:* ]] || return 0 if vpc_has_other_lowkey_deployment "$vpc_id" "$selected_watermark"; then shared_status=0 @@ -375,6 +391,7 @@ print_teardown_plan() { remove_deployment() { local idx="$1" local vpc_id="${VPC_IDS[$idx]}" watermark="${WATERMARKS[$idx]}" method="${METHODS[$idx]}" + local cfn_status echo "" echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" @@ -382,8 +399,16 @@ remove_deployment() { echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" # Strategy 1: CloudFormation stack delete (CFN/SAM deploys) - if [[ "$method" != "terraform" ]] && try_delete_cfn_stack "$vpc_id"; then - ok "Deployment ${watermark} removed via CloudFormation"; return + if [[ "$method" != "terraform" ]]; then + if try_delete_cfn_stack "$vpc_id"; then + ok "Deployment ${watermark} removed via CloudFormation"; return + else + cfn_status=$? + if [[ "$cfn_status" -eq 2 ]]; then + warn "CloudFormation inspection failed; refusing manual cleanup for ${vpc_id}." + return 1 + fi + fi fi # Strategy 2: terraform destroy (Terraform deploys with state) @@ -404,17 +429,23 @@ remove_deployment() { try_delete_cfn_stack() { local vpc_id="$1" local stacks - stacks=$(aws cloudformation list-stacks \ + if ! stacks=$(aws cloudformation list-stacks \ --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ --region "$SCAN_REGION" \ - --query 'StackSummaries[*].StackName' --output text 2>/dev/null || echo "") + --query 'StackSummaries[*].StackName' --output text 2>/dev/null); then + warn "Could not inspect CloudFormation stacks for VPC ${vpc_id}; refusing to remove it." + return 2 + fi for stack_name in $stacks; do local stack_vpc - stack_vpc=$(aws cloudformation describe-stack-resources \ + if ! stack_vpc=$(aws cloudformation describe-stack-resources \ --stack-name "$stack_name" --region "$SCAN_REGION" \ --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ - --output text 2>/dev/null || echo "") + --output text 2>/dev/null); then + warn "Could not inspect CloudFormation stack ${stack_name} for VPC ${vpc_id}; refusing to remove it." + return 2 + fi [[ "$stack_vpc" == *"$vpc_id"* ]] || continue From 9644101d2f53d80a8408bc992d5d5efbbd25c254 Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Tue, 25 Aug 2026 16:33:00 +0000 Subject: [PATCH 4/7] fix: identify shared deployments by stack instance Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 15 ++++++++-- uninstall.sh | 55 +++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh index 57b3f69..899b2bc 100644 --- a/tests/test-uninstall-bpa.sh +++ b/tests/test-uninstall-bpa.sh @@ -58,11 +58,13 @@ aws() { failed-lifecycle:cloudformation:list-stacks) return 1 ;; - shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks) + shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks|same-watermark:cloudformation:list-stacks) printf 'stack-target\n' ;; - shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources) - if [[ "$*" == *"ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'"* ]]; then + shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources|same-watermark:cloudformation:describe-stack-resources) + if [[ "$*" == *"ResourceType=='AWS::EC2::Instance'"* ]]; then + printf 'i-target\n' + elif [[ "$*" == *"ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'"* ]]; then case "$FAKE_SCENARIO" in retained) printf 'ExistingVpcBpaExclusion\tbpa-retained\n' ;; *) printf 'VpcBpaExclusion\tbpa-owned\n' ;; @@ -74,6 +76,9 @@ aws() { shared:ec2:describe-instances|retained:ec2:describe-instances) printf 'i-target\ttarget\ni-other\tother\n' ;; + same-watermark:ec2:describe-instances) + printf 'i-target\ttarget\ni-other\ttarget\n' + ;; single:ec2:describe-instances) printf 'i-target\ttarget\n' ;; @@ -93,6 +98,10 @@ assert_contains "shared VPC warning names the VPC" "VPC vpc-target is shared" "$ assert_contains "shared VPC warning names the exclusion" "bpa-owned" "$output" assert_contains "shared VPC warning gives remediation" "CreateVpcBpaExclusion=true" "$output" +FAKE_SCENARIO=same-watermark +output=$(warn_shared_vpc_bpa 0) +assert_contains "duplicate watermark still detects shared VPC" "VPC vpc-target is shared" "$output" + FAKE_SCENARIO=retained output=$(warn_shared_vpc_bpa 0) assert_not_contains "retained exclusion suppresses warning" "VPC vpc-target is shared" "$output" diff --git a/uninstall.sh b/uninstall.sh index 617fb56..58fb164 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -275,11 +275,47 @@ vpc_bpa_lifecycle() { } # Return success when a non-selected Lowkey deployment still has a managed EC2 -# instance in the target VPC. The watermark is the deployment identity used by -# the installer and is intentionally preferred over a raw instance count. +# instance in the target VPC. The selected deployment is identified by the +# instance owned by the first CloudFormation stack that owns this VPC, not by +# LokiWatermark, because independent stacks may use the same watermark. +vpc_stack_instance_ids() { + local vpc_id="$1" + local stack_name stack_vpc instances + local stacks + if ! stacks=$(aws cloudformation list-stacks \ + --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ + --region "$SCAN_REGION" \ + --query 'StackSummaries[].StackName' --output text 2>/dev/null); then + return 2 + fi + + for stack_name in $stacks; do + if ! stack_vpc=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ + --output text 2>/dev/null); then + return 2 + fi + [[ "$stack_vpc" == "$vpc_id" ]] || continue + + if ! instances=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::Instance'].PhysicalResourceId" \ + --output text 2>/dev/null); then + return 2 + fi + printf '%s\n' "$instances" + return 0 + done + return 1 +} + vpc_has_other_lowkey_deployment() { - local vpc_id="$1" selected_watermark="$2" - local rows watermark + local vpc_id="$1" + local selected_instances rows instance_id watermark + if ! selected_instances=$(vpc_stack_instance_ids "$vpc_id"); then + return 2 + fi if ! rows=$(aws ec2 describe-instances \ --filters "Name=vpc-id,Values=${vpc_id}" "Name=tag:loki:managed,Values=true" \ --region "$SCAN_REGION" \ @@ -288,9 +324,12 @@ vpc_has_other_lowkey_deployment() { return 2 fi - while IFS=$'\t' read -r _ watermark; do - [[ -z "$watermark" || "$watermark" == "None" ]] && continue - [[ "$watermark" != "$selected_watermark" ]] && return 0 + while IFS=$'\t' read -r instance_id watermark; do + [[ -z "$instance_id" || "$instance_id" == "None" ]] && continue + if grep -Fxq -- "$instance_id" <<< "$selected_instances"; then + continue + fi + return 0 done <<< "$rows" return 1 } @@ -312,7 +351,7 @@ warn_shared_vpc_bpa() { return 0 fi [[ "$lifecycle" == owned:* ]] || return 0 - if vpc_has_other_lowkey_deployment "$vpc_id" "$selected_watermark"; then + if vpc_has_other_lowkey_deployment "$vpc_id"; then shared_status=0 else shared_status=$? From 8f4f23fda95cedcbc5a80117a5b323500c5c7ac7 Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Tue, 25 Aug 2026 16:39:49 +0000 Subject: [PATCH 5/7] fix: scan all live CloudFormation stacks Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 13 +++++++++++-- uninstall.sh | 9 +++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh index 899b2bc..8da0e15 100644 --- a/tests/test-uninstall-bpa.sh +++ b/tests/test-uninstall-bpa.sh @@ -55,13 +55,18 @@ WATERMARKS=("target") aws() { local service="$1" operation="$2" case "${FAKE_SCENARIO}:${service}:${operation}" in + rollback-complete:cloudformation:list-stacks) + if [[ "$*" == *"DELETE_COMPLETE"* ]]; then + printf 'stack-target\n' + fi + ;; failed-lifecycle:cloudformation:list-stacks) return 1 ;; shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks|same-watermark:cloudformation:list-stacks) printf 'stack-target\n' ;; - shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources|same-watermark:cloudformation:describe-stack-resources) + shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources|same-watermark:cloudformation:describe-stack-resources|rollback-complete:cloudformation:describe-stack-resources) if [[ "$*" == *"ResourceType=='AWS::EC2::Instance'"* ]]; then printf 'i-target\n' elif [[ "$*" == *"ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'"* ]]; then @@ -73,7 +78,7 @@ aws() { printf 'vpc-target\n' fi ;; - shared:ec2:describe-instances|retained:ec2:describe-instances) + shared:ec2:describe-instances|retained:ec2:describe-instances|rollback-complete:ec2:describe-instances) printf 'i-target\ttarget\ni-other\tother\n' ;; same-watermark:ec2:describe-instances) @@ -102,6 +107,10 @@ FAKE_SCENARIO=same-watermark output=$(warn_shared_vpc_bpa 0) assert_contains "duplicate watermark still detects shared VPC" "VPC vpc-target is shared" "$output" +FAKE_SCENARIO=rollback-complete +output=$(warn_shared_vpc_bpa 0) +assert_contains "rollback-complete stack still warns about shared VPC" "VPC vpc-target is shared" "$output" + FAKE_SCENARIO=retained output=$(warn_shared_vpc_bpa 0) assert_not_contains "retained exclusion suppresses warning" "VPC vpc-target is shared" "$output" diff --git a/uninstall.sh b/uninstall.sh index 58fb164..0e64b81 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -241,9 +241,8 @@ vpc_bpa_lifecycle() { local stack_name stack_vpc resources local stacks if ! stacks=$(aws cloudformation list-stacks \ - --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ --region "$SCAN_REGION" \ - --query 'StackSummaries[].StackName' --output text 2>/dev/null); then + --query 'StackSummaries[?StackStatus!=`DELETE_COMPLETE` && StackStatus!=`DELETE_IN_PROGRESS`].StackName' --output text 2>/dev/null); then return 2 fi @@ -283,9 +282,8 @@ vpc_stack_instance_ids() { local stack_name stack_vpc instances local stacks if ! stacks=$(aws cloudformation list-stacks \ - --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ --region "$SCAN_REGION" \ - --query 'StackSummaries[].StackName' --output text 2>/dev/null); then + --query 'StackSummaries[?StackStatus!=`DELETE_COMPLETE` && StackStatus!=`DELETE_IN_PROGRESS`].StackName' --output text 2>/dev/null); then return 2 fi @@ -469,9 +467,8 @@ try_delete_cfn_stack() { local vpc_id="$1" local stacks if ! stacks=$(aws cloudformation list-stacks \ - --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ --region "$SCAN_REGION" \ - --query 'StackSummaries[*].StackName' --output text 2>/dev/null); then + --query 'StackSummaries[?StackStatus!=`DELETE_COMPLETE` && StackStatus!=`DELETE_IN_PROGRESS`].StackName' --output text 2>/dev/null); then warn "Could not inspect CloudFormation stacks for VPC ${vpc_id}; refusing to remove it." return 2 fi From 64a8dc8b24f6f28a889b33b13746b9b712eb1ee0 Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Wed, 26 Aug 2026 02:37:26 +0000 Subject: [PATCH 6/7] fix: avoid unrelated CloudFormation stack scans Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 33 +++++++- uninstall.sh | 164 +++++++++++++++++------------------- 2 files changed, 107 insertions(+), 90 deletions(-) diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh index 8da0e15..5873469 100644 --- a/tests/test-uninstall-bpa.sh +++ b/tests/test-uninstall-bpa.sh @@ -60,14 +60,30 @@ aws() { printf 'stack-target\n' fi ;; + inaccessible-unrelated:cloudformation:list-stacks) + printf 'stack-unrelated\nstack-target\n' + ;; + inaccessible-unrelated:cloudformation:describe-stacks) + printf 'DELETE_COMPLETE\n' + ;; + inaccessible-unrelated:cloudformation:delete-stack) + ;; failed-lifecycle:cloudformation:list-stacks) return 1 ;; shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks|same-watermark:cloudformation:list-stacks) printf 'stack-target\n' ;; - shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources|same-watermark:cloudformation:describe-stack-resources|rollback-complete:cloudformation:describe-stack-resources) - if [[ "$*" == *"ResourceType=='AWS::EC2::Instance'"* ]]; then + shared:ec2:describe-tags|retained:ec2:describe-tags|single:ec2:describe-tags|failed-scan:ec2:describe-tags|same-watermark:ec2:describe-tags|rollback-complete:ec2:describe-tags|inaccessible-unrelated:ec2:describe-tags) + printf 'stack-target\n' + ;; + failed-lifecycle:ec2:describe-tags) + return 1 + ;; + shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources|same-watermark:cloudformation:describe-stack-resources|rollback-complete:cloudformation:describe-stack-resources|inaccessible-unrelated:cloudformation:describe-stack-resources) + if [[ "$FAKE_SCENARIO" == inaccessible-unrelated && "$*" == *"--stack-name stack-unrelated"* ]]; then + return 1 + elif [[ "$*" == *"ResourceType=='AWS::EC2::Instance'"* ]]; then printf 'i-target\n' elif [[ "$*" == *"ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'"* ]]; then case "$FAKE_SCENARIO" in @@ -78,7 +94,7 @@ aws() { printf 'vpc-target\n' fi ;; - shared:ec2:describe-instances|retained:ec2:describe-instances|rollback-complete:ec2:describe-instances) + shared:ec2:describe-instances|retained:ec2:describe-instances|rollback-complete:ec2:describe-instances|inaccessible-unrelated:ec2:describe-instances) printf 'i-target\ttarget\ni-other\tother\n' ;; same-watermark:ec2:describe-instances) @@ -107,6 +123,17 @@ FAKE_SCENARIO=same-watermark output=$(warn_shared_vpc_bpa 0) assert_contains "duplicate watermark still detects shared VPC" "VPC vpc-target is shared" "$output" +FAKE_SCENARIO=inaccessible-unrelated +output=$(warn_shared_vpc_bpa 0) +assert_contains "inaccessible unrelated stack does not suppress shared VPC warning" "VPC vpc-target is shared" "$output" +assert_not_contains "inaccessible unrelated stack does not make selected stack indeterminate" "Could not verify whether VPC vpc-target has a stack-owned BPA exclusion" "$output" +set +e +output=$(try_delete_cfn_stack "vpc-target") +delete_status=$? +set -e +assert_status "inaccessible unrelated stack does not block selected stack deletion" 0 "$delete_status" +assert_contains "selected stack is resolved from VPC tag" "Found CloudFormation stack: stack-target" "$output" + FAKE_SCENARIO=rollback-complete output=$(warn_shared_vpc_bpa 0) assert_contains "rollback-complete stack still warns about shared VPC" "VPC vpc-target is shared" "$output" diff --git a/uninstall.sh b/uninstall.sh index 0e64b81..aa520f7 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -232,80 +232,79 @@ select_targets() { # ============================================================================ # Phase: Confirmation # ============================================================================ +# Resolve the CloudFormation stack that owns a VPC from the resource's +# CloudFormation system tag. Do not enumerate every stack in the account: an +# unrelated stack may be inaccessible even when the selected VPC's stack is +# readable, and that should not block teardown of the selected deployment. +vpc_cloudformation_stack_name() { + local vpc_id="$1" stack_name + if stack_name=$(aws ec2 describe-tags \ + --filters "Name=resource-id,Values=${vpc_id}" \ + "Name=key,Values=aws:cloudformation:stack-name" \ + --region "$SCAN_REGION" \ + --query 'Tags[0].Value' --output text 2>/dev/null); then + [[ -n "$stack_name" && "$stack_name" != "None" ]] || return 1 + printf '%s\n' "$stack_name" + return 0 + fi + return 2 +} + # Print the lifecycle of the BPA exclusion owned by the stack selected for a VPC. # The output is one of: owned:, retained:, or none. # Reused-VPC exclusions use the retained logical resource and must not trigger a # warning when their creating stack is removed. vpc_bpa_lifecycle() { local vpc_id="$1" - local stack_name stack_vpc resources - local stacks - if ! stacks=$(aws cloudformation list-stacks \ - --region "$SCAN_REGION" \ - --query 'StackSummaries[?StackStatus!=`DELETE_COMPLETE` && StackStatus!=`DELETE_IN_PROGRESS`].StackName' --output text 2>/dev/null); then + local stack_name resources stack_status + + if stack_name=$(vpc_cloudformation_stack_name "$vpc_id"); then + : + else + stack_status=$? + [[ "$stack_status" -eq 1 ]] && { echo "none"; return 0; } return 2 fi - for stack_name in $stacks; do - if ! stack_vpc=$(aws cloudformation describe-stack-resources \ - --stack-name "$stack_name" --region "$SCAN_REGION" \ - --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ - --output text 2>/dev/null); then - return 2 - fi - [[ "$stack_vpc" == "$vpc_id" ]] || continue - - if ! resources=$(aws cloudformation describe-stack-resources \ - --stack-name "$stack_name" --region "$SCAN_REGION" \ - --query "StackResources[?ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'].[LogicalResourceId,PhysicalResourceId]" \ - --output text 2>/dev/null); then - return 2 - fi - while IFS=$'\t' read -r logical_id exclusion_id; do - case "$logical_id" in - VpcBpaExclusion) echo "owned:${exclusion_id:-unknown}"; return 0 ;; - ExistingVpcBpaExclusion) echo "retained:${exclusion_id:-unknown}"; return 0 ;; - esac - done <<< "$resources" - return 0 - done - + if ! resources=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::VPCBlockPublicAccessExclusion'].[LogicalResourceId,PhysicalResourceId]" \ + --output text 2>/dev/null); then + return 2 + fi + while IFS=$'\t' read -r logical_id exclusion_id; do + case "$logical_id" in + VpcBpaExclusion) echo "owned:${exclusion_id:-unknown}"; return 0 ;; + ExistingVpcBpaExclusion) echo "retained:${exclusion_id:-unknown}"; return 0 ;; + esac + done <<< "$resources" echo "none" } # Return success when a non-selected Lowkey deployment still has a managed EC2 # instance in the target VPC. The selected deployment is identified by the -# instance owned by the first CloudFormation stack that owns this VPC, not by +# instance owned by the CloudFormation stack tagged on this VPC, not by # LokiWatermark, because independent stacks may use the same watermark. vpc_stack_instance_ids() { local vpc_id="$1" - local stack_name stack_vpc instances - local stacks - if ! stacks=$(aws cloudformation list-stacks \ - --region "$SCAN_REGION" \ - --query 'StackSummaries[?StackStatus!=`DELETE_COMPLETE` && StackStatus!=`DELETE_IN_PROGRESS`].StackName' --output text 2>/dev/null); then + local stack_name instances stack_status + + if stack_name=$(vpc_cloudformation_stack_name "$vpc_id"); then + : + else + stack_status=$? + [[ "$stack_status" -eq 1 ]] && return 1 return 2 fi - for stack_name in $stacks; do - if ! stack_vpc=$(aws cloudformation describe-stack-resources \ - --stack-name "$stack_name" --region "$SCAN_REGION" \ - --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ - --output text 2>/dev/null); then - return 2 - fi - [[ "$stack_vpc" == "$vpc_id" ]] || continue - - if ! instances=$(aws cloudformation describe-stack-resources \ - --stack-name "$stack_name" --region "$SCAN_REGION" \ - --query "StackResources[?ResourceType=='AWS::EC2::Instance'].PhysicalResourceId" \ - --output text 2>/dev/null); then - return 2 - fi - printf '%s\n' "$instances" - return 0 - done - return 1 + if ! instances=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::Instance'].PhysicalResourceId" \ + --output text 2>/dev/null); then + return 2 + fi + printf '%s\n' "$instances" + return 0 } vpc_has_other_lowkey_deployment() { @@ -465,44 +464,35 @@ remove_deployment() { # ============================================================================ try_delete_cfn_stack() { local vpc_id="$1" - local stacks - if ! stacks=$(aws cloudformation list-stacks \ - --region "$SCAN_REGION" \ - --query 'StackSummaries[?StackStatus!=`DELETE_COMPLETE` && StackStatus!=`DELETE_IN_PROGRESS`].StackName' --output text 2>/dev/null); then - warn "Could not inspect CloudFormation stacks for VPC ${vpc_id}; refusing to remove it." - return 2 - fi + local stack_name stack_status - for stack_name in $stacks; do - local stack_vpc - if ! stack_vpc=$(aws cloudformation describe-stack-resources \ - --stack-name "$stack_name" --region "$SCAN_REGION" \ - --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ - --output text 2>/dev/null); then - warn "Could not inspect CloudFormation stack ${stack_name} for VPC ${vpc_id}; refusing to remove it." + if stack_name=$(vpc_cloudformation_stack_name "$vpc_id"); then + : + else + stack_status=$? + if [[ "$stack_status" -eq 2 ]]; then + warn "Could not inspect CloudFormation ownership for VPC ${vpc_id}; refusing to remove it." return 2 fi + return 1 + fi - [[ "$stack_vpc" == *"$vpc_id"* ]] || continue - - info "Found CloudFormation stack: ${stack_name}" - info "Deleting stack (this takes 5-10 minutes)..." - aws cloudformation delete-stack --stack-name "$stack_name" --region "$SCAN_REGION" - - while true; do - local status - status=$(aws cloudformation describe-stacks --stack-name "$stack_name" --region "$SCAN_REGION" \ - --query 'Stacks[0].StackStatus' --output text 2>&1 || echo "DELETE_COMPLETE") - echo -ne "\r Status: ${status} " - case "$status" in - DELETE_COMPLETE) echo ""; return 0 ;; - *DELETE_FAILED*) echo ""; warn "Stack delete failed — will try manual cleanup"; return 1 ;; - *does\ not\ exist*) echo ""; return 0 ;; - *) sleep 15 ;; - esac - done + info "Found CloudFormation stack: ${stack_name}" + info "Deleting stack (this takes 5-10 minutes)..." + aws cloudformation delete-stack --stack-name "$stack_name" --region "$SCAN_REGION" + + while true; do + local status + status=$(aws cloudformation describe-stacks --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query 'Stacks[0].StackStatus' --output text 2>&1 || echo "DELETE_COMPLETE") + echo -ne "\r Status: ${status} " + case "$status" in + DELETE_COMPLETE) echo ""; return 0 ;; + *DELETE_FAILED*) echo ""; warn "Stack delete failed — will try manual cleanup"; return 1 ;; + *does\ not\ exist*) echo ""; return 0 ;; + *) sleep 15 ;; + esac done - return 1 } # ============================================================================ From b6b1337976ac6e0f0dd1ab83ed89679e5b2c76a1 Mon Sep 17 00:00:00 2001 From: "Bode (Kiro Crew Issue Radar)" Date: Wed, 26 Aug 2026 02:48:05 +0000 Subject: [PATCH 7/7] fix: guard CloudFormation deletion inspection Crew: Bode (Kiro Crew Issue Radar) --- tests/test-uninstall-bpa.sh | 18 +++++++++++++++++- uninstall.sh | 11 ++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/test-uninstall-bpa.sh b/tests/test-uninstall-bpa.sh index 5873469..1dbe2f9 100644 --- a/tests/test-uninstall-bpa.sh +++ b/tests/test-uninstall-bpa.sh @@ -68,18 +68,26 @@ aws() { ;; inaccessible-unrelated:cloudformation:delete-stack) ;; + lifecycle-failure:cloudformation:describe-stacks) + printf 'DELETE_COMPLETE\n' + ;; + lifecycle-failure:cloudformation:delete-stack) + ;; failed-lifecycle:cloudformation:list-stacks) return 1 ;; shared:cloudformation:list-stacks|retained:cloudformation:list-stacks|single:cloudformation:list-stacks|failed-scan:cloudformation:list-stacks|same-watermark:cloudformation:list-stacks) printf 'stack-target\n' ;; - shared:ec2:describe-tags|retained:ec2:describe-tags|single:ec2:describe-tags|failed-scan:ec2:describe-tags|same-watermark:ec2:describe-tags|rollback-complete:ec2:describe-tags|inaccessible-unrelated:ec2:describe-tags) + shared:ec2:describe-tags|retained:ec2:describe-tags|single:ec2:describe-tags|failed-scan:ec2:describe-tags|same-watermark:ec2:describe-tags|rollback-complete:ec2:describe-tags|inaccessible-unrelated:ec2:describe-tags|lifecycle-failure:ec2:describe-tags) printf 'stack-target\n' ;; failed-lifecycle:ec2:describe-tags) return 1 ;; + lifecycle-failure:cloudformation:describe-stack-resources) + return 1 + ;; shared:cloudformation:describe-stack-resources|retained:cloudformation:describe-stack-resources|single:cloudformation:describe-stack-resources|failed-scan:cloudformation:describe-stack-resources|same-watermark:cloudformation:describe-stack-resources|rollback-complete:cloudformation:describe-stack-resources|inaccessible-unrelated:cloudformation:describe-stack-resources) if [[ "$FAKE_SCENARIO" == inaccessible-unrelated && "$*" == *"--stack-name stack-unrelated"* ]]; then return 1 @@ -134,6 +142,14 @@ set -e assert_status "inaccessible unrelated stack does not block selected stack deletion" 0 "$delete_status" assert_contains "selected stack is resolved from VPC tag" "Found CloudFormation stack: stack-target" "$output" +FAKE_SCENARIO=lifecycle-failure +set +e +output=$(try_delete_cfn_stack "vpc-target") +delete_status=$? +set -e +assert_status "selected stack lifecycle inspection failure blocks deletion" 2 "$delete_status" +assert_contains "selected stack lifecycle refusal names inspection failure" "refusing to remove it" "$output" + FAKE_SCENARIO=rollback-complete output=$(warn_shared_vpc_bpa 0) assert_contains "rollback-complete stack still warns about shared VPC" "VPC vpc-target is shared" "$output" diff --git a/uninstall.sh b/uninstall.sh index aa520f7..87ae308 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -464,7 +464,7 @@ remove_deployment() { # ============================================================================ try_delete_cfn_stack() { local vpc_id="$1" - local stack_name stack_status + local stack_name stack_status stack_vpc if stack_name=$(vpc_cloudformation_stack_name "$vpc_id"); then : @@ -477,6 +477,15 @@ try_delete_cfn_stack() { return 1 fi + if ! stack_vpc=$(aws cloudformation describe-stack-resources \ + --stack-name "$stack_name" --region "$SCAN_REGION" \ + --query "StackResources[?ResourceType=='AWS::EC2::VPC'].PhysicalResourceId" \ + --output text 2>/dev/null); then + warn "Could not inspect CloudFormation stack ${stack_name} for VPC ${vpc_id}; refusing to remove it." + return 2 + fi + [[ "$stack_vpc" == *"$vpc_id"* ]] || return 1 + info "Found CloudFormation stack: ${stack_name}" info "Deleting stack (this takes 5-10 minutes)..." aws cloudformation delete-stack --stack-name "$stack_name" --region "$SCAN_REGION"