Skip to content

Commit 4aa4ead

Browse files
authored
sec(ci): gate terraform force-unlock behind explicit input (closes #438) (#818)
* sec(ci): gate terraform force-unlock behind explicit input (closes #438) Unconditionally deleting the S3 state lock before every terraform init allowed concurrent dispatch runs to race and corrupt state. Remove the automatic deletion and replace it with a gated step that only runs when the operator explicitly sets clear_stale_lock=true on workflow_dispatch. The existing failure/cancellation cleanup step is unchanged. Add runbooks/terraform-stuck-lock.md with diagnosis and recovery steps. * fix(ci): add language tag to fenced code block in terraform-stuck-lock.md markdownlint MD040 requires fenced code blocks to specify a language. The error-output block at line 7 had a bare ``` opener; add "text" tag. * sec(ci): drop unsafe failure-path state-lock delete (closes #438) The "Release state lock on failure" step deleted the S3 tflock on any failed/cancelled run. A run that fails because it could not acquire the lock would delete the lock a concurrent run is still actively holding, re-introducing the state-corruption race this PR aims to close. Remove the automatic failure-path unlock. Terraform already releases its own lock on a clean apply error; a lock surviving a run means the run died abnormally, which requires operator confirmation via clear_stale_lock before clearing. Update the runbook accordingly.
1 parent 464a235 commit 4aa4ead

2 files changed

Lines changed: 112 additions & 15 deletions

File tree

‎.github/workflows/deploy-aws-fargate.yml‎

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212
# - ECR_REPOSITORY: ECR repository name
1313
#
1414
# Triggered by:
15-
# - Manual workflow dispatch only (push trigger disabled — Fargate is not the primary compute platform)
15+
# - Manual workflow dispatch only (push trigger disabled; Fargate is not the primary compute platform)
1616
# - Workflow call from deploy-all.yml
17+
#
18+
# Lock cleanup is intentionally manual. If a prior run crashed and left a stuck
19+
# lock, re-run this workflow with clear_stale_lock=true (workflow_dispatch only).
20+
# See runbooks/terraform-stuck-lock.md for diagnosis and manual recovery steps.
1721

1822
name: Deploy to AWS Fargate
1923

@@ -33,6 +37,11 @@ on:
3337
required: true
3438
type: choice
3539
options: [dev, staging, prod]
40+
clear_stale_lock:
41+
description: 'Force-clear a stale S3 state lock from a prior crashed run (use only when you know the prior run is dead)'
42+
required: false
43+
type: boolean
44+
default: false
3645
workflow_call:
3746
inputs:
3847
environment:
@@ -105,18 +114,28 @@ jobs:
105114
with:
106115
terraform_version: ${{ env.TF_VERSION }}
107116

108-
- name: Terraform Init
117+
- name: Clear stale state lock (operator-triggered only)
118+
if: ${{ inputs.clear_stale_lock == true }}
109119
env:
110120
TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }}
111121
ENVIRONMENT: ${{ needs.prepare.outputs.environment }}
112122
run: |
113123
printf '%s\nkey = "github-fargate-%s/terraform.tfstate"\n' "$TF_BACKEND" "$ENVIRONMENT" > /tmp/backend.tfbackend
114-
# Break any stale state lock from a previous failed run
115124
BUCKET=$(grep -E '^\s*bucket\s*=' /tmp/backend.tfbackend 2>/dev/null | tr -d ' "' | cut -d= -f2)
116125
if [ -n "$BUCKET" ]; then
117126
LOCK_KEY="github-fargate-${ENVIRONMENT}/terraform.tfstate.tflock"
118-
aws s3 rm "s3://${BUCKET}/${LOCK_KEY}" 2>/dev/null || true
127+
echo "Removing stale lock: s3://${BUCKET}/${LOCK_KEY}"
128+
aws s3 rm "s3://${BUCKET}/${LOCK_KEY}" 2>/dev/null || echo "No lock file found (already clean)"
129+
else
130+
echo "Could not parse bucket from backend config; skipping lock clear"
119131
fi
132+
133+
- name: Terraform Init
134+
env:
135+
TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }}
136+
ENVIRONMENT: ${{ needs.prepare.outputs.environment }}
137+
run: |
138+
printf '%s\nkey = "github-fargate-%s/terraform.tfstate"\n' "$TF_BACKEND" "$ENVIRONMENT" > /tmp/backend.tfbackend
120139
cd terraform/environments/aws
121140
terraform init -backend-config=/tmp/backend.tfbackend
122141
@@ -138,17 +157,14 @@ jobs:
138157
cd terraform/environments/aws
139158
terraform apply -auto-approve tfplan
140159
141-
- name: Release state lock on failure
142-
if: failure() || cancelled()
143-
env:
144-
ENVIRONMENT: ${{ needs.prepare.outputs.environment }}
145-
run: |
146-
BUCKET=$(grep -E '^\s*bucket\s*=' /tmp/backend.tfbackend 2>/dev/null | tr -d ' "' | cut -d= -f2)
147-
if [ -n "$BUCKET" ]; then
148-
LOCK_KEY="github-fargate-${ENVIRONMENT}/terraform.tfstate.tflock"
149-
echo "Releasing S3 state lock: s3://${BUCKET}/${LOCK_KEY}"
150-
aws s3 rm "s3://${BUCKET}/${LOCK_KEY}" 2>/dev/null || echo "No lock file found (already clean)"
151-
fi
160+
# No automatic state-lock release on failure. A blanket failure-path
161+
# delete is itself a state-corruption risk: a run that fails *because*
162+
# it could not acquire the lock would delete the lock held by another
163+
# run that is still actively applying. Terraform already releases its
164+
# own lock on a clean apply error; a lock that survives a run means the
165+
# run died abnormally, which requires operator confirmation that the
166+
# owning run is dead before clearing it via the clear_stale_lock input.
167+
# See runbooks/terraform-stuck-lock.md.
152168

