@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32

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 (141) 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.js +152 -0
  20. package/dist/composites/terraform-apply-op.js +183 -0
  21. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  22. package/dist/composites/terraform-watch-op.js +219 -0
  23. package/dist/config.js +118 -0
  24. package/dist/describe-resources.js +984 -0
  25. package/dist/generated/index.js +1 -0
  26. package/dist/hcl/descend.js +208 -0
  27. package/dist/hcl/edges.js +287 -0
  28. package/dist/hcl/jsonencode.js +442 -0
  29. package/dist/hcl/marker.js +212 -0
  30. package/dist/hcl/parse.js +372 -0
  31. package/dist/hcl/references.js +156 -0
  32. package/dist/hcl/roots.js +98 -0
  33. package/dist/hcl/suppressions.js +189 -0
  34. package/dist/hcl/value.js +72 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +46 -0
  38. package/dist/integrity.json +2 -2
  39. package/dist/lint/audit-catalog.js +501 -0
  40. package/dist/lint/audit-lineage.js +442 -0
  41. package/dist/lint/post-synth/blocks.js +87 -0
  42. package/dist/lint/post-synth/fixtures/load.js +94 -0
  43. package/dist/lint/post-synth/flat-attributes.js +135 -0
  44. package/dist/lint/post-synth/index.js +90 -0
  45. package/dist/lint/post-synth/module-source.js +153 -0
  46. package/dist/lint/post-synth/scope.js +104 -0
  47. package/dist/lint/post-synth/terragrunt.js +63 -0
  48. package/dist/lint/post-synth/tf001.js +138 -0
  49. package/dist/lint/post-synth/tf002.js +143 -0
  50. package/dist/lint/post-synth/tf003.js +60 -0
  51. package/dist/lint/post-synth/tf004.js +54 -0
  52. package/dist/lint/post-synth/tf005.js +68 -0
  53. package/dist/lint/post-synth/tf006.js +43 -0
  54. package/dist/lint/post-synth/tf007.js +66 -0
  55. package/dist/lint/post-synth/tf008.js +64 -0
  56. package/dist/lint/post-synth/tf009.js +49 -0
  57. package/dist/lint/post-synth/tf010.js +40 -0
  58. package/dist/lint/post-synth/tf011.js +38 -0
  59. package/dist/lint/post-synth/tf012.js +36 -0
  60. package/dist/lint/post-synth/tf013.js +53 -0
  61. package/dist/lint/post-synth/tf014.js +61 -0
  62. package/dist/lint/post-synth/tf015.js +62 -0
  63. package/dist/lint/post-synth/tf016.js +66 -0
  64. package/dist/lint/post-synth/tf017.js +39 -0
  65. package/dist/lint/post-synth/tf018.js +63 -0
  66. package/dist/lint/post-synth/tf019.js +60 -0
  67. package/dist/lint/post-synth/tf020.js +103 -0
  68. package/dist/lint/post-synth/tf021.js +117 -0
  69. package/dist/lint/post-synth/tf022.js +51 -0
  70. package/dist/lint/post-synth/tf024.js +62 -0
  71. package/dist/lint/post-synth/tf025.js +83 -0
  72. package/dist/lint/post-synth/tf026.js +74 -0
  73. package/dist/lint/post-synth/tf027.js +66 -0
  74. package/dist/lint/post-synth/tf028.js +83 -0
  75. package/dist/lint/post-synth/tf029.js +73 -0
  76. package/dist/lint/post-synth/tf030.js +253 -0
  77. package/dist/lint/post-synth/tf031.js +332 -0
  78. package/dist/lint/post-synth/tf032.js +201 -0
  79. package/dist/lint/post-synth/tf033.js +126 -0
  80. package/dist/lint/post-synth/tf034.js +45 -0
  81. package/dist/lint/post-synth/tf035.js +63 -0
  82. package/dist/lint/post-synth/tf036.js +97 -0
  83. package/dist/lint/post-synth/tf037.js +80 -0
  84. package/dist/lint/post-synth/tf038.js +63 -0
  85. package/dist/lint/post-synth/tf039.js +55 -0
  86. package/dist/lint/post-synth/tf040.js +67 -0
  87. package/dist/lint/post-synth/tf041.js +53 -0
  88. package/dist/lint/post-synth/tf042.js +38 -0
  89. package/dist/lint/post-synth/tf043.js +45 -0
  90. package/dist/lint/post-synth/tf044.js +45 -0
  91. package/dist/lint/post-synth/tf045.js +44 -0
  92. package/dist/lint/rules/fixtures/load.js +50 -0
  93. package/dist/lint/rules/index.js +4 -0
  94. package/dist/lint/rules/plan-before-apply.js +136 -0
  95. package/dist/lint/secret-shape.js +149 -0
  96. package/dist/live-ownership.js +57 -0
  97. package/dist/lsp/completions.js +95 -0
  98. package/dist/lsp/context.js +358 -0
  99. package/dist/lsp/hover.js +45 -0
  100. package/dist/lsp/option-keys.js +93 -0
  101. package/dist/manifest.json +1 -1
  102. package/dist/op/activities/index.js +30 -0
  103. package/dist/op/activities/live-detect.js +143 -0
  104. package/dist/op/activities/pin-rollout.js +45 -0
  105. package/dist/op/activities/terraform.js +1270 -0
  106. package/dist/op/activity-contracts.js +162 -0
  107. package/dist/op/adoption.js +272 -0
  108. package/dist/op/builders.js +125 -0
  109. package/dist/op/resolve-root-mode.js +77 -0
  110. package/dist/package-cli.js +16 -0
  111. package/dist/pin/edit-ts.js +76 -0
  112. package/dist/pin/edit.js +118 -0
  113. package/dist/pin/forge.js +65 -0
  114. package/dist/pin/index.js +16 -0
  115. package/dist/pin/rollout.d.ts.map +1 -1
  116. package/dist/pin/rollout.js +382 -0
  117. package/dist/pin/scan.js +287 -0
  118. package/dist/pin/source.js +171 -0
  119. package/dist/pin/waves.js +109 -0
  120. package/dist/plan-digest.js +67 -0
  121. package/dist/plugin.js +323 -0
  122. package/dist/serializer.js +23 -0
  123. package/dist/state-ownership.js +29 -0
  124. package/dist/terragrunt/affected.d.ts.map +1 -1
  125. package/dist/terragrunt/affected.js +290 -0
  126. package/dist/terragrunt/index.js +10 -0
  127. package/dist/terragrunt/mocks.js +270 -0
  128. package/dist/terragrunt/run.js +264 -0
  129. package/dist/terragrunt/units.js +195 -0
  130. package/dist/terragrunt/wave.js +125 -0
  131. package/dist/validate-cli.js +4 -0
  132. package/dist/validate.js +31 -0
  133. package/package.json +10 -10
  134. package/src/codegen/package.ts +2 -2
  135. package/src/components/capability-plugin.test.ts +97 -2
  136. package/src/components/capability-plugin.ts +117 -12
  137. package/src/components/pr-loop.tofu.test.ts +51 -11
  138. package/src/index.ts +1 -0
  139. package/src/pin/rollout.ts +2 -1
  140. package/src/terragrunt/affected.test.ts +11 -0
  141. 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.32",
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.32",
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);
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,
@@ -140,7 +140,8 @@ export interface PinRolloutResult extends PinRequest {
140
140
  const defaultRun: CommandRunner = async (bin, args, cwd) =>
141
141
  (await execFileAsync(bin, args, { cwd, maxBuffer: 64 * 1024 * 1024, ...(bin === "gh" ? { env: await ghHostEnv(process.env, cwd) } : {}) })).stdout;
142
142
 
143
- const SKIP_DIRS = new Set([".git", ".terraform", ".terragrunt-cache", "node_modules"]);
143
+ // `.chant` holds work worktrees in `.chant/work`, each a copy of the checkout (chant#2893).
144
+ const SKIP_DIRS = new Set([".git", ".terraform", ".terragrunt-cache", "node_modules", ".chant"]);
144
145
 
145
146
  const NO_FORGE: PinForge = {
146
147
  findPullRequest: async () => null,
@@ -22,6 +22,7 @@ import {
22
22
  terragruntModuleDirs,
23
23
  type TerragruntExec,
24
24
  } from "./index";
25
+ import { findTerragruntStackFiles } from "./affected";
25
26
 
26
27
  const fixtures = join(dirname(fileURLToPath(import.meta.url)), "../__fixtures__/terragrunt");
27
28
  const discovered = parseTerragruntFind(readFileSync(join(fixtures, "recorded/affected-find.json"), "utf8"));
@@ -132,6 +133,16 @@ describe("stack files", () => {
132
133
  { type: "stack", name: "svc", template: "catalog/stacks/svc", generated: "live/stg/.terragrunt-stack/svc" },
133
134
  ]);
134
135
  });
