@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-terraform",
3
- "version": "0.124.31",
3
+ "version": "0.124.33",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -11,38 +11,38 @@
11
11
  ".": {
12
12
  "development": "./src/index.ts",
13
13
  "types": "./dist/index.d.ts",
14
- "default": "./src/index.ts"
14
+ "default": "./dist/index.js"
15
15
  },
16
16
  "./workspace-kinds": "./workspace-kinds.json",
17
17
  "./*": {
18
18
  "development": "./src/*.ts",
19
19
  "types": "./dist/*.d.ts",
20
- "default": "./src/*.ts"
20
+ "default": "./dist/*.js"
21
21
  },
22
22
  "./op/activities": {
23
23
  "development": "./src/op/activities/index.ts",
24
24
  "types": "./dist/op/activities/index.d.ts",
25
- "default": "./src/op/activities/index.ts"
25
+ "default": "./dist/op/activities/index.js"
26
26
  },
27
27
  "./op/activity-contracts": {
28
28
  "development": "./src/op/activity-contracts.ts",
29
29
  "types": "./dist/op/activity-contracts.d.ts",
30
- "default": "./src/op/activity-contracts.ts"
30
+ "default": "./dist/op/activity-contracts.js"
31
31
  },
32
32
  "./pin": {
33
33
  "development": "./src/pin/index.ts",
34
34
  "types": "./dist/pin/index.d.ts",
35
- "default": "./src/pin/index.ts"
35
+ "default": "./dist/pin/index.js"
36
36
  },
37
37
  "./terragrunt": {
38
38
  "development": "./src/terragrunt/index.ts",
39
39
  "types": "./dist/terragrunt/index.d.ts",
40
- "default": "./src/terragrunt/index.ts"
40
+ "default": "./dist/terragrunt/index.js"
41
41
  },
42
42
  "./lint/post-synth": {
43
43
  "development": "./src/lint/post-synth/index.ts",
44
44
  "types": "./dist/lint/post-synth/index.d.ts",
45
- "default": "./src/lint/post-synth/index.ts"
45
+ "default": "./dist/lint/post-synth/index.js"
46
46
  },
47
47
  "./manifest": "./dist/manifest.json",
48
48
  "./meta": "./dist/meta.json",
@@ -52,7 +52,7 @@
52
52
  "generate": "npx tsx src/codegen/generate-cli.ts",
53
53
  "validate": "npx tsx src/validate-cli.ts",
54
54
  "docs": "npx tsx src/codegen/docs-cli.ts",
55
- "build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete",
55
+ "build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json",
56
56
  "prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
57
57
  "bundle": "tsx src/package-cli.ts"
58
58
  },
@@ -67,7 +67,7 @@
67
67
  "access": "public"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.124.31",
70
+ "@intentius/chant": "^0.124.33",
71
71
  "zod": "^4.3.6"
72
72
  },
73
73
  "description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
- import { fileURLToPath } from "url";
3
+ import { moduleSourceDir } from "@intentius/chant/module-source";
4
4
  import { packagePipeline, collectSkills } from "@intentius/chant/codegen/package";
5
5
  import { generate } from "./generate";
6
6
  import { terraformPlugin } from "../plugin";
@@ -12,7 +12,7 @@ import { terraformPlugin } from "../plugin";
12
12
  * `lint/rules` does not exist). `lexicons/aws/src/codegen/package.ts` resolves
13
13
  * it the same way.
14
14
  */
15
- const srcDir = dirname(dirname(fileURLToPath(import.meta.url)));
15
+ const srcDir = dirname(moduleSourceDir(import.meta.url));
16
16
 
17
17
  /** The published version, so the manifest cannot drift from package.json. */