153169
- name: Get outputs
154170
id: outputs

‎runbooks/terraform-stuck-lock.md‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Runbook: Terraform Stuck State Lock
2+
3+
## Symptoms
4+
5+
A Terraform workflow fails with an error similar to:
6+
7+
```text
8+
Error: Error acquiring the state lock
9+
...
10+
Lock Info:
11+
ID: <lock-id>
12+
Operation: OperationTypeApply
13+
...
14+
```
15+
16+
or the S3 backend reports the `.tflock` object already exists from a prior run.
17+
18+
## When this happens
19+
20+
A lock file is left behind when a CI run is interrupted (runner killed, job
21+
canceled mid-apply, network error during apply). The workflow does NOT
22+
auto-delete the lock on failure: a blanket failure-path delete would itself
23+
risk state corruption, because a run that fails *because* it could not acquire
24+
the lock would delete the lock another run is still actively holding. Terraform
25+
releases its own lock on a clean apply error, so a lock that survives a run
26+
means that run died abnormally; clearing it requires operator confirmation that
27+
the owning run is dead.
28+
29+
## Safe recovery steps
30+
31+
Before clearing the lock, confirm the prior run that owns it is truly dead:
32+
33+
1. Note the `Lock Info.ID` from the error output.
34+
2. In GitHub Actions, find the run that created the lock (check the timestamp in
35+
`Lock Info.Created`). Verify its status is "Failed" or "Cancelled" -- not
36+
still in progress.
37+
3. If the owning run is still running, do NOT clear the lock. Wait for it to
38+
finish or cancel it explicitly first.
39+
40+
## Clearing the lock via workflow dispatch
41+
42+
Once you have confirmed the owning run is dead:
43+
44+
1. Go to **Actions > Deploy to AWS Fargate > Run workflow**.
45+
2. Select the affected environment.
46+
3. Check **"Force-clear a stale S3 state lock from a prior crashed run"**.
47+
4. Click **Run workflow**.
48+
49+
The `Clear stale state lock (operator-triggered only)` step will remove the
50+
`.tflock` object from S3 and then proceed with a normal deploy.
51+
52+
## Manual recovery (AWS CLI)
53+
54+
If you prefer to clear the lock outside CI:
55+
56+
```bash
57+
# Replace <bucket> and <env> with the actual backend bucket and environment name
58+
BUCKET=<bucket>
59+
ENV=<env>
60+
LOCK_KEY="github-fargate-${ENV}/terraform.tfstate.tflock"
61+
62+
aws s3 ls "s3://${BUCKET}/${LOCK_KEY}" # confirm it exists
63+
aws s3 rm "s3://${BUCKET}/${LOCK_KEY}" # remove it
64+
```
65+
66+
Then re-run the deployment workflow normally (without `clear_stale_lock`).
67+
68+
## Why locks are not cleared automatically
69+
70+
Any unconditional lock delete (before `terraform init`, or in a blanket
71+
failure-path cleanup step) lets two concurrent deploys race: one run could
72+
remove another run's active lock, enabling parallel state writes and potential
73+
state corruption. A failure-path delete is especially dangerous because a run
74+
that fails *because* it lost the race to acquire the lock would then delete the
75+
winner's live lock. The lock must only be cleared when you know the holder is no
76+
longer active, which is why clearing is gated behind the operator-triggered
77+
`clear_stale_lock` input rather than an automatic step.
78+
79+
Terraform releases its own lock on a clean apply error within the same run, so
80+
this gating only affects the rarer case where a run dies abnormally before that
81+
release happens.

0 commit comments

Comments
 (0)