@biffo/cli 0.217.7 → 0.218.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/plugin-template/scripts/check-destructive-plan.mjs +140 -0
- package/_skeletons/plugin-template/scripts/destructive-plan.mjs +128 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +25 -2
- package/_skeletons/sibling-template/scripts/check-destructive-plan.mjs +140 -0
- package/_skeletons/sibling-template/scripts/destructive-plan.mjs +128 -0
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CI runner for the destructive-plan guard (issue #387).
|
|
4
|
+
*
|
|
5
|
+
* Reads a `terraform show -json` plan and refuses it when it would destroy
|
|
6
|
+
* stateful infrastructure without an `Infra-Destroy:` trailer authorising it.
|
|
7
|
+
*
|
|
8
|
+
* Runs in the **Plan** job, before Apply, so a refusal costs nothing: no
|
|
9
|
+
* infrastructure has been touched when it fires. Apply runs with
|
|
10
|
+
* `--auto-approve`, so this is the only point at which a human decision can
|
|
11
|
+
* still be required.
|
|
12
|
+
*
|
|
13
|
+
* No dependencies, so it runs on bare node without a pnpm install — see
|
|
14
|
+
* destructive-plan.mjs for why.
|
|
15
|
+
*
|
|
16
|
+
* Usage: node check-destructive-plan.mjs <plan.json>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
20
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { DESTROY_TRAILER, checkDestructivePlan } from './destructive-plan.mjs'
|
|
22
|
+
|
|
23
|
+
const BOLD = '[1m'
|
|
24
|
+
const DIM = '[2m'
|
|
25
|
+
const RED = '[31m'
|
|
26
|
+
const OFF = '[0m'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Commit messages this deploy is carrying.
|
|
30
|
+
*
|
|
31
|
+
* A push build has no PR body, so the commit message is the only place an
|
|
32
|
+
* author can speak to CI. `github.event.before` bounds it to what this push
|
|
33
|
+
* actually added; on a first push or a force-push it is all-zeroes, and then
|
|
34
|
+
* the tip commit alone is the honest answer rather than the entire history —
|
|
35
|
+
* an `Infra-Destroy:` trailer from six months ago must not authorise today.
|
|
36
|
+
*/
|
|
37
|
+
function pushCommitMessages() {
|
|
38
|
+
const before = process.env.GITHUB_EVENT_BEFORE
|
|
39
|
+
const head = process.env.GITHUB_SHA || 'HEAD'
|
|
40
|
+
const args =
|
|
41
|
+
before && !/^0+$/.test(before)
|
|
42
|
+
? ['log', '--format=%B', `${before}..${head}`]
|
|
43
|
+
: ['log', '--format=%B', '-1']
|
|
44
|
+
try {
|
|
45
|
+
return execFileSync('git', args, { encoding: 'utf8' })
|
|
46
|
+
} catch {
|
|
47
|
+
// A shallow clone may not contain `before`. Fall back to the tip rather
|
|
48
|
+
// than treating an unreadable range as "no trailer", which would block a
|
|
49
|
+
// legitimately authorised deploy.
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync('git', ['log', '--format=%B', '-1'], { encoding: 'utf8' })
|
|
52
|
+
} catch {
|
|
53
|
+
return ''
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
const planPath = process.argv[2]
|
|
60
|
+
if (!planPath) {
|
|
61
|
+
console.error('Usage: check-destructive-plan.mjs <plan.json>')
|
|
62
|
+
process.exit(2)
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(planPath)) {
|
|
65
|
+
// Fail closed. A missing plan means the guard cannot see what is about to
|
|
66
|
+
// be applied, which is not the same as there being nothing to see.
|
|
67
|
+
console.error(`::error::Plan file ${planPath} not found — refusing to apply unexamined.`)
|
|
68
|
+
process.exit(2)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let plan
|
|
72
|
+
try {
|
|
73
|
+
plan = JSON.parse(readFileSync(planPath, 'utf8'))
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`::error::Could not parse ${planPath}: ${err.message}`)
|
|
76
|
+
process.exit(2)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = checkDestructivePlan(plan, pushCommitMessages())
|
|
80
|
+
|
|
81
|
+
if (result.destructive.length === 0) {
|
|
82
|
+
console.log('✓ destructive-plan guard: no stateful resource is destroyed by this plan.')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lines = result.destructive.map(
|
|
87
|
+
(c) => ` ${c.address} ${DIM}(${c.replacement ? 'replaced' : 'destroyed'})${OFF}`,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if (!result.blocked) {
|
|
91
|
+
console.log(
|
|
92
|
+
`⚠ destructive-plan guard: allowed by ${DESTROY_TRAILER}: ${result.acknowledgedReason}\n` +
|
|
93
|
+
lines.join('\n'),
|
|
94
|
+
)
|
|
95
|
+
summarise(result.destructive, result.acknowledgedReason)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.error(`
|
|
100
|
+
${RED}${BOLD}✗ This plan destroys stateful infrastructure.${OFF}
|
|
101
|
+
|
|
102
|
+
${lines.join('\n')}
|
|
103
|
+
|
|
104
|
+
${BOLD}Why this is blocked${OFF}
|
|
105
|
+
These resources hold data no re-apply can recreate — rows, users, objects.
|
|
106
|
+
Everything else Terraform rebuilds from this repo; these it cannot, because
|
|
107
|
+
nothing in this repo contains the data. Apply runs with --auto-approve, so
|
|
108
|
+
without this check the destroy would happen with nobody having seen the plan.
|
|
109
|
+
|
|
110
|
+
A ${BOLD}replacement${OFF} destroys the original just as surely as a delete, and most
|
|
111
|
+
incidents of this kind are replacements: an attribute that forces one changed
|
|
112
|
+
somewhere far from the resource itself.
|
|
113
|
+
|
|
114
|
+
${BOLD}If it is deliberate${OFF}
|
|
115
|
+
Say so in the commit message and this passes:
|
|
116
|
+
|
|
117
|
+
${DIM}${DESTROY_TRAILER}: <what is destroyed, and why that is acceptable>${OFF}
|
|
118
|
+
|
|
119
|
+
Take a backup first if the data matters — in dev and staging there may be no
|
|
120
|
+
final snapshot to fall back on.
|
|
121
|
+
`)
|
|
122
|
+
process.exit(1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Record an authorised destroy in the job summary, so it is visible without
|
|
126
|
+
* reading the log — the log being exactly what nobody reads. */
|
|
127
|
+
function summarise(destructive, reason) {
|
|
128
|
+
const path = process.env.GITHUB_STEP_SUMMARY
|
|
129
|
+
if (!path) return
|
|
130
|
+
const rows = destructive.map(
|
|
131
|
+
(c) => `- \`${c.address}\` (${c.replacement ? 'replaced' : 'destroyed'})`,
|
|
132
|
+
)
|
|
133
|
+
appendFileSync(
|
|
134
|
+
path,
|
|
135
|
+
`### ⚠️ Destructive plan authorised\n\n${destructive.length} stateful resource(s) will be destroyed:\n\n` +
|
|
136
|
+
`${rows.join('\n')}\n\n**${DESTROY_TRAILER}:** ${reason || ''}\n`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether a Terraform plan destroys stateful infrastructure (issue #387).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `deploy-infra.yml` runs `terraform apply -auto-approve`. Nobody reads the
|
|
7
|
+
* plan. A user-pool replacement or a database rebuild reads as an ordinary
|
|
8
|
+
* `-/+ resource` line among dozens, and the deploy reports success either way.
|
|
9
|
+
*
|
|
10
|
+
* The exposure is not theoretical. Three attributes on `aws_db_instance` —
|
|
11
|
+
* `identifier`, `db_name`, `username` — are derived from `project_name` and
|
|
12
|
+
* force **replacement**, and in dev and staging `deletion_protection` is false.
|
|
13
|
+
* So renaming the project, a plausible and innocuous-looking edit, plans a
|
|
14
|
+
* destroy of the database, and CI applies it with nobody having seen the plan.
|
|
15
|
+
*
|
|
16
|
+
* A Cognito pool replacement of exactly this shape landed unattended during the
|
|
17
|
+
* 0.50.0 upgrade. It was intended and documented; nothing about the mechanism
|
|
18
|
+
* required it to be.
|
|
19
|
+
*
|
|
20
|
+
* ## Why plain .mjs rather than TypeScript
|
|
21
|
+
*
|
|
22
|
+
* This runs in the **Plan** job, which sets up Terraform and AWS credentials
|
|
23
|
+
* and nothing else — no pnpm, no node_modules. Adding a dependency install to
|
|
24
|
+
* three jobs to run one guard is a poor trade, so the guard has no
|
|
25
|
+
* dependencies and runs on bare node.
|
|
26
|
+
*
|
|
27
|
+
* It is still tested: `cli/src/lib/destructive-plan.test.ts` imports this file
|
|
28
|
+
* directly, the same arrangement `packaged-root-assets.mjs` already uses. The
|
|
29
|
+
* logic has one home, not two.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resource types whose destruction loses data that no re-apply can recreate.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately a small, explicit list rather than a heuristic. A guard that
|
|
36
|
+
* fires on everything gets its acknowledgement trailer added by reflex, and
|
|
37
|
+
* then it protects nothing — so it fires only where a human really should stop.
|
|
38
|
+
*
|
|
39
|
+
* Everything absent from this list is configuration: Terraform rebuilds a
|
|
40
|
+
* Lambda, a security group or an IAM role from this repo. Nothing in this repo
|
|
41
|
+
* contains the rows.
|
|
42
|
+
*/
|
|
43
|
+
export const STATEFUL_RESOURCE_TYPES = [
|
|
44
|
+
// The data itself.
|
|
45
|
+
'aws_db_instance',
|
|
46
|
+
'aws_rds_cluster',
|
|
47
|
+
'aws_rds_cluster_instance',
|
|
48
|
+
'aws_dynamodb_table',
|
|
49
|
+
'aws_efs_file_system',
|
|
50
|
+
'aws_elasticache_cluster',
|
|
51
|
+
'aws_elasticache_replication_group',
|
|
52
|
+
// Identities. Replacing a pool deletes every user and every `sub` with it,
|
|
53
|
+
// orphaning anything keyed on cognito_sub (ADR-0012).
|
|
54
|
+
'aws_cognito_user_pool',
|
|
55
|
+
// Objects. A bucket destroy takes its contents; for state and media buckets
|
|
56
|
+
// that is the entire point of the bucket.
|
|
57
|
+
'aws_s3_bucket',
|
|
58
|
+
// Delegation. A hosted zone's NS assignment is not in this repo — AWS
|
|
59
|
+
// assigns it at creation and the registrar is told about it out of band.
|
|
60
|
+
// Replacing the zone issues new nameservers, so recovery is a registrar
|
|
61
|
+
// change plus propagation, not a re-apply. The zone's records ARE
|
|
62
|
+
// Terraform-managed and would come back; the delegation is what would not.
|
|
63
|
+
//
|
|
64
|
+
// aws_acm_certificate was considered and rejected: a replacement re-issues
|
|
65
|
+
// and DNS-validates automatically while the zone underneath it stands, so
|
|
66
|
+
// it passes the same test that keeps this list small — Terraform rebuilds
|
|
67
|
+
// it from this repo, unattended.
|
|
68
|
+
'aws_route53_zone',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Trailer, on its own line in a commit message, authorising this plan's
|
|
73
|
+
* destructive changes. Mirrors `Core-Divergence:` — deliberate, and recorded in
|
|
74
|
+
* history rather than argued about afterwards.
|
|
75
|
+
*/
|
|
76
|
+
export const DESTROY_TRAILER = 'Infra-Destroy'
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract the reason from an `Infra-Destroy:` trailer, or null.
|
|
80
|
+
*
|
|
81
|
+
* Anchored per line, and comment lines are dropped, for the same reason
|
|
82
|
+
* `Core-Divergence:` is: the words appearing inside prose are not an
|
|
83
|
+
* authorisation to delete a database.
|
|
84
|
+
*/
|
|
85
|
+
export function parseDestroyTrailer(commitMessage) {
|
|
86
|
+
const body = String(commitMessage || '')
|
|
87
|
+
.split('\n')
|
|
88
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
89
|
+
.join('\n')
|
|
90
|
+
const match = /^Infra-Destroy:[ \t]*(\S.*?)[ \t]*$/m.exec(body)
|
|
91
|
+
return match ? match[1] : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Classify a `terraform show -json` plan.
|
|
96
|
+
*
|
|
97
|
+
* `replace` counts as destruction: Terraform encodes it as
|
|
98
|
+
* `["delete","create"]` (or the reverse under create_before_destroy), and a
|
|
99
|
+
* replacement destroys the original exactly as surely as a bare delete. Most
|
|
100
|
+
* incidents in this class ARE replacements — nobody runs `terraform destroy` by
|
|
101
|
+
* accident; they change an attribute that forces one, far from the resource.
|
|
102
|
+
*/
|
|
103
|
+
export function checkDestructivePlan(
|
|
104
|
+
plan,
|
|
105
|
+
commitMessage = '',
|
|
106
|
+
statefulTypes = STATEFUL_RESOURCE_TYPES,
|
|
107
|
+
) {
|
|
108
|
+
const types = new Set(statefulTypes)
|
|
109
|
+
const destructive = []
|
|
110
|
+
|
|
111
|
+
for (const change of (plan && plan.resource_changes) || []) {
|
|
112
|
+
const actions = (change.change && change.change.actions) || []
|
|
113
|
+
if (!actions.includes('delete')) continue
|
|
114
|
+
if (!types.has(change.type)) continue
|
|
115
|
+
destructive.push({
|
|
116
|
+
address: change.address,
|
|
117
|
+
type: change.type,
|
|
118
|
+
replacement: actions.includes('create'),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const acknowledgedReason = parseDestroyTrailer(commitMessage)
|
|
123
|
+
return {
|
|
124
|
+
destructive,
|
|
125
|
+
acknowledgedReason,
|
|
126
|
+
blocked: destructive.length > 0 && acknowledgedReason === null,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -85,8 +85,12 @@ jobs:
|
|
|
85
85
|
EOF
|
|
86
86
|
- run: terraform init -input=false -backend-config=backend.hcl
|
|
87
87
|
timeout-minutes: 10
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
# Plan to a FILE so the guard below reads the plan that is actually
|
|
89
|
+
# applied. Variables belong here rather than on the apply: a saved plan
|
|
90
|
+
# has them baked in, and `terraform apply tfplan` takes no -var at all.
|
|
91
|
+
- name: Plan
|
|
92
|
+
run: terraform plan -input=false -no-color -out=tfplan
|
|
93
|
+
timeout-minutes: 20
|
|
90
94
|
env:
|
|
91
95
|
TF_VAR_project_name: ${{ vars.PROJECT_NAME }}
|
|
92
96
|
TF_VAR_environment: ${{ needs.resolve-environment.outputs.environment }}
|
|
@@ -100,6 +104,25 @@ jobs:
|
|
|
100
104
|
# variables.tf. Re-run this workflow (workflow_dispatch) once
|
|
101
105
|
# that PR merges and this var is set, to add the bucket policy.
|
|
102
106
|
TF_VAR_parent_cloudfront_distribution_arn: ${{ vars.PARENT_CLOUDFRONT_DISTRIBUTION_ARN || '' }}
|
|
107
|
+
# Refuse a plan that destroys stateful infrastructure without an
|
|
108
|
+
# `Infra-Destroy:` trailer authorising it (#387, reach fixed by #1123).
|
|
109
|
+
# Apply is --auto-approve and nobody reads the plan, so this is the only
|
|
110
|
+
# point at which a human decision can still be required — and it costs
|
|
111
|
+
# nothing when it fires, because no infrastructure has been touched yet.
|
|
112
|
+
#
|
|
113
|
+
# Runs on bare `node`: the script has no dependencies precisely so it can
|
|
114
|
+
# execute in a Terraform job that installs no JS toolchain. $GITHUB_WORKSPACE
|
|
115
|
+
# because this job's default working-directory is `infra/`.
|
|
116
|
+
- name: Destructive-plan guard
|
|
117
|
+
run: |
|
|
118
|
+
terraform show -json tfplan > plan.json
|
|
119
|
+
node "$GITHUB_WORKSPACE/scripts/check-destructive-plan.mjs" plan.json
|
|
120
|
+
env:
|
|
121
|
+
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
|
|
122
|
+
# Applies the plan just checked, not a fresh one — a re-plan here could
|
|
123
|
+
# differ from what the guard saw.
|
|
124
|
+
- run: terraform apply -input=false -auto-approve tfplan
|
|
125
|
+
timeout-minutes: 45
|
|
103
126
|
- name: Export outputs to GitHub Actions variables
|
|
104
127
|
env:
|
|
105
128
|
GH_TOKEN: ${{ secrets.SIBLING_GITHUB_TOKEN }}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CI runner for the destructive-plan guard (issue #387).
|
|
4
|
+
*
|
|
5
|
+
* Reads a `terraform show -json` plan and refuses it when it would destroy
|
|
6
|
+
* stateful infrastructure without an `Infra-Destroy:` trailer authorising it.
|
|
7
|
+
*
|
|
8
|
+
* Runs in the **Plan** job, before Apply, so a refusal costs nothing: no
|
|
9
|
+
* infrastructure has been touched when it fires. Apply runs with
|
|
10
|
+
* `--auto-approve`, so this is the only point at which a human decision can
|
|
11
|
+
* still be required.
|
|
12
|
+
*
|
|
13
|
+
* No dependencies, so it runs on bare node without a pnpm install — see
|
|
14
|
+
* destructive-plan.mjs for why.
|
|
15
|
+
*
|
|
16
|
+
* Usage: node check-destructive-plan.mjs <plan.json>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
20
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { DESTROY_TRAILER, checkDestructivePlan } from './destructive-plan.mjs'
|
|
22
|
+
|
|
23
|
+
const BOLD = '[1m'
|
|
24
|
+
const DIM = '[2m'
|
|
25
|
+
const RED = '[31m'
|
|
26
|
+
const OFF = '[0m'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Commit messages this deploy is carrying.
|
|
30
|
+
*
|
|
31
|
+
* A push build has no PR body, so the commit message is the only place an
|
|
32
|
+
* author can speak to CI. `github.event.before` bounds it to what this push
|
|
33
|
+
* actually added; on a first push or a force-push it is all-zeroes, and then
|
|
34
|
+
* the tip commit alone is the honest answer rather than the entire history —
|
|
35
|
+
* an `Infra-Destroy:` trailer from six months ago must not authorise today.
|
|
36
|
+
*/
|
|
37
|
+
function pushCommitMessages() {
|
|
38
|
+
const before = process.env.GITHUB_EVENT_BEFORE
|
|
39
|
+
const head = process.env.GITHUB_SHA || 'HEAD'
|
|
40
|
+
const args =
|
|
41
|
+
before && !/^0+$/.test(before)
|
|
42
|
+
? ['log', '--format=%B', `${before}..${head}`]
|
|
43
|
+
: ['log', '--format=%B', '-1']
|
|
44
|
+
try {
|
|
45
|
+
return execFileSync('git', args, { encoding: 'utf8' })
|
|
46
|
+
} catch {
|
|
47
|
+
// A shallow clone may not contain `before`. Fall back to the tip rather
|
|
48
|
+
// than treating an unreadable range as "no trailer", which would block a
|
|
49
|
+
// legitimately authorised deploy.
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync('git', ['log', '--format=%B', '-1'], { encoding: 'utf8' })
|
|
52
|
+
} catch {
|
|
53
|
+
return ''
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
const planPath = process.argv[2]
|
|
60
|
+
if (!planPath) {
|
|
61
|
+
console.error('Usage: check-destructive-plan.mjs <plan.json>')
|
|
62
|
+
process.exit(2)
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(planPath)) {
|
|
65
|
+
// Fail closed. A missing plan means the guard cannot see what is about to
|
|
66
|
+
// be applied, which is not the same as there being nothing to see.
|
|
67
|
+
console.error(`::error::Plan file ${planPath} not found — refusing to apply unexamined.`)
|
|
68
|
+
process.exit(2)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let plan
|
|
72
|
+
try {
|
|
73
|
+
plan = JSON.parse(readFileSync(planPath, 'utf8'))
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`::error::Could not parse ${planPath}: ${err.message}`)
|
|
76
|
+
process.exit(2)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = checkDestructivePlan(plan, pushCommitMessages())
|
|
80
|
+
|
|
81
|
+
if (result.destructive.length === 0) {
|
|
82
|
+
console.log('✓ destructive-plan guard: no stateful resource is destroyed by this plan.')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lines = result.destructive.map(
|
|
87
|
+
(c) => ` ${c.address} ${DIM}(${c.replacement ? 'replaced' : 'destroyed'})${OFF}`,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if (!result.blocked) {
|
|
91
|
+
console.log(
|
|
92
|
+
`⚠ destructive-plan guard: allowed by ${DESTROY_TRAILER}: ${result.acknowledgedReason}\n` +
|
|
93
|
+
lines.join('\n'),
|
|
94
|
+
)
|
|
95
|
+
summarise(result.destructive, result.acknowledgedReason)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.error(`
|
|
100
|
+
${RED}${BOLD}✗ This plan destroys stateful infrastructure.${OFF}
|
|
101
|
+
|
|
102
|
+
${lines.join('\n')}
|
|
103
|
+
|
|
104
|
+
${BOLD}Why this is blocked${OFF}
|
|
105
|
+
These resources hold data no re-apply can recreate — rows, users, objects.
|
|
106
|
+
Everything else Terraform rebuilds from this repo; these it cannot, because
|
|
107
|
+
nothing in this repo contains the data. Apply runs with --auto-approve, so
|
|
108
|
+
without this check the destroy would happen with nobody having seen the plan.
|
|
109
|
+
|
|
110
|
+
A ${BOLD}replacement${OFF} destroys the original just as surely as a delete, and most
|
|
111
|
+
incidents of this kind are replacements: an attribute that forces one changed
|
|
112
|
+
somewhere far from the resource itself.
|
|
113
|
+
|
|
114
|
+
${BOLD}If it is deliberate${OFF}
|
|
115
|
+
Say so in the commit message and this passes:
|
|
116
|
+
|
|
117
|
+
${DIM}${DESTROY_TRAILER}: <what is destroyed, and why that is acceptable>${OFF}
|
|
118
|
+
|
|
119
|
+
Take a backup first if the data matters — in dev and staging there may be no
|
|
120
|
+
final snapshot to fall back on.
|
|
121
|
+
`)
|
|
122
|
+
process.exit(1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Record an authorised destroy in the job summary, so it is visible without
|
|
126
|
+
* reading the log — the log being exactly what nobody reads. */
|
|
127
|
+
function summarise(destructive, reason) {
|
|
128
|
+
const path = process.env.GITHUB_STEP_SUMMARY
|
|
129
|
+
if (!path) return
|
|
130
|
+
const rows = destructive.map(
|
|
131
|
+
(c) => `- \`${c.address}\` (${c.replacement ? 'replaced' : 'destroyed'})`,
|
|
132
|
+
)
|
|
133
|
+
appendFileSync(
|
|
134
|
+
path,
|
|
135
|
+
`### ⚠️ Destructive plan authorised\n\n${destructive.length} stateful resource(s) will be destroyed:\n\n` +
|
|
136
|
+
`${rows.join('\n')}\n\n**${DESTROY_TRAILER}:** ${reason || ''}\n`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether a Terraform plan destroys stateful infrastructure (issue #387).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `deploy-infra.yml` runs `terraform apply -auto-approve`. Nobody reads the
|
|
7
|
+
* plan. A user-pool replacement or a database rebuild reads as an ordinary
|
|
8
|
+
* `-/+ resource` line among dozens, and the deploy reports success either way.
|
|
9
|
+
*
|
|
10
|
+
* The exposure is not theoretical. Three attributes on `aws_db_instance` —
|
|
11
|
+
* `identifier`, `db_name`, `username` — are derived from `project_name` and
|
|
12
|
+
* force **replacement**, and in dev and staging `deletion_protection` is false.
|
|
13
|
+
* So renaming the project, a plausible and innocuous-looking edit, plans a
|
|
14
|
+
* destroy of the database, and CI applies it with nobody having seen the plan.
|
|
15
|
+
*
|
|
16
|
+
* A Cognito pool replacement of exactly this shape landed unattended during the
|
|
17
|
+
* 0.50.0 upgrade. It was intended and documented; nothing about the mechanism
|
|
18
|
+
* required it to be.
|
|
19
|
+
*
|
|
20
|
+
* ## Why plain .mjs rather than TypeScript
|
|
21
|
+
*
|
|
22
|
+
* This runs in the **Plan** job, which sets up Terraform and AWS credentials
|
|
23
|
+
* and nothing else — no pnpm, no node_modules. Adding a dependency install to
|
|
24
|
+
* three jobs to run one guard is a poor trade, so the guard has no
|
|
25
|
+
* dependencies and runs on bare node.
|
|
26
|
+
*
|
|
27
|
+
* It is still tested: `cli/src/lib/destructive-plan.test.ts` imports this file
|
|
28
|
+
* directly, the same arrangement `packaged-root-assets.mjs` already uses. The
|
|
29
|
+
* logic has one home, not two.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resource types whose destruction loses data that no re-apply can recreate.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately a small, explicit list rather than a heuristic. A guard that
|
|
36
|
+
* fires on everything gets its acknowledgement trailer added by reflex, and
|
|
37
|
+
* then it protects nothing — so it fires only where a human really should stop.
|
|
38
|
+
*
|
|
39
|
+
* Everything absent from this list is configuration: Terraform rebuilds a
|
|
40
|
+
* Lambda, a security group or an IAM role from this repo. Nothing in this repo
|
|
41
|
+
* contains the rows.
|
|
42
|
+
*/
|
|
43
|
+
export const STATEFUL_RESOURCE_TYPES = [
|
|
44
|
+
// The data itself.
|
|
45
|
+
'aws_db_instance',
|
|
46
|
+
'aws_rds_cluster',
|
|
47
|
+
'aws_rds_cluster_instance',
|
|
48
|
+
'aws_dynamodb_table',
|
|
49
|
+
'aws_efs_file_system',
|
|
50
|
+
'aws_elasticache_cluster',
|
|
51
|
+
'aws_elasticache_replication_group',
|
|
52
|
+
// Identities. Replacing a pool deletes every user and every `sub` with it,
|
|
53
|
+
// orphaning anything keyed on cognito_sub (ADR-0012).
|
|
54
|
+
'aws_cognito_user_pool',
|
|
55
|
+
// Objects. A bucket destroy takes its contents; for state and media buckets
|
|
56
|
+
// that is the entire point of the bucket.
|
|
57
|
+
'aws_s3_bucket',
|
|
58
|
+
// Delegation. A hosted zone's NS assignment is not in this repo — AWS
|
|
59
|
+
// assigns it at creation and the registrar is told about it out of band.
|
|
60
|
+
// Replacing the zone issues new nameservers, so recovery is a registrar
|
|
61
|
+
// change plus propagation, not a re-apply. The zone's records ARE
|
|
62
|
+
// Terraform-managed and would come back; the delegation is what would not.
|
|
63
|
+
//
|
|
64
|
+
// aws_acm_certificate was considered and rejected: a replacement re-issues
|
|
65
|
+
// and DNS-validates automatically while the zone underneath it stands, so
|
|
66
|
+
// it passes the same test that keeps this list small — Terraform rebuilds
|
|
67
|
+
// it from this repo, unattended.
|
|
68
|
+
'aws_route53_zone',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Trailer, on its own line in a commit message, authorising this plan's
|
|
73
|
+
* destructive changes. Mirrors `Core-Divergence:` — deliberate, and recorded in
|
|
74
|
+
* history rather than argued about afterwards.
|
|
75
|
+
*/
|
|
76
|
+
export const DESTROY_TRAILER = 'Infra-Destroy'
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract the reason from an `Infra-Destroy:` trailer, or null.
|
|
80
|
+
*
|
|
81
|
+
* Anchored per line, and comment lines are dropped, for the same reason
|
|
82
|
+
* `Core-Divergence:` is: the words appearing inside prose are not an
|
|
83
|
+
* authorisation to delete a database.
|
|
84
|
+
*/
|
|
85
|
+
export function parseDestroyTrailer(commitMessage) {
|
|
86
|
+
const body = String(commitMessage || '')
|
|
87
|
+
.split('\n')
|
|
88
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
89
|
+
.join('\n')
|
|
90
|
+
const match = /^Infra-Destroy:[ \t]*(\S.*?)[ \t]*$/m.exec(body)
|
|
91
|
+
return match ? match[1] : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Classify a `terraform show -json` plan.
|
|
96
|
+
*
|
|
97
|
+
* `replace` counts as destruction: Terraform encodes it as
|
|
98
|
+
* `["delete","create"]` (or the reverse under create_before_destroy), and a
|
|
99
|
+
* replacement destroys the original exactly as surely as a bare delete. Most
|
|
100
|
+
* incidents in this class ARE replacements — nobody runs `terraform destroy` by
|
|
101
|
+
* accident; they change an attribute that forces one, far from the resource.
|
|
102
|
+
*/
|
|
103
|
+
export function checkDestructivePlan(
|
|
104
|
+
plan,
|
|
105
|
+
commitMessage = '',
|
|
106
|
+
statefulTypes = STATEFUL_RESOURCE_TYPES,
|
|
107
|
+
) {
|
|
108
|
+
const types = new Set(statefulTypes)
|
|
109
|
+
const destructive = []
|
|
110
|
+
|
|
111
|
+
for (const change of (plan && plan.resource_changes) || []) {
|
|
112
|
+
const actions = (change.change && change.change.actions) || []
|
|
113
|
+
if (!actions.includes('delete')) continue
|
|
114
|
+
if (!types.has(change.type)) continue
|
|
115
|
+
destructive.push({
|
|
116
|
+
address: change.address,
|
|
117
|
+
type: change.type,
|
|
118
|
+
replacement: actions.includes('create'),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const acknowledgedReason = parseDestroyTrailer(commitMessage)
|
|
123
|
+
return {
|
|
124
|
+
destructive,
|
|
125
|
+
acknowledgedReason,
|
|
126
|
+
blocked: destructive.length > 0 && acknowledgedReason === null,
|
|
127
|
+
}
|
|
128
|
+
}
|