Repository navigation
sec(ci): delete the GCP staging VPC peering before removing it from state (#330) - #1144
Conversation
…tate (LeanerCloud#330) The destroy-gcp job removed module.networking.google_service_networking_connection.private_vpc_connection from state before deleting the peering, deleted it on a hardcoded `cudly-staging-vpc`, and swallowed the delete's failure with `2>/dev/null || echo`. Unlike the root-level Cloud SQL addresses fixed in #1971, this address is module-qualified, so the state rm was real: a failed or misdirected delete left a live peering no state tracked. Move the peering cleanup into scripts/delete-owned-vpc-peering.sh, run as its own step between the Cloud SQL delete and terraform destroy. It deletes only while the state still tracks the address (exact match via select-owned-name.sh), reads the network from the state's network_name output, swallows nothing, and runs `terraform state rm` only after the delete returned 0. Add scripts/test-vpc-peering-delete-scope.sh as the fourth sibling guard suite (stubbed terraform/gcloud behavior, workflow wiring and step order, and a sweep of workflows and scripts/ for inline peering deletes), wired into ci.yml and ci-success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review 2 (claude-fleet, cold): APPROVE 626f0fc Findings (none blocking):
Against #330: the PR does all three things the issue asks:
Attacked:
Verified locally:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rA3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
…nerCloud#330) The peering step added for LeanerCloud#330 sat before `Terraform Destroy` with no `if:`, so a failed peering delete (most likely GCP still releasing the peering right after the Cloud SQL delete) skipped the destroy and left the whole staging stack running and billing. Before LeanerCloud#1144 the destroy still removed everything except the network. The peering step is now continue-on-error with id delete-peering, so the destroy runs after it either way. A new last step, gated on `!cancelled() && steps.delete-peering.outcome == 'failure'`, fails the job so the failure stays visible and is not swallowed. The script is unchanged: it still removes the address from state only after a successful delete, so the destroy keeps tracking a peering whose delete failed. test-vpc-peering-delete-scope.sh gains a small model of the runner's step semantics for destroy-gcp. It pins that a failed peering delete still runs the destroy and fails the job, that a failed destroy does not hide the peering failure, and that a failed Cloud SQL delete still skips the destroy. Five mutants of the real workflow check that the model is not vacuous. The existing failed-delete behavior case still pins that no state is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixed the held item (a failed peering delete skipped What changed
Checks (all local; nothing ran the workflow or called GCP)
|
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rB25. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rB26. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Root cause
In
cleanup-staging.yml'sdestroy-gcpjob, the Cloud SQL step ended with an inline peering cleanup that:terraform state rm module.networking.google_service_networking_connection.private_vpc_connection 2>/dev/null || truebefore the delete. Unlike the root-level Cloud SQL addresses fixed in #1971, this address is module-qualified, so it really removed state;gcloud services vpc-peerings delete --network=cudly-staging-vpc ... 2>/dev/null || echo "VPC peering already gone or not found", so a failed delete still reported success;cudly-staging-vpcinstead of thenetwork_nameoutput the state already publishes. A renamed network would have made the delete a "not found" no-op while the state rm still ran.The result was a live peering that no state tracked.
Fix
scripts/delete-owned-vpc-peering.sh TERRAFORM_STATE_DIR GCP_PROJECT_ID, run as its own step, Delete the Service Networking peering this state owns. It sits between the Cloud SQL step andTerraform Destroy.terraform state list, piped through the sharedscripts/select-owned-name.sh. A re-run after a finished cleanup is therefore a no-op, and a re-run after a failed delete retries it. I didn't useterraform state list ADDRESSbecause it exits 1 both when the address is absent and when the state can't be read (measured on Terraform 1.15.8 with a local state), so those two cases can't be told apart without swallowing an error.terraform output -json→.network_name.value. That output resolves throughmodule.networking.network_nametogoogle_compute_network.main.name, the networkprivate_vpc_connectionis attached to. If the output is not JSON, has nonetwork_name, has a null value, or isn't an RFC 1035 name, the script exits 1 and leaves state alone.2>/dev/null,|| trueor|| echo, and no--async, so gcloud's exit status confirms the delete.terraform state rmruns only after the delete returns 0. A failed delete exits with gcloud's own status and leaves the address in state. The error message tells the operator how to remove the address by hand if the peering is confirmed gone already.scripts/test-vpc-peering-delete-scope.shis the fourth sibling suite, next to the ECR, RDS and Cloud SQL ones. It runs as thevpc-peering-delete-scopejob inci.ymland is listed inci-success'sneeds. It checks:network_nameoutput comes frommodule.networking.network_name, which isgoogle_compute_network.main.name, andprivate_vpc_connectionhasnetwork = google_compute_network.main.id."$PROJECT", with nothing appended. Insidedestroy-gcp, the order is Cloud SQL step, then peering step, thenTerraform Destroy.jq -er '.network_name.value'read, one delete using--network="$NETWORK", no literal network, no--async, onestate rm "$PEERING_ADDR"after the delete, and nothing swallowed.terraformandgcloud(one shared call log; the gcloud stub accepts only the exact delete argv): the golden path on a renamed network, a failed delete, a failed state rm, the address missing from state with only near-miss addresses present, an empty state, the address on an unterminated last line, an unreadable state, nine malformed-output cases, malformed project ids, and arity errors.scripts/must have exactly onegcloud services vpc-peerings deletesite, and it must be the script. Fixtures pin both directions: the pre-fix step is flagged, and prose that only mentions the command is not.scripts/lib/code-scan-awk.sh: the new suite is added tobuild_swept_scripts' exclusion list. Without it, the suite's own fixtures would be swept.Verification
All of this is local, with stubbed
terraformandgcloudand fixtures. Nothing calls real GCP, terraform state or any workflow.bash scripts/test-vpc-peering-delete-scope.sh→passed: 41, failed: 0.Fail before: I ran the new suite (plus the updated
code-scan-awk.sh) against agit archive origin/maincopy →passed: 17, failed: 24. The sweep names the pre-fix inline delete:cleanup-staging.yml:412 total 1. The wiring and step-order checks fail, and every behavior case fails because the script doesn't exist.Mutation probes on a copy of the fixed tree. Each one fails the suite:
--network=cudly-staging-vpchardcoded--asyncaddedexit 0grep -F ... | head -1 || true|| trueappended to the workflow callThe sibling suites still pass:
test-cloud-sql-delete-scope.sh62/0,test-ecr-delete-selection.sh59/0,test-rds-deletion-protection-scope.sh58/0,test-aws-tfstate-platform-key.sh26/0.actionlint .github/workflows/ci.yml .github/workflows/cleanup-staging.ymlpasses (shellcheck present).zizmor==1.29.0 --offline --persona=pedantic --min-severity=mediumon both workflows → no findings.shellcheckis clean ondelete-owned-vpc-peering.sh.shellcheck -S warningis clean on the new suite and oncode-scan-awk.sh. The suite has only SC2015/SC2016 info notes, the same kind the sibling suites carry.git-secrets: I ran
scripts/setup-git-secrets.shin a freshgit initcopy with an isolated HOME, thengit secrets --scanon the changed files. Both are clean, andscripts/test-git-secrets-allowlist.shgives 42 PASS, 0 FAIL.git diff --checkis clean. No Go files changed, so go test, vet, golangci-lint and gocyclo don't apply.Not covered by real-cloud evidence: I didn't run real
gcloud services vpc-peerings deleteorterraform state listagainst the GCS backend. The--asyncand exit-status semantics come from gcloud's documented behavior; theterraform state listsemantics were measured locally on Terraform 1.15.8 (CI pins 1.10.0).Out of scope / notes
terraform destroythen failed ongoogle_compute_network, so the job failed either way. I didn't add a bounded retry loop.terraform destroythen fails on the network, as it does today. Such a peering has to be deleted by hand once.scripts/gcp-import-dev-state.shrunsgcloud services vpc-peerings list ... 2>/dev/null. That's a read-only existence probe, not a delete, so I left it alone.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,impact/internal,triaged,urgency/this-quarter,effort/s,type/security.Closes #330
🤖 Generated with Claude Code