18
18
  function packageVersion(): string {
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
8
8
  const calls: string[] = [];
9
9
  let applyResult: Record<string, unknown> = { applied: true };
10
10
  let changeSet: Record<string, unknown> | undefined;
11
+ let planJson: unknown;
11
12
  // #3459: whether the root goes through choudoufu's set commands, and what they answer.
12
13
  let waveRoot: { wave: true; root: string } | { wave: false; reason: string } = { wave: false, reason: "the root runs terraform" };
13
14
  let waveApply: Record<string, unknown> = { exitCode: 0, landed: true, message: "landed" };
@@ -39,7 +40,7 @@ vi.mock("../op/activities/terraform", () => ({
39
40
  }),
40
41
  terraformPlan: vi.fn(async (args: { root: string; vars?: Record<string, unknown> }) => {
41
42
  calls.push(`plan ${args.root} ${JSON.stringify(args.vars ?? {})}`);
42
- return { planFile: "chant.tfplan", planDigest: `jcs1-sha256:${args.root}`, ...(changeSet ? { changeSet } : {}) };
43
+ return { planFile: "chant.tfplan", planDigest: `jcs1-sha256:${args.root}`, json: planJson, ...(changeSet ? { changeSet } : {}) };
43
44
  }),
44
45
  terraformApply: vi.fn(async (args: { root: string; planFile: string }) => {
45
46
  calls.push(`apply ${args.root} ${args.planFile}`);
@@ -51,12 +52,14 @@ vi.mock("../op/activities/terraform", () => ({
51
52
  }),
52
53
  }));
53
54
 
54
- const { terraformApplyCapability, terraformCapabilityPlugin } = await import("./capability-plugin");
55
+ const { terraformApplyCapability, terraformCapabilityPlugin, terraformPlannedOutputs, TerraformPlanMovedError } = await import("./capability-plugin");
56
+ const { isPlanMovedError } = await import("@intentius/chant/components/capability");
55
57
 
56
58
  beforeEach(() => {
57
59
  calls.length = 0;
58
60
  applyResult = { applied: true };
59
61
  changeSet = undefined;
62
+ planJson = undefined;
60
63
  waveRoot = { wave: false, reason: "the root runs terraform" };
61
64
  waveApply = { exitCode: 0, landed: true, message: "landed" };
62
65
  waveApplyArgs.length = 0;
@@ -124,6 +127,24 @@ describe("terraform-apply on a choudoufu root in a gated wave (#3459)", () => {
124
127
  expect(calls).toEqual(["wave-apply e01 sha256:set-e01"]);
125
128
  expect(kept).toEqual([carry]);
126
129
  });
130
+
131
+ test("a moved set throws core's plan-moved mark, so the command exits 3 (#3997)", async () => {
132
+ waveApply = { exitCode: 3, landed: false, message: "choudoufu refused the approved set" };
133
+ const err = await terraformApplyCapability
134
+ .run({ env: "test", component: "e01", plans: { e01: wavePlan } }, { root: "e01" })
135
+ .catch((e: unknown) => e);
136
+ expect(err).toBeInstanceOf(TerraformPlanMovedError);
137
+ expect(isPlanMovedError(err)).toBe(true);
138
+ });
139
+
140
+ test("a root that could not land is an ordinary failure, not a moved plan", async () => {
141
+ waveApply = { exitCode: 4, landed: false, message: "choudoufu could not land the root" };
142
+ const err = await terraformApplyCapability
143
+ .run({ env: "test", component: "e01", plans: { e01: wavePlan } }, { root: "e01" })
144
+ .catch((e: unknown) => e);
145
+ expect(err).toBeInstanceOf(Error);
146
+ expect(isPlanMovedError(err)).toBe(false);
147
+ });
127
148
  });
128
149
 
129
150
  describe("terraform-apply", () => {
@@ -137,10 +158,65 @@ describe("terraform-apply", () => {
137
158
  member: "app",
138
159
  planDigest: "jcs1-sha256:app",
139
160
  artifact: { planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" },
161
+ outputs: { known: {}, unknown: [] },
140
162
  });
141
163
  expect(calls).toEqual(["init app", 'plan app {"vpc":"v-1"}']);
142
164
  });
143
165
 
166
+ test("plan answers the outputs the root will have, from the plan's planned values (#3999)", async () => {
167
+ planJson = {
168
+ planned_values: { outputs: { cidr: { sensitive: false, value: "10.1.0.0/16" }, id: { sensitive: false } } },
169
+ output_changes: {
170
+ cidr: { actions: ["update"], before: "10.0.0.0/16", after: "10.1.0.0/16", after_unknown: false },
171
+ id: { actions: ["create"], before: null, after_unknown: true },
172
+ },
173
+ };
174
+ const planned = await terraformApplyCapability.plan!({ env: "test", component: "net" }, { root: "net" });
175
+ expect(planned.outputs).toEqual({ known: { cidr: "10.1.0.0/16" }, unknown: ["id"] });
176
+ // The artifact `run` gets is still the plan file alone.
177
+ expect(planned.artifact).toEqual({ planFile: "chant.tfplan", planDigest: "jcs1-sha256:net" });
178
+ });
179
+
180
+ test("a root whose vars read outputs not known yet is not planned: a hole names whose (#3999)", async () => {
181
+ const ctx = { env: "test", component: "app", unresolvedInputs: ["net"] };
182
+ const planned = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "app" } });
183
+ expect(calls).toEqual([]);
184
+ expect(planned.artifact).toBeUndefined();
185
+ expect(planned.outputs).toBeUndefined();
186
+ expect(planned.changeSet?.entries).toEqual([]);
187
+ expect(planned.changeSet?.member.holes).toEqual([{ address: "app", type: "terraform_root", reason: "inputs from net; planned again once they apply" }]);
188
+ expect(planned.planDigest).toBe(planned.changeSet?.member.planDigest);
189
+ const again = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "app" } });
190
+ expect(again.planDigest).toBe(planned.planDigest);
191
+ const other = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "web" } });
192
+ expect(other.planDigest).not.toBe(planned.planDigest);
193
+ });
194
+ });
195
+
196
+ describe("terraformPlannedOutputs (#3999)", () => {
197
+ test("an output is known when its planned value is there and nothing in it is unknown after apply", () => {
198
+ expect(
199
+ terraformPlannedOutputs({
200
+ planned_values: { outputs: { a: { value: "x" }, b: { value: { k: 1 } }, c: {}, d: { value: null } } },
201
+ output_changes: {
202
+ a: { after: "x", after_unknown: false },
203
+ b: { after: { k: 1 }, after_unknown: { k: false, j: true } },
204
+ c: { after_unknown: true },
205
+ d: { after: null, after_unknown: false },
206
+ },
207
+ }),
208
+ ).toEqual({ known: { a: "x", d: null }, unknown: ["b", "c"] });
209
+ });
210
+
211
+ test("an output with no planned value and no change is unknown; a plan with no outputs has none", () => {
212
+ expect(terraformPlannedOutputs({ planned_values: { outputs: { a: { sensitive: true } } } })).toEqual({ known: {}, unknown: ["a"] });
213
+ expect(terraformPlannedOutputs({})).toEqual({ known: {}, unknown: [] });
214
+ expect(terraformPlannedOutputs(undefined)).toEqual({ known: {}, unknown: [] });
215
+ });
216
+ });
217
+
218
+ describe("terraform-apply, continued", () => {
219
+
144
220
  test("plan hands on the root's change-set part, outside the artifact run receives (#3183)", async () => {
145
221
  changeSet = { member: { member: "app" }, entries: [] };
146
222
  const planned = await terraformApplyCapability.plan!({ env: "test", component: "app" }, { root: "app" });
@@ -178,4 +254,23 @@ describe("terraform-apply", () => {
178
254
  ).rejects.toThrow(/refused the plan it was given \(approval-mismatch\)/);
179
255
  expect(calls).not.toContain("output app");
180
256
  });
257
+
258
+ test.each(["approval-mismatch", "wrong-estate"])(
259
+ "choudoufu's %s refusal of the wave's plan throws core's plan-moved mark, so the command exits 3 (#3997)",
260
+ async (refused) => {
261
+ applyResult = { applied: false, refused, refusal: "refused" };
262
+ const err = await terraformApplyCapability
263
+ .run({ env: "test", component: "app", plans: { app: { planFile: "chant.tfplan", planDigest: "d" } } }, { root: "app" })
264
+ .catch((e: unknown) => e);
265
+ expect(err).toBeInstanceOf(TerraformPlanMovedError);
266
+ expect(isPlanMovedError(err)).toBe(true);
267
+ },
268
+ );
269
+
270
+ test("a refusal of a plan this run made itself is an ordinary failure: the next run plans afresh", async () => {
271
+ applyResult = { applied: false, refused: "approval-mismatch", refusal: "refused" };
272
+ const err = await terraformApplyCapability.run({ env: "test", component: "app" }, { root: "app" }).catch((e: unknown) => e);
273
+ expect(err).toBeInstanceOf(Error);
274
+ expect(isPlanMovedError(err)).toBe(false);
275
+ });
181
276
  });
@@ -20,12 +20,28 @@
20
20
  * that file and nothing else. terraform and tofu refuse a saved plan whose
21
21
  * state moved since it was written; choudoufu re-plans against the live
22
22
  * system and refuses with exit 3 when its fresh plan differs (choudoufu
23
- * #878). Either refusal fails the step.
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.
24
27
  *
25
28
  * Run without a wave plan (`chant run --components`, or a fan-out with no
26
29
  * wave gate), `run` plans and applies in one go, which is what `terraform
27
30
  * apply` with no plan file does too.
28
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
+ *
29
45
  * ## choudoufu roots in a gated wave (#3459)
30
46
  *
31
47
  * A live choudoufu root that `live-plan-set` can plan as chant would (no
@@ -49,8 +65,9 @@
49
65
  * choudoufu's wave over it is always wave 1.
50
66
  */
51
67
 
52
- import type { Capability, DeployContext, CapabilityPlan } from "@intentius/chant/components/capability";
68
+ import type { Capability, DeployContext, CapabilityPlan, PlannedOutputs } from "@intentius/chant/components/capability";
53
69
  import type { ChangeSetPart } from "@intentius/chant/change-set";
70
+ import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
54
71
  import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
55
72
  import {
56
73
  choudoufuPlanSet,
@@ -96,7 +113,7 @@ function requireRoot(input: TerraformApplyStepInput): void {
96
113
  }
97
114
  }
98
115
 
99
- async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & { changeSet?: ChangeSetPart }> {
116
+ async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & { changeSet?: ChangeSetPart; json?: unknown }> {
100
117
  requireRoot(input);
101
118
  await terraformInit(rootArgs(input));
102
119
  const planned = await terraformPlan({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
@@ -104,9 +121,84 @@ async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & {
104
121
  planFile: planned.planFile,
105
122
  planDigest: planned.planDigest,
106
123
  ...(planned.changeSet ? { changeSet: planned.changeSet } : {}),
124
+ json: planned.json,
125
+ };
126
+ }
127
+
128
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
129
+
130
+ /** Whether an `after_unknown` value marks anything unknown: `true`, or `true` anywhere inside. */
131
+ function anyUnknown(value: unknown): boolean {
132
+ if (value === true) return true;
133
+ if (Array.isArray(value)) return value.some(anyUnknown);
134
+ if (isRecord(value)) return Object.values(value).some(anyUnknown);
135
+ return false;
136
+ }
137
+
138
+ /**
139
+ * The outputs a root will have once the plan `json` (`show -json`) applies
140
+ * (#3999). An output is known when `planned_values.outputs` gives its value
141
+ * and its `output_changes` entry marks no part of it unknown after apply.
142
+ * Every other output the plan names is unknown, the whole output even when
143
+ * only a part of it is.
144
+ */
145
+ export function terraformPlannedOutputs(json: unknown): PlannedOutputs {
146
+ const doc = isRecord(json) ? json : {};
147
+ const values = isRecord(doc.planned_values) && isRecord(doc.planned_values.outputs) ? doc.planned_values.outputs : {};
148
+ const changes = isRecord(doc.output_changes) ? doc.output_changes : {};
149
+ const known: Record<string, unknown> = {};
150
+ const unknown: string[] = [];
151
+ for (const name of [...new Set([...Object.keys(values), ...Object.keys(changes)])].sort()) {
152
+ const change = changes[name];
153
+ const planned = values[name];
154
+ if (isRecord(change) && anyUnknown(change.after_unknown)) unknown.push(name);
155
+ else if (isRecord(planned) && "value" in planned) known[name] = planned.value;
156
+ else if (isRecord(change) && "after" in change) known[name] = change.after;
157
+ else unknown.push(name);
158
+ }
159
+ return { known, unknown };
160
+ }
161
+
162
+ /**
163
+ * The member of a root whose `vars` read outputs not known yet (#3999): no
164
+ * plan, a hole naming whose outputs it waits on, and a digest over the root,
165
+ * the vars as resolved and those names.
166
+ */
167
+ function waitingRoot(ctx: DeployContext, input: TerraformApplyStepInput, unresolved: string[]): CapabilityPlan {
168
+ const vars = Object.fromEntries(Object.entries(input.vars ?? {}).map(([k, v]) => [k, v === undefined ? null : v]));
169
+ const planDigest = computePlanDigest("terraform-apply-unresolved", { env: ctx.env, root: input.root, vars, unresolved });
170
+ const changeSet: ChangeSetPart = {
171
+ member: {
172
+ member: input.root,
173
+ lexicon: "terraform",
174
+ planner: "chant",
175
+ status: "planned",
176
+ planDigest,
177
+ holes: [{ address: input.root, type: "terraform_root", reason: `inputs from ${unresolved.join(", ")}; planned again once they apply` }],
178
+ },
179
+ entries: [],
107
180
  };
181
+ return { member: input.root, planDigest, changeSet };
108
182
  }
109
183
 
184
+ /**
185
+ * choudoufu refused the plan `run` was handed by its wave (its exit 3):
186
+ * the live system moved since the plan, or the plan names another estate.
187
+ * Nothing was applied. `planMoved` is core's mark for a moved plan
188
+ * (`isPlanMovedError`), so the command that applied it exits 3, as for the
189
+ * other capabilities that refuse a moved plan (#3997).
190
+ */
191
+ export class TerraformPlanMovedError extends Error {
192
+ readonly planMoved = true;
193
+ constructor(public readonly root: string, message: string) {
194
+ super(message);
195
+ this.name = "TerraformPlanMovedError";
196
+ }
197
+ }
198
+
199
+ /** `live-wave-apply`'s exit status for a set whose plan moved (`CHOUDOUFU_WAVE_EXIT.setMoved`). */
200
+ const CHOUDOUFU_SET_MOVED_EXIT = 3;
201
+
110
202
  function isPlannedRoot(value: unknown): value is PlannedRoot {
111
203
  const v = value as Partial<PlannedRoot> | undefined;
112
204
  return typeof v?.planFile === "string" && typeof v.planDigest === "string";
@@ -141,7 +233,9 @@ async function runChoudoufuWave(
141
233
  });
142
234
  if (applied.carry) ctx.carry?.(input.root, applied.carry);
143
235
  if (!applied.landed) {
144
- throw new Error(`terraform-apply: root "${input.root}": ${applied.message}`);
236
+ const message = `terraform-apply: root "${input.root}": ${applied.message}`;
237
+ if (applied.exitCode === CHOUDOUFU_SET_MOVED_EXIT) throw new TerraformPlanMovedError(input.root, message);
238
+ throw new Error(message);
145
239
  }
146
240
  const outputs = await terraformOutputs(rootArgs(input));
147
241
  return { root: input.root, planDigest: planned.planDigest, fromWavePlan: true, outputs };
@@ -152,8 +246,10 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
152
246
  // A root's apply has no undo short of applying the previous configuration,
153
247
  // which is a change of its own. COMP003 asks the component to say so.
154
248
  rollbackPolicy: "needs-opt-out",
155
- async plan(_ctx: DeployContext, input: TerraformApplyStepInput): Promise<CapabilityPlan> {
249
+ async plan(ctx: DeployContext, input: TerraformApplyStepInput): Promise<CapabilityPlan> {
156
250
  requireRoot(input);
251
+ const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
252
+ if (unresolved.length > 0) return waitingRoot(ctx, input, unresolved);
157
253
  const wave = await choudoufuWaveRoot({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
158
254
  if (wave.wave) {
159
255
  const set = await choudoufuPlanSet(rootArgs(input));
@@ -161,10 +257,16 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
161
257
  planDigest: set.planDigest,
162
258
  choudoufu: { planSetFile: set.planSetFile, setDigest: set.setDigest },
163
259
  };
164
- return { member: input.root, planDigest: set.planDigest, artifact, changeSet: set.changeSet };
260
+ return { member: input.root, planDigest: set.planDigest, artifact, changeSet: set.changeSet, outputs: terraformPlannedOutputs(set.json) };
165
261
  }
166
- const { changeSet, ...planned } = await planRoot(input);
167
- return { member: input.root, planDigest: planned.planDigest, artifact: planned, ...(changeSet ? { changeSet } : {}) };
262
+ const { changeSet, json, ...planned } = await planRoot(input);
263
+ return {
264
+ member: input.root,
265
+ planDigest: planned.planDigest,
266
+ artifact: planned,
267
+ ...(changeSet ? { changeSet } : {}),
268
+ outputs: terraformPlannedOutputs(json),
269
+ };
168
270
  },
169
271
  // The root's outputs as its state holds them now (#3183): what a dependent
170
272
  // planned before this root applies reads through `stackOutput()`.
@@ -176,13 +278,16 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
176
278
  async run(ctx: DeployContext, input: TerraformApplyStepInput): Promise<TerraformApplyStepOutput> {
177
279
  const fromWave = ctx.plans?.[input.root];
178
280
  if (isChoudoufuWavePlan(fromWave)) return runChoudoufuWave(ctx, input, fromWave);
179
- const planned: PlannedRoot = isPlannedRoot(fromWave) ? fromWave : await planRoot(input);
281
+ const planned: PlannedRoot = isPlannedRoot(fromWave) ? fromWave : await planRoot(input).then(({ planFile, planDigest }) => ({ planFile, planDigest }));
180
282
  const applied = await terraformApply({ ...rootArgs(input), planFile: planned.planFile });
181
283
  if (!applied.applied) {
182
- throw new Error(
284
+ const message =
183
285
  `terraform-apply: root "${input.root}" refused the plan it was given (${applied.refused ?? "refused"}). ` +
184
- (applied.refusal ?? ""),
185
- );
286
+ (applied.refusal ?? "");
287
+ // A plan the wave approved that choudoufu refuses moved since the
288
+ // approval. One this run made itself is planned afresh by the next run.
289
+ if (applied.refused !== undefined && isPlannedRoot(fromWave)) throw new TerraformPlanMovedError(input.root, message);
290
+ throw new Error(message);
186
291
  }
187
292
  const outputs = await terraformOutputs(rootArgs(input));
188
293
  return { root: input.root, planDigest: planned.planDigest, fromWavePlan: isPlannedRoot(fromWave), outputs };
@@ -103,6 +103,9 @@ class FakeGithub {
103
103
  return ok({});
104
104
  }
105
105
  if ((m = path.match(/^\/pulls\/(\d+)\/reviews$/))) return ok(this.reviews[Number(m[1])] ?? []);
106
+ // #4000: the author and committers, whose own approval never counts.
107
+ if ((m = path.match(/^\/pulls\/(\d+)$/))) return ok({ number: Number(m[1]), user: { login: "ci" } });
108
+ if ((m = path.match(/^\/pulls\/(\d+)\/commits$/))) return ok([{ author: { login: "ci" }, committer: { login: "ci" } }]);
106
109
  if ((m = path.match(/^\/commits\/([0-9a-f]+)\/pulls$/))) {
107
110
  const pr = this.mergedBy[m[1]];
108
111
  return ok(pr ? [{ number: pr, merged_at: "2026-10-03T00:00:00Z", merge_commit_sha: m[1] }] : []);
@@ -194,12 +197,16 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
194
197
  return report("plan");
195
198
  }
196
199
 
200
+ /** The commit before the last merge, which a re-run of its apply measures from. */
201
+ let mergedFrom = "";
202
+ const RESUME = ".chant/pr-resume/pr-apply.json";
203
+
197
204
  async function merge(pr: number): Promise<number> {
198
205
  git("checkout", "-q", "main");
199
- const before = git("rev-parse", "HEAD");
206
+ mergedFrom = git("rev-parse", "HEAD");
200
207
  git("merge", "-q", "--no-ff", `pr-${pr}`, "-m", `Merge pull request #${pr}`);
201
208
  github.mergedBy[git("rev-parse", "HEAD")] = pr;
202
- return handlers.runComponentsPrApply(ctx({ base: before, forge: "github", requireReview: true }) as never);
209
+ return handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never);
203
210
  }
204
211
 
205
212
  test("a change to one root plans that root and its dependent, and nothing else", async () => {
@@ -207,34 +214,67 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
207
214
 
208
215
  expect(plan.status).toBe("planned");
209
216
  expect(plan.selection).toMatchObject({ changed: ["a"], dependents: ["app"], waves: [["a"], ["app"]] });
210
- expect(plan.members.map((m) => [m.member, m.counts.update])).toEqual([
211
- ["a", 1],
212
- ["app", 0],
217
+ // a's id is terraform_data's output, which tofu knows only after apply,
218
+ // so app is not planned against the id a has now (#3999): the note says
219
+ // the approval does not cover what app does with it.
220
+ expect(plan.members.map((m) => [m.member, m.counts.update, m.unknownInputs])).toEqual([
221
+ ["a", 1, undefined],
222
+ ["app", 0, ["a.id"]],
223
+ ]);
224
+ expect(plan.changeSet.members.find((m) => m.member === "app")?.holes).toEqual([
225
+ { address: "app", type: "terraform_root", reason: "inputs from a; planned again once they apply" },
213
226
  ]);
227
+ expect(plan.plannedOutputs).toEqual({ a: [] });
228
+ expect(plan.deployedOutputs).toEqual({ net: ["cidr"] });
214
229
  expect(plan.changeSet.digest).toBe(plan.digest);
215
230
  expect(plan.approval).toEqual({ status: "pending" });
216
231
 
217
232
  const note = github.comments.find((c) => c.pr === 1)!;
218
233
  expect(note.body.startsWith("<!-- chant-pr:local -->")).toBe(true);
219
234
  expect(note.body).toContain(`chant approve pr-1 pr-apply --plan ${plan.digest} --approver github:<your-login> --sign`);
235
+ expect(note.body).toContain("Known only after apply: `app` reads `a.id`.");
220
236
  expect(github.statuses.at(-1)).toMatchObject({ context: "chant/plan", state: "success", sha: plan.head });
221
237
  }, 240_000);
222
238
 
223
- test("on merge it applies the plan the reviewer approved", async () => {
239
+ test("on merge it applies the plan the reviewer approved, and plans the dependent again once its upstream applied (#3999)", async () => {
224
240
  const plan = report("plan");
225
241
  github.reviews[1] = [{ user: { login: "alice" }, state: "APPROVED" }];
226
242
  expect((await recordGateApproval("pr-1", "pr-apply", { actor: "github:alice", plan: plan.digest })).ok).toBe(true);
227
243
 
228
- expect(await merge(1)).toBe(0);
244
+ // a applies; app, planned now against the id a applied as, waits on its own gate.
245
+ expect(await merge(1)).toBe(3);
246
+ const waiting = report("apply");
247
+ expect(waiting.digest).toBe(plan.digest);
248
+ expect(waiting.status).toBe("waiting");
249
+ expect(waiting.members.map((m) => [m.member, m.status, m.round])).toEqual([
250
+ ["a", "applied", undefined],
251
+ ["app", "waiting", 2],
252
+ ]);
253
+ const round = waiting.rounds![0]!;
254
+ expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", components: ["app"], status: "waiting", approval: { status: "pending" } });
255
+ expect(round.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([["app", "terraform_data.app", "update"]]);
256
+ expect(state("a")).toContain("10.0.0.0/16/a-v2");
257
+ expect(state("app")).not.toContain("a-v2");
258
+ expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "pending" });
259
+ expect(github.comments.find((c) => c.pr === 1)!.body).toContain(`chant approve pr-1 pr-apply-round-2 --plan ${round.digest}`);
260
+ }, 240_000);
261
+
262
+ test("once the reviewer approves the round's plan, the same apply carries on from its record and applies the dependent (#3999)", async () => {
263
+ const plan = report("plan");
264
+ const round = report("apply").rounds![0]!;
265
+ expect((await recordGateApproval("pr-1", "pr-apply-round-2", { actor: "github:alice", plan: round.digest })).ok).toBe(true);
266
+ expect(await handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never)).toBe(0);
229
267
  const applied = report("apply");
230
268
  expect(applied.digest).toBe(plan.digest);
231
269
  expect(applied.status).toBe("applied");
232
270
  expect(applied.approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
233
- expect(applied.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
271
+ expect(applied.resumed).toEqual({ applied: ["a"] });
272
+ expect(applied.members.map((m) => [m.member, m.status, m.round])).toEqual([
234
273
  ["a", "applied", undefined],
235
- ["app", "applied", ["a"]],
274
+ ["app", "applied", 2],
236
275
  ]);
237
- expect(state("a")).toContain("10.0.0.0/16/a-v2");
276
+ expect(applied.rounds?.map((r) => [r.digest, r.status, r.approval.approvedBy])).toEqual([[round.digest, "applied", ["github:alice"]]]);
277
+ expect(state("app")).toContain("app on 10.0.0.0/16/a-v2");
238
278
  // The same note, now with the apply's outcome.
239
279
  expect(github.comments.filter((c) => c.pr === 1)).toHaveLength(1);
240
280
  expect(github.comments.find((c) => c.pr === 1)!.body).toContain("### chant apply for `local`");
@@ -251,9 +291,9 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
251
291
  expect(stored.digest).toBe(applied.digest);
252
292
  expect(stored.graph).toEqual({ member: null, at: applied.head });
253
293
  // The plan's addresses, each on the block the terraform lexicon's graph names `<root>/<address>`.
294
+ // app's plan had a hole for a's id, and no entries.
254
295
  expect(stored.entries.map((e) => [e.member, e.address, e.action, e.node])).toEqual([
255
296
  ["a", "terraform_data.subnet", "update", "a/terraform_data.subnet"],
256
- ["app", "terraform_data.app", "no-op", "app/terraform_data.app"],
257
297
  ]);
258
298
  // And those are ids the estate's graph has.
259
299
  const graph = await estateGraphIds(dir);
@@ -176,6 +176,12 @@ describe("TerraformAdoptOp compensation (#2105)", () => {
176
176
  expect((op.onFailure![0].steps[0] as ActivityStep).args?.cmd).toBe("./untag.sh");
177
177
  });
178
178
 
179
+ test("a supplied image runs the rollback in that container (#4015)", () => {
180
+ const image = `alpine:3.20@sha256:${"b".repeat(64)}`;
181
+ const op = props({ ...base, compensate: { command: "./untag.sh", image } });
182
+ expect((op.onFailure![0].steps[0] as ActivityStep).args).toEqual({ cmd: "./untag.sh", image });
183
+ });
184
+
179
185
  test("compensate: false and omitting it both leave no onFailure", () => {
180
186
  expect(props(base).onFailure).toBeUndefined();
181
187
  expect(props({ ...base, compensate: false }).onFailure).toBeUndefined();
@@ -143,7 +143,11 @@ export interface TerraformAdoptOpConfig {
143
143
  * object with no `command`, throws when the Op is built, naming the Op,
144
144
  * rather than warning at the moment a rollback is already wanted.
145
145
  */
146
- compensate?: boolean | { command?: string };
146
+ compensate?: boolean | {
147
+ command?: string;
148
+ /** Run the rollback command in a container pinned by digest (#4015), as `shell()`'s `image` does. */
149
+ image?: string;
150
+ };
147
151
  }
148
152
 
149
153
  export interface TerraformAdoptOpResources {
@@ -153,6 +157,7 @@ export interface TerraformAdoptOpResources {
153
157
 
154
158
  export function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdoptOpResources {
155
159
  const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
160
+ const compensateImage = typeof config.compensate === "object" ? config.compensate.image : undefined;
156
161
  if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
157
162
  throw new Error(
158
163
  `TerraformAdoptOp "${config.name}": compensate is enabled, but there is no automatic undo for an ` +
@@ -212,7 +217,7 @@ export function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdopt
212
217
  phase("Adopt", [adopt]),
213
218
  ],
214
219
  ...(compensateCommand
215
- ? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand })])] }
220
+ ? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand, ...(compensateImage ? { image: compensateImage } : {}) })])] }
216
221
  : {}),
217
222
  });
218
223
 
@@ -179,6 +179,12 @@ describe("TerraformApplyOp compensation — total or refused (#2086)", () => {
179
179
  args: { cmd: "terraform apply -input=false rollback.tfplan" },
180
180
  });
181
181
  });
182
+
183
+ test("compensate: { command, image } runs the rollback in that image (#4015)", () => {
184
+ const image = `hashicorp/terraform:1.9@sha256:${"a".repeat(64)}`;
185
+ const op = props({ name: "a", root: "app", compensate: { command: "terraform apply rollback.tfplan", image } });
186
+ expect(op.onFailure![0].steps[0]).toMatchObject({ fn: "shellCmd", args: { cmd: "terraform apply rollback.tfplan", image } });
187
+ });
182
188
  });
183
189
 
184
190
  describe("TerraformApplyOp on the local executor (#2086, gate-as-fact #2119)", () => {
@@ -158,7 +158,11 @@ export interface TerraformApplyOpConfig {
158
158
  * rollback is already needed. Supply `compensate: { command: "..." }` with a
159
159
  * rollback of your own, or leave it unset.
160
160
  */
161
- compensate?: boolean | { command?: string };
161
+ compensate?: boolean | {
162
+ command?: string;
163
+ /** Run the rollback command in a container pinned by digest (#4015), as `shell()`'s `image` does. */
164
+ image?: string;
165
+ };
162
166
  }
163
167
 
164
168
  export interface TerraformApplyOpResources {
@@ -174,6 +178,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
174
178
  // fails here, at build time, with the Op named — not as a warning when the
175
179
  // apply has already half-run. Same shape as ApplyOp's refusal (#1449).
176
180
  const compensateCommand = typeof config.compensate === "object" ? config.compensate.command : undefined;
181
+ const compensateImage = typeof config.compensate === "object" ? config.compensate.image : undefined;
177
182
  if (config.compensate !== undefined && config.compensate !== false && compensateCommand === undefined) {
178
183
  throw new Error(
179
184
  `TerraformApplyOp "${config.name}": compensate is enabled, but terraform has no automatic ` +
@@ -274,7 +279,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
274
279
  },
275
280
  phases,
276
281
  ...(compensateCommand
277
- ? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand })])] }
282
+ ? { onFailure: [phase("Rollback", [activity("shellCmd", { cmd: compensateCommand, ...(compensateImage ? { image: compensateImage } : {}) })])] }
278
283
  : {}),
279
284
  });
280
285
 
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { terraformPlugin } from "./plugin";
8
8
  export {
9
9
  terraformCapabilityPlugin,
10
10
  terraformApplyCapability,
11
+ TerraformPlanMovedError,
11
12
  TERRAFORM_VERB_FAMILIES,
12
13
  type TerraformApplyStepInput,
13
14
  type TerraformApplyStepOutput,