@intentius/chant-lexicon-terraform 0.124.31 → 0.124.33
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/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
- package/dist/__fixtures__/terragrunt/record.js +57 -0
- package/dist/behaviour/index.js +6 -0
- package/dist/behaviour/kinds.js +194 -0
- package/dist/behaviour/predict.js +297 -0
- package/dist/behaviour/reference-catalog.js +178 -0
- package/dist/behaviour/request.js +482 -0
- package/dist/change-set.js +234 -0
- package/dist/changed-roots.js +97 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +44 -0
- package/dist/codegen/generate-cli.js +9 -0
- package/dist/codegen/generate.js +59 -0
- package/dist/codegen/package.js +43 -0
- package/dist/commands.js +106 -0
- package/dist/components/capability-plugin.d.ts +38 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +260 -0
- package/dist/composites/terraform-adopt-op.d.ts +2 -0
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/composites/terraform-adopt-op.js +153 -0
- package/dist/composites/terraform-apply-op.d.ts +2 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.js +184 -0
- package/dist/composites/terraform-pin-rollout-op.js +45 -0
- package/dist/composites/terraform-watch-op.js +219 -0
- package/dist/config.js +118 -0
- package/dist/describe-resources.js +984 -0
- package/dist/generated/index.js +1 -0
- package/dist/hcl/descend.js +208 -0
- package/dist/hcl/edges.js +287 -0
- package/dist/hcl/jsonencode.js +442 -0
- package/dist/hcl/marker.js +212 -0
- package/dist/hcl/parse.js +372 -0
- package/dist/hcl/references.js +156 -0
- package/dist/hcl/roots.js +98 -0
- package/dist/hcl/suppressions.js +189 -0
- package/dist/hcl/value.js +72 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +501 -0
- package/dist/lint/audit-lineage.js +442 -0
- package/dist/lint/post-synth/blocks.js +87 -0
- package/dist/lint/post-synth/fixtures/load.js +94 -0
- package/dist/lint/post-synth/flat-attributes.js +135 -0
- package/dist/lint/post-synth/index.js +90 -0
- package/dist/lint/post-synth/module-source.js +153 -0
- package/dist/lint/post-synth/scope.js +104 -0
- package/dist/lint/post-synth/terragrunt.js +63 -0
- package/dist/lint/post-synth/tf001.js +138 -0
- package/dist/lint/post-synth/tf002.js +143 -0
- package/dist/lint/post-synth/tf003.js +60 -0
- package/dist/lint/post-synth/tf004.js +54 -0
- package/dist/lint/post-synth/tf005.js +68 -0
- package/dist/lint/post-synth/tf006.js +43 -0
- package/dist/lint/post-synth/tf007.js +66 -0
- package/dist/lint/post-synth/tf008.js +64 -0
- package/dist/lint/post-synth/tf009.js +49 -0
- package/dist/lint/post-synth/tf010.js +40 -0
- package/dist/lint/post-synth/tf011.js +38 -0
- package/dist/lint/post-synth/tf012.js +36 -0
- package/dist/lint/post-synth/tf013.js +53 -0
- package/dist/lint/post-synth/tf014.js +61 -0
- package/dist/lint/post-synth/tf015.js +62 -0
- package/dist/lint/post-synth/tf016.js +66 -0
- package/dist/lint/post-synth/tf017.js +39 -0
- package/dist/lint/post-synth/tf018.js +63 -0
- package/dist/lint/post-synth/tf019.js +60 -0
- package/dist/lint/post-synth/tf020.js +103 -0
- package/dist/lint/post-synth/tf021.js +117 -0
- package/dist/lint/post-synth/tf022.js +51 -0
- package/dist/lint/post-synth/tf024.js +62 -0
- package/dist/lint/post-synth/tf025.js +83 -0
- package/dist/lint/post-synth/tf026.js +74 -0
- package/dist/lint/post-synth/tf027.js +66 -0
- package/dist/lint/post-synth/tf028.js +83 -0
- package/dist/lint/post-synth/tf029.js +73 -0
- package/dist/lint/post-synth/tf030.js +253 -0
- package/dist/lint/post-synth/tf031.js +332 -0
- package/dist/lint/post-synth/tf032.js +201 -0
- package/dist/lint/post-synth/tf033.js +126 -0
- package/dist/lint/post-synth/tf034.js +45 -0
- package/dist/lint/post-synth/tf035.js +63 -0
- package/dist/lint/post-synth/tf036.js +97 -0
- package/dist/lint/post-synth/tf037.js +80 -0
- package/dist/lint/post-synth/tf038.js +63 -0
- package/dist/lint/post-synth/tf039.js +55 -0
- package/dist/lint/post-synth/tf040.js +67 -0
- package/dist/lint/post-synth/tf041.js +53 -0
- package/dist/lint/post-synth/tf042.js +38 -0
- package/dist/lint/post-synth/tf043.js +45 -0
- package/dist/lint/post-synth/tf044.js +45 -0
- package/dist/lint/post-synth/tf045.js +44 -0
- package/dist/lint/rules/fixtures/load.js +50 -0
- package/dist/lint/rules/index.js +4 -0
- package/dist/lint/rules/plan-before-apply.js +136 -0
- package/dist/lint/secret-shape.js +149 -0
- package/dist/live-ownership.js +57 -0
- package/dist/lsp/completions.js +95 -0
- package/dist/lsp/context.js +358 -0
- package/dist/lsp/hover.js +45 -0
- package/dist/lsp/option-keys.js +93 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.js +30 -0
- package/dist/op/activities/live-detect.js +143 -0
- package/dist/op/activities/pin-rollout.js +45 -0
- package/dist/op/activities/terraform.js +1270 -0
- package/dist/op/activity-contracts.js +162 -0
- package/dist/op/adoption.js +272 -0
- package/dist/op/builders.js +125 -0
- package/dist/op/resolve-root-mode.js +77 -0
- package/dist/package-cli.js +16 -0
- package/dist/pin/edit-ts.js +76 -0
- package/dist/pin/edit.js +118 -0
- package/dist/pin/forge.js +65 -0
- package/dist/pin/index.js +16 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +382 -0
- package/dist/pin/scan.js +287 -0
- package/dist/pin/source.js +171 -0
- package/dist/pin/waves.js +109 -0
- package/dist/plan-digest.js +67 -0
- package/dist/plugin.js +323 -0
- package/dist/serializer.js +23 -0
- package/dist/state-ownership.js +29 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +290 -0
- package/dist/terragrunt/index.js +10 -0
- package/dist/terragrunt/mocks.js +270 -0
- package/dist/terragrunt/run.js +264 -0
- package/dist/terragrunt/units.js +195 -0
- package/dist/terragrunt/wave.js +125 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +31 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/components/capability-plugin.test.ts +97 -2
- package/src/components/capability-plugin.ts +117 -12
- package/src/components/pr-loop.tofu.test.ts +51 -11
- package/src/composites/terraform-adopt-op.test.ts +6 -0
- package/src/composites/terraform-adopt-op.ts +7 -2
- package/src/composites/terraform-apply-op.test.ts +6 -0
- package/src/composites/terraform-apply-op.ts +7 -2
- package/src/index.ts +1 -0
- package/src/pin/rollout.ts +2 -1
- package/src/terragrunt/affected.test.ts +11 -0
- package/src/terragrunt/affected.ts +2 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAiC,cAAc,EAAE,MAAM,wCAAwC,CAAC;AAGxH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,+CAA+C,CAAC;AAWzG,0CAA0C;AAC1C,MAAM,WAAW,uBAAuB;IACtC,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,6FAA6F;AAC7F,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,YAAY,EAAE,OAAO,CAAC;IACtB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAsCD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,OAAO,GAAG,cAAc,CAerE;AAwBD;;;;;;GAMG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;aAEpB,IAAI,EAAE,MAAM;IADxC,QAAQ,CAAC,SAAS,QAAQ;IAC1B,YAA4B,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAGxD;CACF;AA+CD,eAAO,MAAM,wBAAwB,EAAE,UAAU,CAAC,uBAAuB,EAAE,wBAAwB,CAmDlG,CAAC;AAEF,eAAO,MAAM,uBAAuB;aAClC,KAAK,YAAG,iBAAiB;CACjB,CAAC;AAEX,eAAO,MAAM,yBAAyB,EAAE,gBAWvC,CAAC"}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `terraformCapabilityPlugin` — the terraform lexicon's capability plugin
|
|
3
|
+
* (#3049). It contributes `terraform-apply`, which lets a component deploy a
|
|
4
|
+
* root named in `terraform.roots`, so a change across many roots runs as a
|
|
5
|
+
* `chant components fan-out`: in an order derived from each component's
|
|
6
|
+
* `dependsOn`, and, with `--wave-gate`, a gate per wave bound to the wave's
|
|
7
|
+
* set digest.
|
|
8
|
+
*
|
|
9
|
+
* The step is `{ kind: "terraform-apply", root: "<name>" }`, with optional
|
|
10
|
+
* `vars` (passed as `-var`, so `stackOutput()` can hand one root's output to
|
|
11
|
+
* another) and `cwd`. It works the same for `terraform`, `tofu` and
|
|
12
|
+
* `choudoufu`, since all three go through the lexicon's activities.
|
|
13
|
+
*
|
|
14
|
+
* ## Plan, then apply exactly that plan
|
|
15
|
+
*
|
|
16
|
+
* `plan` runs `init` and `plan -out`, and answers with the root as the member
|
|
17
|
+
* and `terraformPlanDigest` as its digest: the value a `TerraformApplyOp`
|
|
18
|
+
* gate on this root alone binds (#2300). A gated-wave fan-out folds those into
|
|
19
|
+
* the wave's set digest and hands the plan file back to `run`, which applies
|
|
20
|
+
* that file and nothing else. terraform and tofu refuse a saved plan whose
|
|
21
|
+
* state moved since it was written; choudoufu re-plans against the live
|
|
22
|
+
* system and refuses with exit 3 when its fresh plan differs (choudoufu
|
|
23
|
+
* #878). Either refusal fails the step. choudoufu's refusal of a plan the
|
|
24
|
+
* wave handed `run` throws {@link TerraformPlanMovedError}, so the command
|
|
25
|
+
* that applied it exits 3 (#3997). terraform's and tofu's stale-plan error
|
|
26
|
+
* is not classified and exits 1.
|
|
27
|
+
*
|
|
28
|
+
* Run without a wave plan (`chant run --components`, or a fan-out with no
|
|
29
|
+
* wave gate), `run` plans and applies in one go, which is what `terraform
|
|
30
|
+
* apply` with no plan file does too.
|
|
31
|
+
*
|
|
32
|
+
* ## What the plan says about the root's outputs (#3999)
|
|
33
|
+
*
|
|
34
|
+
* `plan` answers the outputs the root will have once that plan applies
|
|
35
|
+
* (`terraformPlannedOutputs`): `show -json`'s `planned_values.outputs`, less
|
|
36
|
+
* each output whose `output_changes` entry is unknown after apply. A pull
|
|
37
|
+
* request's plan reads a dependent's `stackOutput()` references from there
|
|
38
|
+
* when both roots are in the change.
|
|
39
|
+
*
|
|
40
|
+
* A root whose `vars` read an output not known yet (`DeployContext.unresolvedInputs`)
|
|
41
|
+
* is not planned: terraform takes no unknown variable. Its member gets a hole
|
|
42
|
+
* naming the roots it waits on and no artifact, and the run that applies it
|
|
43
|
+
* plans it once their outputs are known.
|
|
44
|
+
*
|
|
45
|
+
* ## choudoufu roots in a gated wave (#3459)
|
|
46
|
+
*
|
|
47
|
+
* A live choudoufu root that `live-plan-set` can plan as chant would (no
|
|
48
|
+
* `vars`, no var files, no backend config; see `choudoufuWaveRoot`) goes
|
|
49
|
+
* through choudoufu's own set commands (choudoufu#1754), which need
|
|
50
|
+
* choudoufu v0.22.0 (`MIN_CHOUDOUFU_WAVES_VERSION`):
|
|
51
|
+
*
|
|
52
|
+
* - `plan` runs `live-plan-set -json` over the root as a one-root set. The
|
|
53
|
+
* wave gate still binds chant's `terraformPlanDigest` of the plan the
|
|
54
|
+
* document embeds, the same string a single-root gate binds. choudoufu's
|
|
55
|
+
* set digest is kept for the apply, and its root digest is the change-set
|
|
56
|
+
* part's `nativeDigest`.
|
|
57
|
+
* - `run` hands that document and choudoufu's set digest to
|
|
58
|
+
* `live-wave-apply`, which plans the root again and applies nothing when
|
|
59
|
+
* the plan moved (exit 3).
|
|
60
|
+
* - choudoufu's resume file is kept in the fan-out's attempt record
|
|
61
|
+
* (`DeployContext.carry`) after every apply, landed or not, and written
|
|
62
|
+
* back on the next attempt when it was written for the same set digest.
|
|
63
|
+
*
|
|
64
|
+
* chant's waves stay the order. Each root is its own one-root set, so
|
|
65
|
+
* choudoufu's wave over it is always wave 1.
|
|
66
|
+
*/
|
|
67
|
+
import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
|
|
68
|
+
import { ownPackageVersion } from "@intentius/chant/components/capability-plugin";
|
|
69
|
+
import { choudoufuPlanSet, choudoufuWaveApply, choudoufuWaveRoot, terraformApply, terraformInit, terraformOutputs, terraformPlan, } from "../op/activities/terraform.js";
|
|
70
|
+
const rootArgs = (input) => ({ root: input.root, ...(input.cwd ? { cwd: input.cwd } : {}) });
|
|
71
|
+
function requireRoot(input) {
|
|
72
|
+
if (typeof input.root !== "string" || input.root === "") {
|
|
73
|
+
throw new Error('terraform-apply: "root" is required and names an entry in terraform.roots');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function planRoot(input) {
|
|
77
|
+
requireRoot(input);
|
|
78
|
+
await terraformInit(rootArgs(input));
|
|
79
|
+
const planned = await terraformPlan({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
|
|
80
|
+
return {
|
|
81
|
+
planFile: planned.planFile,
|
|
82
|
+
planDigest: planned.planDigest,
|
|
83
|
+
...(planned.changeSet ? { changeSet: planned.changeSet } : {}),
|
|
84
|
+
json: planned.json,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
/** Whether an `after_unknown` value marks anything unknown: `true`, or `true` anywhere inside. */
|
|
89
|
+
function anyUnknown(value) {
|
|
90
|
+
if (value === true)
|
|
91
|
+
return true;
|
|
92
|
+
if (Array.isArray(value))
|
|
93
|
+
return value.some(anyUnknown);
|
|
94
|
+
if (isRecord(value))
|
|
95
|
+
return Object.values(value).some(anyUnknown);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The outputs a root will have once the plan `json` (`show -json`) applies
|
|
100
|
+
* (#3999). An output is known when `planned_values.outputs` gives its value
|
|
101
|
+
* and its `output_changes` entry marks no part of it unknown after apply.
|
|
102
|
+
* Every other output the plan names is unknown, the whole output even when
|
|
103
|
+
* only a part of it is.
|
|
104
|
+
*/
|
|
105
|
+
export function terraformPlannedOutputs(json) {
|
|
106
|
+
const doc = isRecord(json) ? json : {};
|
|
107
|
+
const values = isRecord(doc.planned_values) && isRecord(doc.planned_values.outputs) ? doc.planned_values.outputs : {};
|
|
108
|
+
const changes = isRecord(doc.output_changes) ? doc.output_changes : {};
|
|
109
|
+
const known = {};
|
|
110
|
+
const unknown = [];
|
|
111
|
+
for (const name of [...new Set([...Object.keys(values), ...Object.keys(changes)])].sort()) {
|
|
112
|
+
const change = changes[name];
|
|
113
|
+
const planned = values[name];
|
|
114
|
+
if (isRecord(change) && anyUnknown(change.after_unknown))
|
|
115
|
+
unknown.push(name);
|
|
116
|
+
else if (isRecord(planned) && "value" in planned)
|
|
117
|
+
known[name] = planned.value;
|
|
118
|
+
else if (isRecord(change) && "after" in change)
|
|
119
|
+
known[name] = change.after;
|
|
120
|
+
else
|
|
121
|
+
unknown.push(name);
|
|
122
|
+
}
|
|
123
|
+
return { known, unknown };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The member of a root whose `vars` read outputs not known yet (#3999): no
|
|
127
|
+
* plan, a hole naming whose outputs it waits on, and a digest over the root,
|
|
128
|
+
* the vars as resolved and those names.
|
|
129
|
+
*/
|
|
130
|
+
function waitingRoot(ctx, input, unresolved) {
|
|
131
|
+
const vars = Object.fromEntries(Object.entries(input.vars ?? {}).map(([k, v]) => [k, v === undefined ? null : v]));
|
|
132
|
+
const planDigest = computePlanDigest("terraform-apply-unresolved", { env: ctx.env, root: input.root, vars, unresolved });
|
|
133
|
+
const changeSet = {
|
|
134
|
+
member: {
|
|
135
|
+
member: input.root,
|
|
136
|
+
lexicon: "terraform",
|
|
137
|
+
planner: "chant",
|
|
138
|
+
status: "planned",
|
|
139
|
+
planDigest,
|
|
140
|
+
holes: [{ address: input.root, type: "terraform_root", reason: `inputs from ${unresolved.join(", ")}; planned again once they apply` }],
|
|
141
|
+
},
|
|
142
|
+
entries: [],
|
|
143
|
+
};
|
|
144
|
+
return { member: input.root, planDigest, changeSet };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* choudoufu refused the plan `run` was handed by its wave (its exit 3):
|
|
148
|
+
* the live system moved since the plan, or the plan names another estate.
|
|
149
|
+
* Nothing was applied. `planMoved` is core's mark for a moved plan
|
|
150
|
+
* (`isPlanMovedError`), so the command that applied it exits 3, as for the
|
|
151
|
+
* other capabilities that refuse a moved plan (#3997).
|
|
152
|
+
*/
|
|
153
|
+
export class TerraformPlanMovedError extends Error {
|
|
154
|
+
constructor(root, message) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.root = root;
|
|
157
|
+
this.planMoved = true;
|
|
158
|
+
this.name = "TerraformPlanMovedError";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** `live-wave-apply`'s exit status for a set whose plan moved (`CHOUDOUFU_WAVE_EXIT.setMoved`). */
|
|
162
|
+
const CHOUDOUFU_SET_MOVED_EXIT = 3;
|
|
163
|
+
function isPlannedRoot(value) {
|
|
164
|
+
const v = value;
|
|
165
|
+
return typeof v?.planFile === "string" && typeof v.planDigest === "string";
|
|
166
|
+
}
|
|
167
|
+
function isChoudoufuWavePlan(value) {
|
|
168
|
+
const v = value;
|
|
169
|
+
return (typeof v?.planDigest === "string" &&
|
|
170
|
+
typeof v.choudoufu?.planSetFile === "string" &&
|
|
171
|
+
typeof v.choudoufu.setDigest === "string");
|
|
172
|
+
}
|
|
173
|
+
/** Apply a choudoufu root through `live-wave-apply`, carrying its resume file either way. */
|
|
174
|
+
async function runChoudoufuWave(ctx, input, planned) {
|
|
175
|
+
const applied = await choudoufuWaveApply({
|
|
176
|
+
...rootArgs(input),
|
|
177
|
+
planSetFile: planned.choudoufu.planSetFile,
|
|
178
|
+
setDigest: planned.choudoufu.setDigest,
|
|
179
|
+
...(ctx.carried?.[input.root] !== undefined ? { carried: ctx.carried[input.root] } : {}),
|
|
180
|
+
});
|
|
181
|
+
if (applied.carry)
|
|
182
|
+
ctx.carry?.(input.root, applied.carry);
|
|
183
|
+
if (!applied.landed) {
|
|
184
|
+
const message = `terraform-apply: root "${input.root}": ${applied.message}`;
|
|
185
|
+
if (applied.exitCode === CHOUDOUFU_SET_MOVED_EXIT)
|
|
186
|
+
throw new TerraformPlanMovedError(input.root, message);
|
|
187
|
+
throw new Error(message);
|
|
188
|
+
}
|
|
189
|
+
const outputs = await terraformOutputs(rootArgs(input));
|
|
190
|
+
return { root: input.root, planDigest: planned.planDigest, fromWavePlan: true, outputs };
|
|
191
|
+
}
|
|
192
|
+
export const terraformApplyCapability = {
|
|
193
|
+
kind: "terraform-apply",
|
|
194
|
+
// A root's apply has no undo short of applying the previous configuration,
|
|
195
|
+
// which is a change of its own. COMP003 asks the component to say so.
|
|
196
|
+
rollbackPolicy: "needs-opt-out",
|
|
197
|
+
async plan(ctx, input) {
|
|
198
|
+
requireRoot(input);
|
|
199
|
+
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
200
|
+
if (unresolved.length > 0)
|
|
201
|
+
return waitingRoot(ctx, input, unresolved);
|
|
202
|
+
const wave = await choudoufuWaveRoot({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
|
|
203
|
+
if (wave.wave) {
|
|
204
|
+
const set = await choudoufuPlanSet(rootArgs(input));
|
|
205
|
+
const artifact = {
|
|
206
|
+
planDigest: set.planDigest,
|
|
207
|
+
choudoufu: { planSetFile: set.planSetFile, setDigest: set.setDigest },
|
|
208
|
+
};
|
|
209
|
+
return { member: input.root, planDigest: set.planDigest, artifact, changeSet: set.changeSet, outputs: terraformPlannedOutputs(set.json) };
|
|
210
|
+
}
|
|
211
|
+
const { changeSet, json, ...planned } = await planRoot(input);
|
|
212
|
+
return {
|
|
213
|
+
member: input.root,
|
|
214
|
+
planDigest: planned.planDigest,
|
|
215
|
+
artifact: planned,
|
|
216
|
+
...(changeSet ? { changeSet } : {}),
|
|
217
|
+
outputs: terraformPlannedOutputs(json),
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
// The root's outputs as its state holds them now (#3183): what a dependent
|
|
221
|
+
// planned before this root applies reads through `stackOutput()`.
|
|
222
|
+
async outputs(_ctx, input) {
|
|
223
|
+
requireRoot(input);
|
|
224
|
+
await terraformInit(rootArgs(input));
|
|
225
|
+
return terraformOutputs(rootArgs(input));
|
|
226
|
+
},
|
|
227
|
+
async run(ctx, input) {
|
|
228
|
+
const fromWave = ctx.plans?.[input.root];
|
|
229
|
+
if (isChoudoufuWavePlan(fromWave))
|
|
230
|
+
return runChoudoufuWave(ctx, input, fromWave);
|
|
231
|
+
const planned = isPlannedRoot(fromWave) ? fromWave : await planRoot(input).then(({ planFile, planDigest }) => ({ planFile, planDigest }));
|
|
232
|
+
const applied = await terraformApply({ ...rootArgs(input), planFile: planned.planFile });
|
|
233
|
+
if (!applied.applied) {
|
|
234
|
+
const message = `terraform-apply: root "${input.root}" refused the plan it was given (${applied.refused ?? "refused"}). ` +
|
|
235
|
+
(applied.refusal ?? "");
|
|
236
|
+
// A plan the wave approved that choudoufu refuses moved since the
|
|
237
|
+
// approval. One this run made itself is planned afresh by the next run.
|
|
238
|
+
if (applied.refused !== undefined && isPlannedRoot(fromWave))
|
|
239
|
+
throw new TerraformPlanMovedError(input.root, message);
|
|
240
|
+
throw new Error(message);
|
|
241
|
+
}
|
|
242
|
+
const outputs = await terraformOutputs(rootArgs(input));
|
|
243
|
+
return { root: input.root, planDigest: planned.planDigest, fromWavePlan: isPlannedRoot(fromWave), outputs };
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
export const TERRAFORM_VERB_FAMILIES = {
|
|
247
|
+
apply: ["terraform-apply"],
|
|
248
|
+
};
|
|
249
|
+
export const terraformCapabilityPlugin = {
|
|
250
|
+
name: "terraform",
|
|
251
|
+
get version() {
|
|
252
|
+
return ownPackageVersion(import.meta.url);
|
|
253
|
+
},
|
|
254
|
+
capabilities() {
|
|
255
|
+
return [terraformApplyCapability];
|
|
256
|
+
},
|
|
257
|
+
families() {
|
|
258
|
+
return TERRAFORM_VERB_FAMILIES;
|
|
259
|
+
},
|
|
260
|
+
};
|
|
@@ -137,6 +137,8 @@ export interface TerraformAdoptOpConfig {
|
|
|
137
137
|
*/
|
|
138
138
|
compensate?: boolean | {
|
|
139
139
|
command?: string;
|
|
140
|
+
/** Run the rollback command in a container pinned by digest (#4015), as `shell()`'s `image` does. */
|
|
141
|
+
image?: string;
|
|
140
142
|
};
|
|
141
143
|
}
|
|
142
144
|
export interface TerraformAdoptOpResources {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terraform-adopt-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-adopt-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2FG;AAEH,OAAO,EAA6B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAQ5E,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,wGAAwG;IACxG,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG;
|
|
1
|
+
{"version":3,"file":"terraform-adopt-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-adopt-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2FG;AAEH,OAAO,EAA6B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAQ5E,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,wGAAwG;IACxG,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,qGAAqG;QACrG,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,yBAAyB;IACxC,wEAAwE;IACxE,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,yBAAyB,CAmE1F"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformAdoptOp composite (#2105): claim the live resources a choudoufu
|
|
3
|
+
* estate's configuration already describes.
|
|
4
|
+
*
|
|
5
|
+
* The reconcile position on the lifecycle dial, for the one backend that has
|
|
6
|
+
* a typed path from live back to the configuration. chant #2089 deferred
|
|
7
|
+
* reconcile for the terraform lexicon because stock Terraform has none: a live
|
|
8
|
+
* resource nobody has imported is knowable only as HCL somebody writes, and
|
|
9
|
+
* generating that is a different problem. choudoufu changes the question
|
|
10
|
+
* rather than answering the old one. Ownership there is two tags on the
|
|
11
|
+
* resource (`live/MARKERS.md`), `live-plan` already names every live resource
|
|
12
|
+
* that exactly matches a declared block and carries no marker, and it prints
|
|
13
|
+
* the two values that would claim it. Cloud-to-code here is "claim what the
|
|
14
|
+
* configuration already describes" — a tag write, not a regeneration.
|
|
15
|
+
*
|
|
16
|
+
* Phases: Init, Check, Ledger, Gate, Adopt.
|
|
17
|
+
*
|
|
18
|
+
* - **Init** runs `terraform init`, the same step `TerraformApplyOp` and
|
|
19
|
+
* `TerraformWatchOp` open with. The Ledger step's `live-plan` needs the
|
|
20
|
+
* provider schema to discover markers on the live resources it reads, and
|
|
21
|
+
* a checkout that has never run `init` has no `.terraform` directory to
|
|
22
|
+
* read one from — which is every fresh CI checkout, not a GitLab
|
|
23
|
+
* peculiarity (#2302). Without this phase `live-plan` fails outright:
|
|
24
|
+
* `Error: Provider unavailable for marker discovery`. This composite had
|
|
25
|
+
* no Init phase because nothing had ever run `live-adopt` before
|
|
26
|
+
* INTENTIUS/choudoufu#1026 did, so the gap survived every guard that only
|
|
27
|
+
* read the generated YAML.
|
|
28
|
+
* - **Check** runs `live-check`, which makes no cloud calls and says whether
|
|
29
|
+
* this configuration can move under markers at all. It runs first because
|
|
30
|
+
* a root that `live-check` refuses will produce an adoption ledger that
|
|
31
|
+
* reads as authoritative and is not, and the cheapest place to find that
|
|
32
|
+
* out is before the estate-wide sweep.
|
|
33
|
+
* - **Ledger** runs `live-plan` with `adoptionOnly`, so the run's human half
|
|
34
|
+
* is choudoufu's own adoption ledger (GitHub issue #587) and its machine
|
|
35
|
+
* half is the bound/omissions/unowned document (#788). The ledger is the
|
|
36
|
+
* approval subject; the document is what the Adopt step acts on.
|
|
37
|
+
* - **Gate** always. Adoption writes tags onto resources this estate does
|
|
38
|
+
* not yet own, which is the moment the estate's boundary moves, and no
|
|
39
|
+
* reading of that is routine enough to skip. There is no `gate: "never"`
|
|
40
|
+
* here, unlike `TerraformApplyOp`, so every run stops at the gate until
|
|
41
|
+
* someone has approved it. A gate is a fact on the gate ledger (#2119):
|
|
42
|
+
* `chant run` records that the run is waiting, ends with status `gated`
|
|
43
|
+
* and exits 3, `chant approve <op> <gate>` writes the resolution, and the
|
|
44
|
+
* next run reads it and walks through to the Adopt phase.
|
|
45
|
+
* - **Adopt** writes the markers. See `choudoufuAdopt`'s own doc for the
|
|
46
|
+
* mechanism and why it is the tag write rather than choudoufu's `adopt`
|
|
47
|
+
* policy verb.
|
|
48
|
+
*
|
|
49
|
+
* ## The adopt mechanism, and why it is not the policy verb
|
|
50
|
+
*
|
|
51
|
+
* choudoufu's `policy` block has an `adopt` verb for the declared-but-unmarked
|
|
52
|
+
* quadrant, and its migration guide names `policy { declared_untagged =
|
|
53
|
+
* "adopt" }` as the way to claim a whole estate at once. That is the smaller
|
|
54
|
+
* write, and #2105 tried to reach it from outside the configuration. It is not
|
|
55
|
+
* reachable: a `live` block in a terraform override file is dropped in silence
|
|
56
|
+
* (choudoufu's `Module.mergeFile` has no `Lives` case), a second primary file
|
|
57
|
+
* carrying one is refused as a duplicate, and a sidecar beside an in-block
|
|
58
|
+
* form is refused as two sources of truth. What is left is editing the root's
|
|
59
|
+
* own checked-in live configuration around an apply, which leaves an estate
|
|
60
|
+
* declaring "adopt everything declared and unmarked" if the run dies between
|
|
61
|
+
* the two edits.
|
|
62
|
+
*
|
|
63
|
+
* So the mechanism is the tag write, which choudoufu's migration guide calls
|
|
64
|
+
* the whole contract: "There is no `choudoufu adopt` command and no need for
|
|
65
|
+
* one. Two tags is the whole contract, so any tool that writes two tags can
|
|
66
|
+
* adopt a resource." Each write runs the paste-ready command choudoufu itself
|
|
67
|
+
* printed in the ledger, carrying its own provider configuration's region and
|
|
68
|
+
* endpoint. `choudoufuAdopt` has the measurements behind that ruling.
|
|
69
|
+
*
|
|
70
|
+
* ## Ambiguity is reported, never resolved
|
|
71
|
+
*
|
|
72
|
+
* Two live resources at one declared identity are two `unowned` entries with
|
|
73
|
+
* the same address, and no single tag write claims that address. Those
|
|
74
|
+
* candidates never enter the adoptable set; the Adopt step is handed them
|
|
75
|
+
* separately so the Op's own result names what it declined and why, beside
|
|
76
|
+
* what it wrote.
|
|
77
|
+
*
|
|
78
|
+
* ## Compensation
|
|
79
|
+
*
|
|
80
|
+
* Refused without a command, the same stance `TerraformApplyOp` takes.
|
|
81
|
+
* Un-adopting is `untag`: dropping this estate's marker from a live resource,
|
|
82
|
+
* which is a decision about the estate rather than something chant can
|
|
83
|
+
* synthesize. It is also not always the right undo — a resource that was
|
|
84
|
+
* already carrying the markers before this run is not one this run adopted.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* import { TerraformAdoptOp } from "@intentius/chant-lexicon-terraform";
|
|
89
|
+
*
|
|
90
|
+
* export const { op } = TerraformAdoptOp({ name: "estate-adopt", root: "estate" });
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
import { Op, phase, gate, activity } from "@intentius/chant/op";
|
|
94
|
+
import { terraformInit as initStep, choudoufuLiveCheck as checkStep, choudoufuLivePlan as ledgerStep, choudoufuAdopt as adoptStep, } from "../op/builders.js";
|
|
95
|
+
export function TerraformAdoptOp(config) {
|
|
96
|
+
const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
|
|
97
|
+
const compensateImage = typeof config.compensate === "object" ? config.compensate.image : undefined;
|
|
98
|
+
if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
|
|
99
|
+
throw new Error(`TerraformAdoptOp "${config.name}": compensate is enabled, but there is no automatic undo for an ` +
|
|
100
|
+
`adoption — un-adopting is untag, removing this estate's ownership marker from a live resource, ` +
|
|
101
|
+
`which is a decision about the estate rather than something chant can synthesize, and which this ` +
|
|
102
|
+
`Op cannot scope for you (a resource that already carried the markers is not one this run ` +
|
|
103
|
+
`claimed). Either supply compensate: { command: "..." } with an undo of your own, or set ` +
|
|
104
|
+
`compensate: false.`);
|
|
105
|
+
}
|
|
106
|
+
const where = config.cwd ? { cwd: config.cwd } : {};
|
|
107
|
+
const estate = config.estate ? { estate: config.estate } : {};
|
|
108
|
+
const check = checkStep(config.root, { ...where });
|
|
109
|
+
check.outcomeAttribute = { name: "Refused", from: "refused" };
|
|
110
|
+
// `id` is what makes `.out` legal. The Adopt step below reads two fields of
|
|
111
|
+
// this one: the adoptable matches, and the contested candidates it must
|
|
112
|
+
// report and never write.
|
|
113
|
+
const ledger = ledgerStep(config.root, { ...where, ...estate, adoptionOnly: true, id: "ledger" });
|
|
114
|
+
ledger.outcomeAttribute = [
|
|
115
|
+
{ name: "Adoptable", from: "adoptable" },
|
|
116
|
+
{ name: "Ambiguous", from: "ambiguous" },
|
|
117
|
+
];
|
|
118
|
+
const adopt = adoptStep(config.root, {
|
|
119
|
+
...where,
|
|
120
|
+
adoptions: ledger.out.adoptions,
|
|
121
|
+
contested: ledger.out.contested,
|
|
122
|
+
});
|
|
123
|
+
adopt.outcomeAttribute = { name: "Adopted", from: "adoptedCount" };
|
|
124
|
+
const op = Op({
|
|
125
|
+
name: config.name,
|
|
126
|
+
overview: `Adopt the live resources the "${config.root}" choudoufu estate already declares`,
|
|
127
|
+
labels: {
|
|
128
|
+
Adopt: "true",
|
|
129
|
+
TerraformRoot: config.root,
|
|
130
|
+
TerraformMode: "live",
|
|
131
|
+
},
|
|
132
|
+
phases: [
|
|
133
|
+
phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
|
|
134
|
+
phase("Check", [check]),
|
|
135
|
+
phase("Ledger", [ledger]),
|
|
136
|
+
phase("Gate", [
|
|
137
|
+
gate(config.gateName ?? config.signalName ?? `approve-${config.name}`, {
|
|
138
|
+
...(config.gateTimeout ? { timeout: config.gateTimeout } : {}),
|
|
139
|
+
description: config.gateDescription ??
|
|
140
|
+
`Approve adopting live resources into the "${config.root}" estate. The Ledger phase's ` +
|
|
141
|
+
`adoption ledger is what is being approved: every resource it lists as adoptable gets this ` +
|
|
142
|
+
`estate's tofu-estate and tofu-address tags written onto it. The Adoptable and Ambiguous ` +
|
|
143
|
+
`run outcomes from that phase are its counts; an ambiguous address is never adopted.`,
|
|
144
|
+
}),
|
|
145
|
+
]),
|
|
146
|
+
phase("Adopt", [adopt]),
|
|
147
|
+
],
|
|
148
|
+
...(compensateCommand
|
|
149
|
+
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand, ...(compensateImage ? { image: compensateImage } : {}) })])] }
|
|
150
|
+
: {}),
|
|
151
|
+
});
|
|
152
|
+
return { op };
|
|
153
|
+
}
|
|
@@ -148,6 +148,8 @@ export interface TerraformApplyOpConfig {
|
|
|
148
148
|
*/
|
|
149
149
|
compensate?: boolean | {
|
|
150
150
|
command?: string;
|
|
151
|
+
/** Run the rollback command in a container pinned by digest (#4015), as `shell()`'s `image` does. */
|
|
152
|
+
image?: string;
|
|
151
153
|
};
|
|
152
154
|
}
|
|
153
155
|
export interface TerraformApplyOpResources {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terraform-apply-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-apply-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgFG;AAEH,OAAO,EAA6B,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAW/F;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,QAAQ,GAAG,OAAO,CAAC;AAElE,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,+GAA+G;IAC/G,IAAI,EAAE,MAAM,CAAC;IACb,0GAA0G;IAC1G,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,iBAAiB,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG;
|
|
1
|
+
{"version":3,"file":"terraform-apply-op.d.ts","sourceRoot":"","sources":["../../src/composites/terraform-apply-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgFG;AAEH,OAAO,EAA6B,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAW/F;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,QAAQ,GAAG,OAAO,CAAC;AAElE,MAAM,WAAW,sBAAsB;IACrC,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,+GAA+G;IAC/G,IAAI,EAAE,MAAM,CAAC;IACb,0GAA0G;IAC1G,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,iBAAiB,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,qGAAqG;QACrG,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,yBAAyB;IACxC,mEAAmE;IACnE,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,yBAAyB,CAkH1F"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformApplyOp composite (#2086), init, plan, approve, apply for one root
|
|
3
|
+
* module named in `terraform.roots`.
|
|
4
|
+
*
|
|
5
|
+
* Authority stays with terraform: chant runs the binary, reads its plan, and
|
|
6
|
+
* hands the same saved plan to apply. The Apply step never applies something
|
|
7
|
+
* no gate saw, because it takes the Plan step's own `planFile` as a
|
|
8
|
+
* `StepOutputRef`.
|
|
9
|
+
*
|
|
10
|
+
* Phases: Init, Plan, Gate, Apply. One shape, both kinds of root. A live root
|
|
11
|
+
* (#2103, `terraform.binary: "choudoufu"` plus a declared estate) ran a
|
|
12
|
+
* different shape for one release, because choudoufu refused `-out` and
|
|
13
|
+
* `apply <planfile>`; choudoufu v0.13.0 admits both under a `live` block
|
|
14
|
+
* ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878),
|
|
15
|
+
* PR 889), so the plan-file discipline is back and the two shapes are one
|
|
16
|
+
* again.
|
|
17
|
+
*
|
|
18
|
+
* **What the approval covers on a live root.** `plan -out=<planFile>` writes
|
|
19
|
+
* stock's own plan file, and that file is what the approver reads and what
|
|
20
|
+
* the Apply step names. `apply <planFile>` does not replay it: prior state on
|
|
21
|
+
* a live root is a projection rebuilt from the live system every run, so the
|
|
22
|
+
* apply re-plans against the live system the way every live-markers run does,
|
|
23
|
+
* then compares its fresh plan with the file's. Same addresses, same actions,
|
|
24
|
+
* same live objects, and the same planned values, or it refuses before
|
|
25
|
+
* anything changes:
|
|
26
|
+
*
|
|
27
|
+
* - `The approved plan no longer matches the live system`, and
|
|
28
|
+
* - `The approved plan belongs to a different estate`,
|
|
29
|
+
*
|
|
30
|
+
* both with exit status **3**, which is neither 1 (any ordinary failure) nor
|
|
31
|
+
* `-detailed-exitcode`'s 2. `terraformApply` maps that status to a named
|
|
32
|
+
* result rather than a thrown error (`refused: "approval-mismatch" |
|
|
33
|
+
* "wrong-estate"`, with choudoufu's message in `refusal`), so a workflow
|
|
34
|
+
* reading the Apply step's output can route the run back to review instead of
|
|
35
|
+
* treating it as a broken step. The seam #2106 left named and unused on the
|
|
36
|
+
* apply args is now the plan file itself.
|
|
37
|
+
*
|
|
38
|
+
* TF101 therefore fires on a live root's `terraformApply` call again: there
|
|
39
|
+
* is a plan file to pair once more, and pairing it is what the refusal above
|
|
40
|
+
* has to compare against.
|
|
41
|
+
*
|
|
42
|
+
* **Delete modes.** chant's `delete: "never" | "owned-only" | "gated"`
|
|
43
|
+
* (`terraform.roots.<name>.delete`, `../config.ts`) maps onto choudoufu's
|
|
44
|
+
* `policy` block. `"owned-only"` is choudoufu's own default verb for the
|
|
45
|
+
* `undeclared_tagged` quadrant (an orphaned marked resource) and needs
|
|
46
|
+
* nothing; `"gated"` needs nothing beyond this composite's own approval gate;
|
|
47
|
+
* `"never"` requires the root's `policy` block to set `undeclared_tagged` to
|
|
48
|
+
* `"keep"`, `"untag"` or `"report"`, and TF026 (`../lint/post-synth/tf026.ts`)
|
|
49
|
+
* enforces that at build time, naming the setting to add. Separately, and
|
|
50
|
+
* regardless of `delete`, this composite refuses outright to build against a
|
|
51
|
+
* root whose `policy` sets `undeclared_untagged = "delete"` (account-scoped
|
|
52
|
+
* reconciliation, which needs a `scope` block): that quadrant deletes
|
|
53
|
+
* resources the estate never claimed, anywhere the scope reaches, and chant
|
|
54
|
+
* never proposes deleting a resource it does not own, not for a root, and not
|
|
55
|
+
* for an Op it generated.
|
|
56
|
+
*
|
|
57
|
+
* A gate is a fact on the gate ledger, not a wait (#2119). Reaching one,
|
|
58
|
+
* `chant run` consults the ledger (`packages/core/src/op/gate.ts`): a
|
|
59
|
+
* resolution recorded for this run's own plan lets the run walk through, and
|
|
60
|
+
* anything else records the pending fact and ends the run with status `gated`
|
|
61
|
+
* and exit 3. `chant approve <op> <gate>` writes the resolution, and the next
|
|
62
|
+
* run reads it. `gate: "never"` drops the Gate phase entirely, so a run never
|
|
63
|
+
* stops.
|
|
64
|
+
*
|
|
65
|
+
* **The gate binds the plan** (#2300). The Plan step publishes
|
|
66
|
+
* `planDigest` — its `terraform show -json` change set, hashed — and the Gate
|
|
67
|
+
* step references it, so the resolution names the plan it approves. That
|
|
68
|
+
* closes the window the exit-3 refusal above does not: exit 3 compares the
|
|
69
|
+
* Apply step's fresh plan against the *file* it was handed inside one run,
|
|
70
|
+
* and this compares a later run's fresh plan against what a human actually
|
|
71
|
+
* approved. INTENTIUS/choudoufu#1026 measured the gap by approving, renaming
|
|
72
|
+
* a resource, and re-running: the second run applied without complaint. It
|
|
73
|
+
* now stops, naming the approved digest and the planned one.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* import { TerraformApplyOp } from "@intentius/chant-lexicon-terraform";
|
|
78
|
+
*
|
|
79
|
+
* export const { op } = TerraformApplyOp({ name: "app-apply", root: "app" });
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
import { Op, phase, gate, activity } from "@intentius/chant/op";
|
|
83
|
+
import { DEFAULT_PLAN_FILE } from "../op/activities/terraform.js";
|
|
84
|
+
import { detectLivePolicyVerbs } from "../op/activities/live-detect.js";
|
|
85
|
+
import { resolveRootModeSync } from "../op/resolve-root-mode.js";
|
|
86
|
+
import { terraformInit as initStep, terraformPlan as planStep, terraformApply as applyStep, terraformShow as showStep, } from "../op/builders.js";
|
|
87
|
+
export function TerraformApplyOp(config) {
|
|
88
|
+
const planFile = config.planFile ?? DEFAULT_PLAN_FILE;
|
|
89
|
+
const gateMode = config.gate ?? "on-destroy";
|
|
90
|
+
// Terraform has no rollback to run, so asking for one without supplying it
|
|
91
|
+
// fails here, at build time, with the Op named — not as a warning when the
|
|
92
|
+
// apply has already half-run. Same shape as ApplyOp's refusal (#1449).
|
|
93
|
+
const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
|
|
94
|
+
const compensateImage = typeof config.compensate === "object" ? config.compensate.image : undefined;
|
|
95
|
+
if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
|
|
96
|
+
throw new Error(`TerraformApplyOp "${config.name}": compensate is enabled, but terraform has no automatic ` +
|
|
97
|
+
`rollback — undoing a partial apply means planning and applying the inverse, which is a ` +
|
|
98
|
+
`decision about the estate rather than something chant can synthesize. Either supply ` +
|
|
99
|
+
`compensate: { command: "..." } with a rollback of your own, or set compensate: false.`);
|
|
100
|
+
}
|
|
101
|
+
// Every step resolves the root through the same project config, so the cwd
|
|
102
|
+
// travels with each of them rather than being read off the process once.
|
|
103
|
+
const where = config.cwd ? { cwd: config.cwd } : {};
|
|
104
|
+
// Best-effort, synchronous (chant.config.json only, see resolveRootModeSync's
|
|
105
|
+
// own doc comment). The mode no longer changes which steps are emitted; it
|
|
106
|
+
// words the gate below, and it decides whether the policy refusal is even
|
|
107
|
+
// asked. "Unknown" reads as stock, which for the gate wording is harmless
|
|
108
|
+
// and for the refusal is the permissive direction, so the refusal is not
|
|
109
|
+
// this function's to guarantee: on a chant.config.ts project, which is
|
|
110
|
+
// every project in this repository, the mode never resolves and the
|
|
111
|
+
// refusal below never runs (#2216).
|
|
112
|
+
const resolved = resolveRootModeSync(config.root, config.cwd);
|
|
113
|
+
const live = resolved?.mode === "live";
|
|
114
|
+
if (live) {
|
|
115
|
+
// Regardless of `delete`: an account-scoped purge is never something
|
|
116
|
+
// chant proposes on an Op's own initiative. TF026 handles the narrower,
|
|
117
|
+
// config-driven `delete: "never"` requirement; this is unconditional.
|
|
118
|
+
//
|
|
119
|
+
// TF027 (`../lint/post-synth/tf027.ts`) makes the same refusal off the
|
|
120
|
+
// parsed HCL, whatever the config file is written in, and is the check
|
|
121
|
+
// that guarantees it. This throw is kept for the projects whose mode does
|
|
122
|
+
// resolve here, where failing at the moment the Op is built names the Op
|
|
123
|
+
// as well as the root.
|
|
124
|
+
const verbs = detectLivePolicyVerbs(resolved.dir);
|
|
125
|
+
if (verbs?.undeclaredUntagged === "delete") {
|
|
126
|
+
throw new Error(`TerraformApplyOp "${config.name}": root "${config.root}"'s policy block sets ` +
|
|
127
|
+
`undeclared_untagged = "delete" (account-scoped reconciliation, scoped by a \`scope\` block). ` +
|
|
128
|
+
"chant never proposes deleting a resource it does not own, and neither does an Op it " +
|
|
129
|
+
"generates. Remove that setting from the policy block, or narrow the estate's ownership " +
|
|
130
|
+
"answer instead of the account's.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const phases = [
|
|
134
|
+
phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
|
|
135
|
+
];
|
|
136
|
+
// `id` is what makes `plan.out` legal, and `plan.out.planFile` is how the
|
|
137
|
+
// Apply step below names this step's saved plan. On a live root that file
|
|
138
|
+
// is also the approval artifact the apply re-plans against, so the same
|
|
139
|
+
// reference carries the approval as well as the path.
|
|
140
|
+
const plan = planStep(config.root, { planFile, ...where, id: "plan" });
|
|
141
|
+
plan.outcomeAttribute = { name: "Changed", from: "changed" };
|
|
142
|
+
phases.push(phase("Plan", [plan]));
|
|
143
|
+
if (gateMode !== "never") {
|
|
144
|
+
// Re-render the saved plan just before the gate, so the approver's
|
|
145
|
+
// `Destroys` attribute comes off the plan that will actually apply.
|
|
146
|
+
// `show` against a plan file calls no provider, on either kind of root.
|
|
147
|
+
const show = showStep(config.root, { ...where, planFile: plan.out.planFile });
|
|
148
|
+
show.outcomeAttribute = { name: "Destroys", from: "destroys" };
|
|
149
|
+
phases.push(phase("Gate", [
|
|
150
|
+
show,
|
|
151
|
+
gate(config.gateName ?? config.signalName ?? `approve-${config.name}`, {
|
|
152
|
+
...(config.gateTimeout ? { timeout: config.gateTimeout } : {}),
|
|
153
|
+
// #2300: the approval is for this plan, not for the next run. The
|
|
154
|
+
// Plan step's digest rides onto the pending fact, `chant approve`
|
|
155
|
+
// copies it onto the resolution, and a later run whose fresh plan
|
|
156
|
+
// digests differently is refused with both digests named.
|
|
157
|
+
plan: plan.out.planDigest,
|
|
158
|
+
...(config.gateApproval ? { approval: config.gateApproval } : {}),
|
|
159
|
+
description: config.gateDescription ??
|
|
160
|
+
`Approve terraform apply of ${live ? "live " : ""}root "${config.root}" (gate: ${gateMode}). ` +
|
|
161
|
+
`The Destroys run outcome from this phase is the plan's destroy count.` +
|
|
162
|
+
(live
|
|
163
|
+
? " The apply that follows re-plans against the live system and applies this plan file only" +
|
|
164
|
+
" if its own fresh plan agrees; otherwise it refuses with exit status 3 and the Apply" +
|
|
165
|
+
" step reports a named refusal for review (choudoufu #878)."
|
|
166
|
+
: ""),
|
|
167
|
+
}),
|
|
168
|
+
]));
|
|
169
|
+
}
|
|
170
|
+
phases.push(phase("Apply", [applyStep(config.root, { ...where, planFile: plan.out.planFile })]));
|
|
171
|
+
const op = Op({
|
|
172
|
+
name: config.name,
|
|
173
|
+
overview: `Init, plan and apply the "${config.root}" terraform root`,
|
|
174
|
+
labels: {
|
|
175
|
+
Apply: "true",
|
|
176
|
+
TerraformRoot: config.root,
|
|
177
|
+
},
|
|
178
|
+
phases,
|
|
179
|
+
...(compensateCommand
|
|
180
|
+
? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand, ...(compensateImage ? { image: compensateImage } : {}) })])] }
|
|
181
|
+
: {}),
|
|
182
|
+
});
|
|
183
|
+
return { op };
|
|
184
|
+
}
|