136
+
137
+ it("a work worktree under .chant/work is not searched for stack files (chant#2893)", () => {
138
+ const dir = join(mkdtempSync(join(tmpdir(), "chant-tg-stacks-")), "repo");
139
+ tmps.push(dirname(dir));
140
+ for (const p of ["live/terragrunt.stack.hcl", ".chant/work/W-1/live/terragrunt.stack.hcl"]) {
141
+ mkdirSync(dirname(join(dir, p)), { recursive: true });
142
+ writeFileSync(join(dir, p), "");
143
+ }
144
+ expect(findTerragruntStackFiles(dir)).toEqual(["live/terragrunt.stack.hcl"]);
145
+ });
135
146
  });
136
147
 
137
148
  describe("the affected runner", () => {
@@ -180,7 +180,8 @@ function topLevelString(body: string, name: string): string | undefined {
180
180
  return undefined;
181
181
  }
182
182
 
183
- const SKIP_DIRS = new Set([".git", ".terragrunt-cache", "node_modules", ".terraform"]);
183
+ // `.chant` holds work worktrees in `.chant/work`, each a copy of the checkout (chant#2893).
184
+ const SKIP_DIRS = new Set([".git", ".terragrunt-cache", "node_modules", ".terraform", ".chant"]);
184
185
 
185
186
  /** Every `terragrunt.stack.hcl` under `dir`, generated ones included, relative to `dir`. */
186
187
  export function findTerragruntStackFiles(dir: string): string[] {