@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.
Files changed (149) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.d.ts +2 -0
  20. package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
  21. package/dist/composites/terraform-adopt-op.js +153 -0
  22. package/dist/composites/terraform-apply-op.d.ts +2 -0
  23. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  24. package/dist/composites/terraform-apply-op.js +184 -0
  25. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  26. package/dist/composites/terraform-watch-op.js +219 -0
  27. package/dist/config.js +118 -0
  28. package/dist/describe-resources.js +984 -0
  29. package/dist/generated/index.js +1 -0
  30. package/dist/hcl/descend.js +208 -0
  31. package/dist/hcl/edges.js +287 -0
  32. package/dist/hcl/jsonencode.js +442 -0
  33. package/dist/hcl/marker.js +212 -0
  34. package/dist/hcl/parse.js +372 -0
  35. package/dist/hcl/references.js +156 -0
  36. package/dist/hcl/roots.js +98 -0
  37. package/dist/hcl/suppressions.js +189 -0
  38. package/dist/hcl/value.js +72 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +46 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/lint/audit-catalog.js +501 -0
  44. package/dist/lint/audit-lineage.js +442 -0
  45. package/dist/lint/post-synth/blocks.js +87 -0
  46. package/dist/lint/post-synth/fixtures/load.js +94 -0
  47. package/dist/lint/post-synth/flat-attributes.js +135 -0
  48. package/dist/lint/post-synth/index.js +90 -0
  49. package/dist/lint/post-synth/module-source.js +153 -0
  50. package/dist/lint/post-synth/scope.js +104 -0
  51. package/dist/lint/post-synth/terragrunt.js +63 -0
  52. package/dist/lint/post-synth/tf001.js +138 -0
  53. package/dist/lint/post-synth/tf002.js +143 -0
  54. package/dist/lint/post-synth/tf003.js +60 -0
  55. package/dist/lint/post-synth/tf004.js +54 -0
  56. package/dist/lint/post-synth/tf005.js +68 -0
  57. package/dist/lint/post-synth/tf006.js +43 -0
  58. package/dist/lint/post-synth/tf007.js +66 -0
  59. package/dist/lint/post-synth/tf008.js +64 -0
  60. package/dist/lint/post-synth/tf009.js +49 -0
  61. package/dist/lint/post-synth/tf010.js +40 -0
  62. package/dist/lint/post-synth/tf011.js +38 -0
  63. package/dist/lint/post-synth/tf012.js +36 -0
  64. package/dist/lint/post-synth/tf013.js +53 -0
  65. package/dist/lint/post-synth/tf014.js +61 -0
  66. package/dist/lint/post-synth/tf015.js +62 -0
  67. package/dist/lint/post-synth/tf016.js +66 -0
  68. package/dist/lint/post-synth/tf017.js +39 -0
  69. package/dist/lint/post-synth/tf018.js +63 -0
  70. package/dist/lint/post-synth/tf019.js +60 -0
  71. package/dist/lint/post-synth/tf020.js +103 -0
  72. package/dist/lint/post-synth/tf021.js +117 -0
  73. package/dist/lint/post-synth/tf022.js +51 -0
  74. package/dist/lint/post-synth/tf024.js +62 -0
  75. package/dist/lint/post-synth/tf025.js +83 -0
  76. package/dist/lint/post-synth/tf026.js +74 -0
  77. package/dist/lint/post-synth/tf027.js +66 -0
  78. package/dist/lint/post-synth/tf028.js +83 -0
  79. package/dist/lint/post-synth/tf029.js +73 -0
  80. package/dist/lint/post-synth/tf030.js +253 -0
  81. package/dist/lint/post-synth/tf031.js +332 -0
  82. package/dist/lint/post-synth/tf032.js +201 -0
  83. package/dist/lint/post-synth/tf033.js +126 -0
  84. package/dist/lint/post-synth/tf034.js +45 -0
  85. package/dist/lint/post-synth/tf035.js +63 -0
  86. package/dist/lint/post-synth/tf036.js +97 -0
  87. package/dist/lint/post-synth/tf037.js +80 -0
  88. package/dist/lint/post-synth/tf038.js +63 -0
  89. package/dist/lint/post-synth/tf039.js +55 -0
  90. package/dist/lint/post-synth/tf040.js +67 -0
  91. package/dist/lint/post-synth/tf041.js +53 -0
  92. package/dist/lint/post-synth/tf042.js +38 -0
  93. package/dist/lint/post-synth/tf043.js +45 -0
  94. package/dist/lint/post-synth/tf044.js +45 -0
  95. package/dist/lint/post-synth/tf045.js +44 -0
  96. package/dist/lint/rules/fixtures/load.js +50 -0
  97. package/dist/lint/rules/index.js +4 -0
  98. package/dist/lint/rules/plan-before-apply.js +136 -0
  99. package/dist/lint/secret-shape.js +149 -0
  100. package/dist/live-ownership.js +57 -0
  101. package/dist/lsp/completions.js +95 -0
  102. package/dist/lsp/context.js +358 -0
  103. package/dist/lsp/hover.js +45 -0
  104. package/dist/lsp/option-keys.js +93 -0
  105. package/dist/manifest.json +1 -1
  106. package/dist/op/activities/index.js +30 -0
  107. package/dist/op/activities/live-detect.js +143 -0
  108. package/dist/op/activities/pin-rollout.js +45 -0
  109. package/dist/op/activities/terraform.js +1270 -0
  110. package/dist/op/activity-contracts.js +162 -0
  111. package/dist/op/adoption.js +272 -0
  112. package/dist/op/builders.js +125 -0
  113. package/dist/op/resolve-root-mode.js +77 -0
  114. package/dist/package-cli.js +16 -0
  115. package/dist/pin/edit-ts.js +76 -0
  116. package/dist/pin/edit.js +118 -0
  117. package/dist/pin/forge.js +65 -0
  118. package/dist/pin/index.js +16 -0
  119. package/dist/pin/rollout.d.ts.map +1 -1
  120. package/dist/pin/rollout.js +382 -0
  121. package/dist/pin/scan.js +287 -0
  122. package/dist/pin/source.js +171 -0
  123. package/dist/pin/waves.js +109 -0
  124. package/dist/plan-digest.js +67 -0
  125. package/dist/plugin.js +323 -0
  126. package/dist/serializer.js +23 -0
  127. package/dist/state-ownership.js +29 -0
  128. package/dist/terragrunt/affected.d.ts.map +1 -1
  129. package/dist/terragrunt/affected.js +290 -0
  130. package/dist/terragrunt/index.js +10 -0
  131. package/dist/terragrunt/mocks.js +270 -0
  132. package/dist/terragrunt/run.js +264 -0
  133. package/dist/terragrunt/units.js +195 -0
  134. package/dist/terragrunt/wave.js +125 -0
  135. package/dist/validate-cli.js +4 -0
  136. package/dist/validate.js +31 -0
  137. package/package.json +10 -10
  138. package/src/codegen/package.ts +2 -2
  139. package/src/components/capability-plugin.test.ts +97 -2
  140. package/src/components/capability-plugin.ts +117 -12
  141. package/src/components/pr-loop.tofu.test.ts +51 -11
  142. package/src/composites/terraform-adopt-op.test.ts +6 -0
  143. package/src/composites/terraform-adopt-op.ts +7 -2
  144. package/src/composites/terraform-apply-op.test.ts +6 -0
  145. package/src/composites/terraform-apply-op.ts +7 -2
  146. package/src/index.ts +1 -0
  147. package/src/pin/rollout.ts +2 -1
  148. package/src/terragrunt/affected.test.ts +11 -0
  149. 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAiC,MAAM,wCAAwC,CAAC;AAExG,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;AAmED,eAAO,MAAM,wBAAwB,EAAE,UAAU,CAAC,uBAAuB,EAAE,wBAAwB,CAwClG,CAAC;AAEF,eAAO,MAAM,uBAAuB;aAClC,KAAK,YAAG,iBAAiB;CACjB,CAAC;AAEX,eAAO,MAAM,yBAAyB,EAAE,gBAWvC,CAAC"}
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;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;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,CAkE1F"}
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;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;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,CAiH1F"}
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
+ }