@intentius/chant-lexicon-terraform 0.102.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.d.ts +23 -0
  2. package/dist/__fixtures__/terragrunt/affected-cases.d.ts.map +1 -0
  3. package/dist/__fixtures__/terragrunt/record.d.ts +17 -0
  4. package/dist/__fixtures__/terragrunt/record.d.ts.map +1 -0
  5. package/dist/change-set.d.ts +67 -0
  6. package/dist/change-set.d.ts.map +1 -0
  7. package/dist/changed-roots.d.ts +30 -0
  8. package/dist/changed-roots.d.ts.map +1 -0
  9. package/dist/commands.d.ts +26 -0
  10. package/dist/commands.d.ts.map +1 -0
  11. package/dist/components/capability-plugin.d.ts +54 -0
  12. package/dist/components/capability-plugin.d.ts.map +1 -0
  13. package/dist/composites/terraform-pin-rollout-op.d.ts +44 -0
  14. package/dist/composites/terraform-pin-rollout-op.d.ts.map +1 -0
  15. package/dist/hcl/descend.d.ts +1 -1
  16. package/dist/hcl/descend.d.ts.map +1 -1
  17. package/dist/hcl/edges.d.ts +2 -2
  18. package/dist/hcl/edges.d.ts.map +1 -1
  19. package/dist/hcl/parse.d.ts +29 -1
  20. package/dist/hcl/parse.d.ts.map +1 -1
  21. package/dist/hcl/roots.d.ts +1 -1
  22. package/dist/hcl/roots.d.ts.map +1 -1
  23. package/dist/hcl/suppressions.d.ts.map +1 -1
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/integrity.json +13 -4
  27. package/dist/lint/audit-catalog.d.ts.map +1 -1
  28. package/dist/lint/post-synth/index.d.ts.map +1 -1
  29. package/dist/lint/post-synth/module-source.d.ts +27 -1
  30. package/dist/lint/post-synth/module-source.d.ts.map +1 -1
  31. package/dist/lint/post-synth/terragrunt.d.ts +37 -0
  32. package/dist/lint/post-synth/terragrunt.d.ts.map +1 -0
  33. package/dist/lint/post-synth/tf002.d.ts +3 -1
  34. package/dist/lint/post-synth/tf002.d.ts.map +1 -1
  35. package/dist/lint/post-synth/tf038.d.ts +22 -0
  36. package/dist/lint/post-synth/tf038.d.ts.map +1 -0
  37. package/dist/lint/post-synth/tf039.d.ts +23 -0
  38. package/dist/lint/post-synth/tf039.d.ts.map +1 -0
  39. package/dist/lint/post-synth/tf040.d.ts +30 -0
  40. package/dist/lint/post-synth/tf040.d.ts.map +1 -0
  41. package/dist/lint/post-synth/tf041.d.ts +20 -0
  42. package/dist/lint/post-synth/tf041.d.ts.map +1 -0
  43. package/dist/lint/post-synth/tf042.d.ts +18 -0
  44. package/dist/lint/post-synth/tf042.d.ts.map +1 -0
  45. package/dist/lint/post-synth/tf043.d.ts +17 -0
  46. package/dist/lint/post-synth/tf043.d.ts.map +1 -0
  47. package/dist/lint/post-synth/tf044.d.ts +17 -0
  48. package/dist/lint/post-synth/tf044.d.ts.map +1 -0
  49. package/dist/lint/post-synth/tf045.d.ts +22 -0
  50. package/dist/lint/post-synth/tf045.d.ts.map +1 -0
  51. package/dist/manifest.json +1 -1
  52. package/dist/okf/index.md +8 -0
  53. package/dist/okf/rules/TF038.md +11 -0
  54. package/dist/okf/rules/TF039.md +11 -0
  55. package/dist/okf/rules/TF040.md +11 -0
  56. package/dist/okf/rules/TF041.md +11 -0
  57. package/dist/okf/rules/TF042.md +11 -0
  58. package/dist/okf/rules/TF043.md +11 -0
  59. package/dist/okf/rules/TF044.md +11 -0
  60. package/dist/okf/rules/TF045.md +11 -0
  61. package/dist/op/activities/index.d.ts +4 -0
  62. package/dist/op/activities/index.d.ts.map +1 -1
  63. package/dist/op/activities/pin-rollout.d.ts +46 -0
  64. package/dist/op/activities/pin-rollout.d.ts.map +1 -0
  65. package/dist/op/activities/terraform.d.ts +24 -50
  66. package/dist/op/activities/terraform.d.ts.map +1 -1
  67. package/dist/op/activity-contracts.d.ts +48 -0
  68. package/dist/op/activity-contracts.d.ts.map +1 -1
  69. package/dist/op/builders.d.ts +9 -0
  70. package/dist/op/builders.d.ts.map +1 -1
  71. package/dist/pin/edit-ts.d.ts +21 -0
  72. package/dist/pin/edit-ts.d.ts.map +1 -0
  73. package/dist/pin/edit.d.ts +52 -0
  74. package/dist/pin/edit.d.ts.map +1 -0
  75. package/dist/pin/forge.d.ts +39 -0
  76. package/dist/pin/forge.d.ts.map +1 -0
  77. package/dist/pin/index.d.ts +22 -0
  78. package/dist/pin/index.d.ts.map +1 -0
  79. package/dist/pin/rollout.d.ts +116 -0
  80. package/dist/pin/rollout.d.ts.map +1 -0
  81. package/dist/pin/scan.d.ts +54 -0
  82. package/dist/pin/scan.d.ts.map +1 -0
  83. package/dist/pin/source.d.ts +93 -0
  84. package/dist/pin/source.d.ts.map +1 -0
  85. package/dist/pin/waves.d.ts +66 -0
  86. package/dist/pin/waves.d.ts.map +1 -0
  87. package/dist/plan-digest.d.ts +59 -0
  88. package/dist/plan-digest.d.ts.map +1 -0
  89. package/dist/plugin.d.ts.map +1 -1
  90. package/dist/rules/module-source.ts +56 -1
  91. package/dist/rules/terragrunt.ts +76 -0
  92. package/dist/rules/tf002.ts +1 -1
  93. package/dist/rules/tf038.ts +71 -0
  94. package/dist/rules/tf039.ts +64 -0
  95. package/dist/rules/tf040.ts +76 -0
  96. package/dist/rules/tf041.ts +56 -0
  97. package/dist/rules/tf042.ts +42 -0
  98. package/dist/rules/tf043.ts +49 -0
  99. package/dist/rules/tf044.ts +51 -0
  100. package/dist/rules/tf045.ts +47 -0
  101. package/dist/terragrunt/affected.d.ts +119 -0
  102. package/dist/terragrunt/affected.d.ts.map +1 -0
  103. package/dist/terragrunt/index.d.ts +11 -0
  104. package/dist/terragrunt/index.d.ts.map +1 -0
  105. package/dist/terragrunt/mocks.d.ts +147 -0
  106. package/dist/terragrunt/mocks.d.ts.map +1 -0
  107. package/dist/terragrunt/run.d.ts +130 -0
  108. package/dist/terragrunt/run.d.ts.map +1 -0
  109. package/dist/terragrunt/units.d.ts +103 -0
  110. package/dist/terragrunt/units.d.ts.map +1 -0
  111. package/dist/terragrunt/wave.d.ts +89 -0
  112. package/dist/terragrunt/wave.d.ts.map +1 -0
  113. package/package.json +12 -2
  114. package/src/__fixtures__/change-set/choudoufu-set-plan.change-set.golden.json +95 -0
  115. package/src/__fixtures__/change-set/choudoufu-set-plan.json +76 -0
  116. package/src/__fixtures__/change-set/choudoufu.part.golden.json +173 -0
  117. package/src/__fixtures__/change-set/choudoufu.plan.json +610 -0
  118. package/src/__fixtures__/change-set/terraform.import-forget.plan.json +81 -0
  119. package/src/__fixtures__/change-set/terraform.part.golden.json +173 -0
  120. package/src/__fixtures__/change-set/terraform.plan.json +589 -0
  121. package/src/__fixtures__/change-set/tofu.part.golden.json +173 -0
  122. package/src/__fixtures__/change-set/tofu.plan.json +588 -0
  123. package/src/__fixtures__/change-set/v1.tf.txt +21 -0
  124. package/src/__fixtures__/change-set/v2-module-app.tf.txt +4 -0
  125. package/src/__fixtures__/change-set/v2.tf.txt +21 -0
  126. package/src/__fixtures__/pin/choudoufu-live-waves-n5.json +83 -0
  127. package/src/__fixtures__/pin/largeset-oci-n5/apply-order.txt +5 -0
  128. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/estate.chdf.hcl +6 -0
  129. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/main.tf +33 -0
  130. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/estate.chdf.hcl +6 -0
  131. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/main.tf +45 -0
  132. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/estate.chdf.hcl +6 -0
  133. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/main.tf +47 -0
  134. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/estate.chdf.hcl +6 -0
  135. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/main.tf +31 -0
  136. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/estate.chdf.hcl +6 -0
  137. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/main.tf +27 -0
  138. package/src/__fixtures__/pin/largeset-oci-n5/fixture.json +77 -0
  139. package/src/__fixtures__/plan-summary/largeset-n5-bump.json +2071 -0
  140. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.json +95 -0
  141. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.md +22 -0
  142. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.txt +12 -0
  143. package/src/__fixtures__/terragrunt/affected/catalog/units/web/terragrunt.hcl +3 -0
  144. package/src/__fixtures__/terragrunt/affected/gitignore +3 -0
  145. package/src/__fixtures__/terragrunt/affected/live/dev/app/terragrunt.hcl +3 -0
  146. package/src/__fixtures__/terragrunt/affected/live/dev/vpc/terragrunt.hcl +3 -0
  147. package/src/__fixtures__/terragrunt/affected/live/prod/app/terragrunt.hcl +3 -0
  148. package/src/__fixtures__/terragrunt/affected/live/stg/terragrunt.stack.hcl +5 -0
  149. package/src/__fixtures__/terragrunt/affected/modules/app/main.tf +6 -0
  150. package/src/__fixtures__/terragrunt/affected/modules/app/policy.json +1 -0
  151. package/src/__fixtures__/terragrunt/affected/modules/net/main.tf +2 -0
  152. package/src/__fixtures__/terragrunt/affected/modules/thing/main.tf +3 -0
  153. package/src/__fixtures__/terragrunt/affected/root.hcl +5 -0
  154. package/src/__fixtures__/terragrunt/affected-cases.ts +126 -0
  155. package/src/__fixtures__/terragrunt/five-units/catalog/units/thing/terragrunt.hcl +3 -0
  156. package/src/__fixtures__/terragrunt/five-units/live/dev/app/terragrunt.hcl +13 -0
  157. package/src/__fixtures__/terragrunt/five-units/live/dev/db/terragrunt.hcl +8 -0
  158. package/src/__fixtures__/terragrunt/five-units/live/dev/vpc/terragrunt.hcl +3 -0
  159. package/src/__fixtures__/terragrunt/five-units/live/prod/app/terragrunt.hcl +8 -0
  160. package/src/__fixtures__/terragrunt/five-units/live/prod/vpc/terragrunt.hcl +3 -0
  161. package/src/__fixtures__/terragrunt/five-units/modules/thing/main.tf +7 -0
  162. package/src/__fixtures__/terragrunt/five-units/root.hcl +5 -0
  163. package/src/__fixtures__/terragrunt/record.ts +56 -0
  164. package/src/__fixtures__/terragrunt/recorded/affected-find.json +50 -0
  165. package/src/__fixtures__/terragrunt/recorded/failed-plan-report.json +36 -0
  166. package/src/__fixtures__/terragrunt/recorded/find.json +67 -0
  167. package/src/__fixtures__/terragrunt/recorded/version.txt +1 -0
  168. package/src/__fixtures__/terragrunt/recorded/wave-1/apply-report.json +22 -0
  169. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/dev/vpc/tfplan.json +1 -0
  170. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/prod/vpc/tfplan.json +1 -0
  171. package/src/__fixtures__/terragrunt/recorded/wave-1/plan-report.json +22 -0
  172. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/dev/db/tfplan.json +1 -0
  173. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/prod/app/tfplan.json +1 -0
  174. package/src/__fixtures__/terragrunt/recorded/wave-2/plan-report.json +22 -0
  175. package/src/change-set-combined-run.test.ts +156 -0
  176. package/src/change-set.test.ts +151 -0
  177. package/src/change-set.ts +273 -0
  178. package/src/changed-roots.test.ts +58 -0
  179. package/src/changed-roots.ts +102 -0
  180. package/src/commands.ts +112 -0
  181. package/src/components/capability-plugin.test.ts +91 -0
  182. package/src/components/capability-plugin.ts +131 -0
  183. package/src/components/pr-loop.tofu.test.ts +244 -0
  184. package/src/composites/terraform-pin-rollout-op.test.ts +69 -0
  185. package/src/composites/terraform-pin-rollout-op.ts +60 -0
  186. package/src/hcl/descend.ts +1 -1
  187. package/src/hcl/edges.ts +2 -2
  188. package/src/hcl/parse.test.ts +25 -0
  189. package/src/hcl/parse.ts +91 -6
  190. package/src/hcl/roots.ts +1 -1
  191. package/src/hcl/suppressions.ts +8 -2
  192. package/src/index.ts +18 -0
  193. package/src/lint/audit-catalog.ts +82 -0
  194. package/src/lint/audit.test.ts +3 -1
  195. package/src/lint/post-synth/fixtures/TF038/positive.tf +3 -0
  196. package/src/lint/post-synth/fixtures/TF039/positive.tf +4 -0
  197. package/src/lint/post-synth/fixtures/TF041/negative/terragrunt.hcl +12 -0
  198. package/src/lint/post-synth/fixtures/TF041/positive/terragrunt.hcl +7 -0
  199. package/src/lint/post-synth/fixtures/TF041/positive-apply/terragrunt.hcl +8 -0
  200. package/src/lint/post-synth/fixtures/TF042/negative/terragrunt.hcl +13 -0
  201. package/src/lint/post-synth/fixtures/TF042/positive/terragrunt.hcl +9 -0
  202. package/src/lint/post-synth/fixtures/TF043/negative/terragrunt.hcl +3 -0
  203. package/src/lint/post-synth/fixtures/TF043/negative-other/terragrunt.hcl +3 -0
  204. package/src/lint/post-synth/fixtures/TF043/positive-git/terragrunt.hcl +3 -0
  205. package/src/lint/post-synth/fixtures/TF043/positive-oci/terragrunt.hcl +3 -0
  206. package/src/lint/post-synth/fixtures/TF043/positive-tfr/terragrunt.hcl +3 -0
  207. package/src/lint/post-synth/fixtures/TF044/negative/terragrunt.hcl +9 -0
  208. package/src/lint/post-synth/fixtures/TF044/positive/terragrunt.hcl +7 -0
  209. package/src/lint/post-synth/fixtures/TF044/positive-generate/terragrunt.hcl +9 -0
  210. package/src/lint/post-synth/fixtures/TF045/negative/root.hcl +1 -0
  211. package/src/lint/post-synth/fixtures/TF045/negative/terragrunt.hcl +3 -0
  212. package/src/lint/post-synth/fixtures/TF045/positive/root.hcl +9 -0
  213. package/src/lint/post-synth/fixtures/TF045/positive/terragrunt.hcl +3 -0
  214. package/src/lint/post-synth/fixtures/TF045/unit-only/terragrunt.hcl +3 -0
  215. package/src/lint/post-synth/index.ts +16 -0
  216. package/src/lint/post-synth/module-source.test.ts +42 -1
  217. package/src/lint/post-synth/module-source.ts +56 -1
  218. package/src/lint/post-synth/terragrunt.ts +76 -0
  219. package/src/lint/post-synth/tf002.ts +1 -1
  220. package/src/lint/post-synth/tf038.test.ts +51 -0
  221. package/src/lint/post-synth/tf038.ts +71 -0
  222. package/src/lint/post-synth/tf039.test.ts +40 -0
  223. package/src/lint/post-synth/tf039.ts +64 -0
  224. package/src/lint/post-synth/tf040.test.ts +55 -0
  225. package/src/lint/post-synth/tf040.ts +76 -0
  226. package/src/lint/post-synth/tf041.test.ts +50 -0
  227. package/src/lint/post-synth/tf041.ts +56 -0
  228. package/src/lint/post-synth/tf042.test.ts +19 -0
  229. package/src/lint/post-synth/tf042.ts +42 -0
  230. package/src/lint/post-synth/tf043.test.ts +65 -0
  231. package/src/lint/post-synth/tf043.ts +49 -0
  232. package/src/lint/post-synth/tf044.test.ts +27 -0
  233. package/src/lint/post-synth/tf044.ts +51 -0
  234. package/src/lint/post-synth/tf045.test.ts +30 -0
  235. package/src/lint/post-synth/tf045.ts +47 -0
  236. package/src/op/activities/index.ts +4 -0
  237. package/src/op/activities/pin-rollout.ts +77 -0
  238. package/src/op/activities/terraform.test.ts +13 -0
  239. package/src/op/activities/terraform.ts +46 -86
  240. package/src/op/activity-contracts.test.ts +4 -1
  241. package/src/op/activity-contracts.ts +42 -0
  242. package/src/op/builders.ts +13 -0
  243. package/src/pin/bundle.test.ts +53 -0
  244. package/src/pin/edit-ts.test.ts +39 -0
  245. package/src/pin/edit-ts.ts +77 -0
  246. package/src/pin/edit.test.ts +199 -0
  247. package/src/pin/edit.ts +157 -0
  248. package/src/pin/forge.ts +85 -0
  249. package/src/pin/index.ts +22 -0
  250. package/src/pin/rollout.test.ts +232 -0
  251. package/src/pin/rollout.ts +451 -0
  252. package/src/pin/scan.ts +309 -0
  253. package/src/pin/source.ts +206 -0
  254. package/src/pin/waves.test.ts +101 -0
  255. package/src/pin/waves.ts +125 -0
  256. package/src/plan-digest.ts +99 -0
  257. package/src/plan-summary.test.ts +154 -0
  258. package/src/plugin.ts +25 -2
  259. package/src/terragrunt/affected.acceptance.test.ts +83 -0
  260. package/src/terragrunt/affected.test.ts +177 -0
  261. package/src/terragrunt/affected.ts +348 -0
  262. package/src/terragrunt/index.ts +11 -0
  263. package/src/terragrunt/mocks.acceptance.test.ts +105 -0
  264. package/src/terragrunt/mocks.test.ts +309 -0
  265. package/src/terragrunt/mocks.ts +324 -0
  266. package/src/terragrunt/run.ts +369 -0
  267. package/src/terragrunt/terragrunt.acceptance.test.ts +122 -0
  268. package/src/terragrunt/terragrunt.test.ts +332 -0
  269. package/src/terragrunt/units.ts +235 -0
  270. package/src/terragrunt/wave.ts +181 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Affected Terragrunt units (#3415) on the `affected/` fixture, with
3
+ * Terragrunt's own selection taken from `../__fixtures__/terragrunt/affected-cases.ts`
4
+ * and discovery from `recorded/affected-find.json` (Terragrunt 1.1.6, stacks
5
+ * generated). Nothing here runs Terragrunt; `affected.acceptance.test.ts` does.
6
+ */
7
+
8
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { afterAll, describe, expect, it } from "vitest";
13
+ import { AFFECTED_CASES } from "../__fixtures__/terragrunt/affected-cases";
14
+ import {
15
+ describeTerragruntAffectedReason,
16
+ findTerragruntAffected,
17
+ parseTerragruntFind,
18
+ readTerragruntStackFile,
19
+ terragruntAffected,
20
+ terragruntAffectedDiffArgs,
21
+ terragruntAffectedFindArgs,
22
+ terragruntModuleDirs,
23
+ type TerragruntExec,
24
+ } from "./index";
25
+
26
+ const fixtures = join(dirname(fileURLToPath(import.meta.url)), "../__fixtures__/terragrunt");
27
+ const discovered = parseTerragruntFind(readFileSync(join(fixtures, "recorded/affected-find.json"), "utf8"));
28
+
29
+ const tmps: string[] = [];
30
+ afterAll(() => {
31
+ for (const t of tmps) rmSync(t, { recursive: true, force: true });
32
+ });
33
+ function copyFixture(): string {
34
+ const dir = join(mkdtempSync(join(tmpdir(), "chant-tg-affected-")), "repo");
35
+ tmps.push(dirname(dir));
36
+ cpSync(join(fixtures, "affected"), dir, { recursive: true });
37
+ renameSync(join(dir, "gitignore"), join(dir, ".gitignore"));
38
+ return dir;
39
+ }
40
+
41
+ describe("affected units, table-driven", () => {
42
+ for (const c of AFFECTED_CASES) {
43
+ it(c.name, () => {
44
+ const dir = copyFixture();
45
+ c.change(dir);
46
+ // Discovery at head: a unit whose terragrunt.hcl is gone is not listed; generated units are.
47
+ const units = discovered.filter((u) => u.path.includes("/.terragrunt-stack/") || existsSync(join(dir, u.path, "terragrunt.hcl")));
48
+ const got = terragruntAffected({ dir, units, terragruntSelected: c.terragrunt, changedFiles: c.changed });
49
+ expect(got.units).toEqual(c.expected);
50
+ expect(got.notes).toEqual([]);
51
+ });
52
+ }
53
+
54
+ it("each supplement selects a unit Terragrunt's own range does not", () => {
55
+ for (const name of ["supplement 1", "supplement 2", "supplement 3"]) {
56
+ const c = AFFECTED_CASES.find((x) => x.name.startsWith(name))!;
57
+ expect(c.terragrunt).toEqual([]);
58
+ expect(c.expected.length).toBeGreaterThan(0);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("affected units, edges", () => {
64
+ it("notes a unit Terragrunt selected that discovery left out, and keeps it out", () => {
65
+ const dir = copyFixture();
66
+ const units = discovered.filter((u) => u.path !== "live/dev/vpc");
67
+ const got = terragruntAffected({ dir, units, terragruntSelected: ["live/dev/vpc"], changedFiles: ["live/dev/vpc/terragrunt.hcl"] });
68
+ expect(got.units).toEqual([]);
69
+ expect(got.notes).toEqual(["live/dev/vpc: Terragrunt's git range selected it, but discovery left it out, so it is not affected here"]);
70
+ });
71
+
72
+ it("notes a changed template whose units are not generated yet", () => {
73
+ const dir = copyFixture();
74
+ const units = discovered.filter((u) => !u.path.includes("/.terragrunt-stack/"));
75
+ const got = terragruntAffected({ dir, units, terragruntSelected: [], changedFiles: ["catalog/units/web/terragrunt.hcl"] });
76
+ expect(got.units).toEqual([]);
77
+ expect(got.notes).toEqual([
78
+ 'live/stg/.terragrunt-stack/web: generated by unit "web" in live/stg/terragrunt.stack.hcl from the changed template catalog/units/web, but discovery did not list it; run `terragrunt stack generate` before discovery',
79
+ ]);
80
+ });
81
+
82
+ it("a stack template selects every unit generated under it", () => {
83
+ const dir = copyFixture();
84
+ mkdirSync(join(dir, "live/qa"));
85
+ writeFileSync(join(dir, "live/qa/terragrunt.stack.hcl"), 'stack "svc" {\n source = "../../catalog/stacks/svc"\n path = "svc"\n}\n');
86
+ const units = [
87
+ { path: "live/qa/.terragrunt-stack/svc/.terragrunt-stack/web", dependencies: [] },
88
+ { path: "live/qa/.terragrunt-stack/svc/.terragrunt-stack/db", dependencies: [] },
89
+ { path: "live/dev/vpc", dependencies: [] },
90
+ ];
91
+ const got = terragruntAffected({ dir, units, terragruntSelected: [], changedFiles: ["catalog/stacks/svc/terragrunt.stack.hcl"] });
92
+ expect(got.units.map((u) => [u.path, u.reasons.map((r) => `${r.kind} via ${r.via}`)])).toEqual([
93
+ ["live/qa/.terragrunt-stack/svc/.terragrunt-stack/db", ["stack-template via catalog/stacks/svc"]],
94
+ ["live/qa/.terragrunt-stack/svc/.terragrunt-stack/web", ["stack-template via catalog/stacks/svc"]],
95
+ ]);
96
+ });
97
+
98
+ it("module directories come from the .tf files Terragrunt reports reading, outside the unit", () => {
99
+ expect(terragruntModuleDirs({ path: "live/a", dependencies: [], reading: ["root.hcl", "live/a/local.tf", "modules/x/main.tf", "modules/x/vars.tofu", "_envcommon/a.hcl"] })).toEqual([
100
+ "modules/x",
101
+ ]);
102
+ expect(terragruntModuleDirs({ path: "live/a", dependencies: [] })).toEqual([]);
103
+ });
104
+ });
105
+
106
+ describe("stack files", () => {
107
+ it("reads unit and stack blocks, their local templates and where they generate", () => {
108
+ const text = [
109
+ 'unit "web" {',
110
+ ' source = "../../catalog/units/web"',
111
+ ' path = "web"',
112
+ ' values = { path = "ignored", source = "ignored" }',
113
+ "}",
114
+ 'unit "flat" {',
115
+ ' source = "${get_terragrunt_dir()}/../../catalog/units/flat"',
116
+ ' path = "flat"',
117
+ " no_dot_terragrunt_stack = true",
118
+ "}",
119
+ 'unit "remote" {',
120
+ ' source = "git::https://example.com/catalog.git//units/web?ref=v1.2.0"',
121
+ ' path = "remote"',
122
+ "}",
123
+ 'stack "svc" {',
124
+ ' source = "../../catalog//stacks/svc"',
125
+ ' path = "svc"',
126
+ "}",
127
+ ].join("\n");
128
+ expect(readTerragruntStackFile("/repo", "live/stg/terragrunt.stack.hcl", text)).toEqual([
129
+ { type: "unit", name: "web", template: "catalog/units/web", generated: "live/stg/.terragrunt-stack/web" },
130
+ { type: "unit", name: "flat", template: "catalog/units/flat", generated: "live/stg/flat" },
131
+ { type: "unit", name: "remote", generated: "live/stg/.terragrunt-stack/remote" },
132
+ { type: "stack", name: "svc", template: "catalog/stacks/svc", generated: "live/stg/.terragrunt-stack/svc" },
133
+ ]);
134
+ });
135
+ });
136
+
137
+ describe("the affected runner", () => {
138
+ it("asks Terragrunt for the range with no filters file and the discovery excludes", () => {
139
+ expect(terragruntAffectedFindArgs({ base: "origin/main", exclude: ["sandbox/**"] })).toEqual([
140
+ "find",
141
+ "--json",
142
+ "--no-color",
143
+ "--no-filters-file",
144
+ "--filter",
145
+ "[origin/main...HEAD]",
146
+ "--filter",
147
+ "!./catalog/**",
148
+ "--filter",
149
+ "!./**/.terragrunt-cache/**",
150
+ "--filter",
151
+ "!./sandbox/**",
152
+ ]);
153
+ expect(terragruntAffectedDiffArgs({ base: "abc", head: "def" })).toEqual(["diff", "--name-only", "--no-renames", "--relative", "abc...def"]);
154
+ });
155
+
156
+ it("runs git and terragrunt, and joins them with discovery", async () => {
157
+ const dir = copyFixture();
158
+ const calls: string[] = [];
159
+ const exec: TerragruntExec = async (file, args) => {
160
+ calls.push(`${file} ${args[0]}`);
161
+ if (file === "git") return { code: 0, stdout: "modules/app/policy.json\nlive/dev/vpc/terragrunt.hcl\n", stderr: "" };
162
+ return { code: 0, stdout: JSON.stringify([{ type: "unit", path: "live/dev/vpc" }]), stderr: "" };
163
+ };
164
+ const got = await findTerragruntAffected({ dir, base: "main", units: discovered, exec });
165
+ expect(calls).toEqual(["git diff", "terragrunt find"]);
166
+ expect(got.units.map((u) => [u.path, u.reasons.map(describeTerragruntAffectedReason)])).toEqual([
167
+ ["live/dev/app", ["modules/app/policy.json changed under its module modules/app, which Terragrunt does not track"]],
168
+ ["live/dev/vpc", ["Terragrunt's git range selected it (live/dev/vpc/terragrunt.hcl)"]],
169
+ ["live/prod/app", ["modules/app/policy.json changed under its module modules/app, which Terragrunt does not track"]],
170
+ ]);
171
+ });
172
+
173
+ it("fails with the command when git fails", async () => {
174
+ const exec: TerragruntExec = async () => ({ code: 128, stdout: "", stderr: "fatal: bad revision 'nope...HEAD'" });
175
+ await expect(findTerragruntAffected({ dir: "/nowhere", base: "nope", units: [], exec })).rejects.toThrow(/git diff .* failed \(exit 128\): fatal: bad revision/);
176
+ });
177
+ });
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Affected Terragrunt units for a git range (#3415): what Terragrunt's own
3
+ * git filter selects, plus three cases it misses, each unit with its reasons.
4
+ *
5
+ * Terragrunt's `[base...head]` filter is the starting point, so the selection
6
+ * matches what `run --all` with that filter would run. Terragrunt 1.1.6 and
7
+ * 1.2.0-rc1 both miss these (observed on `../__fixtures__/terragrunt/affected`):
8
+ *
9
+ * - `module-file`: a file under a unit's local module directory that is not a
10
+ * configuration file, such as one the module reads with `file()`. A unit's
11
+ * module directory is where the `.tf` and `.tofu` files in its `reading`
12
+ * list live, so an interpolated `terraform.source` resolves the way
13
+ * Terragrunt resolved it. A file Terragrunt lists in `reading` is its own
14
+ * to track and is not counted here.
15
+ * - `module-call`: a file under a local module that the unit's module calls,
16
+ * followed through modules that call modules with the same reader
17
+ * `../changed-roots.ts` uses for plain roots.
18
+ * - `stack-template`: a file under a stack's local unit template selects the
19
+ * units generated from it. A stack template (a `stack` block) selects every
20
+ * unit generated under it.
21
+ *
22
+ * Dependents of the selected units are the caller's to add (`terragruntDependents`).
23
+ */
24
+
25
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
26
+ import { join, posix, relative, resolve, sep } from "node:path";
27
+ import { rootWatchPaths } from "../changed-roots";
28
+ import { excludeFilter, parseTerragruntFind, TERRAGRUNT_DISCOVERY_EXCLUDES, type TerragruntFindOptions, type TerragruntUnit } from "./units";
29
+ import { defaultTerragruntExec, discoverTerragruntUnits, TerragruntError, type TerragruntRunOptions } from "./run";
30
+ import { terragruntEnv } from "./wave";
31
+
32
+ /** Why a unit is affected. */
33
+ export type TerragruntAffectedKind =
34
+ /** Terragrunt's git filter selected it. */
35
+ | "terragrunt"
36
+ /** Terragrunt's git filter selected it and it is gone at head: Terragrunt plans its destroy from the base checkout. */
37
+ | "removed"
38
+ /** A changed file under the unit's module directory that Terragrunt does not track. */
39
+ | "module-file"
40
+ /** A changed file under a local module the unit's module calls. */
41
+ | "module-call"
42
+ /** A changed file under the stack template the unit is generated from. */
43
+ | "stack-template";
44
+
45
+ export interface TerragruntAffectedReason {
46
+ kind: TerragruntAffectedKind;
47
+ /** The changed files behind this reason, sorted. Empty when Terragrunt selected the unit for files chant cannot attribute. */
48
+ files: string[];
49
+ /** The module directory or template directory the files sit under. Unset for `terragrunt` and `removed`. */
50
+ via?: string;
51
+ }
52
+
53
+ export interface TerragruntAffectedUnit {
54
+ /** The unit's path, as discovery reports it. */
55
+ path: string;
56
+ /** Terragrunt's reason first, then the supplements in the order of {@link TerragruntAffectedKind}. */
57
+ reasons: TerragruntAffectedReason[];
58
+ }
59
+
60
+ export interface TerragruntAffected {
61
+ /** The affected units, sorted by path. */
62
+ units: TerragruntAffectedUnit[];
63
+ /** What the selection could not do, one line each: a unit Terragrunt selected that discovery left out, a generated unit not generated yet. */
64
+ notes: string[];
65
+ }
66
+
67
+ export interface TerragruntAffectedInput {
68
+ /** The project directory. Unit paths, `reading` entries and changed files are relative to it. */
69
+ dir: string;
70
+ /** The project's units at head, from discovery, with stacks generated. */
71
+ units: readonly TerragruntUnit[];
72
+ /** Unit paths Terragrunt's own `[base...head]` filter selected. */
73
+ terragruntSelected: readonly string[];
74
+ /** Files the range changed, relative to `dir`, `/`-separated, as `git diff --name-only --relative` prints them. */
75
+ changedFiles: readonly string[];
76
+ }
77
+
78
+ const KIND_ORDER: readonly TerragruntAffectedKind[] = ["terragrunt", "removed", "module-file", "module-call", "stack-template"];
79
+ const CONFIG_EXT = /\.(tf|tofu)(\.json)?$/;
80
+ const toPosix = (p: string): string => p.split(sep).join("/");
81
+ const clean = (p: string): string => posix.normalize(toPosix(p)).replace(/^\.\//, "").replace(/\/+$/, "");
82
+ const under = (dir: string, file: string): boolean => dir === "." || file === dir || file.startsWith(`${dir}/`);
83
+
84
+ /** Arguments to `terragrunt find` for the units Terragrunt's git filter selects between `base` and `head`. */
85
+ export function terragruntAffectedFindArgs(options: { base: string; head?: string } & TerragruntFindOptions): string[] {
86
+ const excludes = [...TERRAGRUNT_DISCOVERY_EXCLUDES, ...(options.exclude ?? [])];
87
+ return [
88
+ "find",
89
+ "--json",
90
+ "--no-color",
91
+ // A filters file is unioned with --filter, so it would add every unit it names to the range.
92
+ "--no-filters-file",
93
+ "--filter",
94
+ `[${options.base}...${options.head ?? "HEAD"}]`,
95
+ ...excludes.flatMap((g) => ["--filter", excludeFilter(g)]),
96
+ ];
97
+ }
98
+
99
+ /** Arguments to `git` for the files the range changed, relative to the project directory. */
100
+ export function terragruntAffectedDiffArgs(options: { base: string; head?: string }): string[] {
101
+ return ["diff", "--name-only", "--no-renames", "--relative", `${options.base}...${options.head ?? "HEAD"}`];
102
+ }
103
+
104
+ /** The unit's local module directories: where the `.tf` and `.tofu` files Terragrunt reports it reading live, outside the unit's own directory. */
105
+ export function terragruntModuleDirs(unit: TerragruntUnit): string[] {
106
+ const dirs = new Set<string>();
107
+ for (const f of unit.reading ?? []) {
108
+ if (!CONFIG_EXT.test(f) || under(unit.path, f) || f.includes(".terragrunt-cache/")) continue;
109
+ dirs.add(posix.dirname(f));
110
+ }
111
+ return [...dirs].sort();
112
+ }
113
+
114
+ /** A `unit` or `stack` block of a `terragrunt.stack.hcl`, as far as affected selection needs it. */
115
+ export interface TerragruntStackBlock {
116
+ type: "unit" | "stack";
117
+ name: string;
118
+ /** The template directory, relative to the project, for a local source; unset for a remote one. */
119
+ template?: string;
120
+ /** Where the block generates, relative to the project. */
121
+ generated: string;
122
+ }
123
+
124
+ /**
125
+ * The `unit` and `stack` blocks of a stack file at `stackFile` (relative to
126
+ * `dir`). Sources that are relative paths or start with `${get_terragrunt_dir()}`
127
+ * are local; anything else (git, registry, other functions) is left without a
128
+ * template, since its version lives in the stack file and a change to it is
129
+ * Terragrunt's to see.
130
+ */
131
+ export function readTerragruntStackFile(dir: string, stackFile: string, text: string): TerragruntStackBlock[] {
132
+ const stackDir = posix.dirname(clean(stackFile));
133
+ const blocks: TerragruntStackBlock[] = [];
134
+ const head = /\b(unit|stack)\s+"([^"]+)"\s*\{/g;
135
+ for (let m = head.exec(text); m; m = head.exec(text)) {
136
+ const body = blockBody(text, head.lastIndex);
137
+ const source = topLevelString(body, "source");
138
+ const path = topLevelString(body, "path");
139
+ if (path === undefined) continue;
140
+ const noDot = /^\s*no_dot_terragrunt_stack\s*=\s*true\b/m.test(body);
141
+ const base = noDot ? stackDir : posix.join(stackDir, ".terragrunt-stack");
142
+ const generated = clean(posix.join(base, path));
143
+ let template: string | undefined;
144
+ if (source !== undefined) {
145
+ const local = source.replace(/^\$\{get_terragrunt_dir\(\)\}\//, "./");
146
+ if (/^\.{1,2}\//.test(local)) template = clean(toPosix(relative(dir, resolve(dir, stackDir, local.replace("//", "/")))));
147
+ }
148
+ blocks.push({ type: m[1] as "unit" | "stack", name: m[2]!, generated, ...(template !== undefined ? { template } : {}) });
149
+ }
150
+ return blocks;
151
+ }
152
+
153
+ /** The text between the brace before `start` and its matching close, skipping strings and comments. */
154
+ function blockBody(text: string, start: number): string {
155
+ let depth = 1;
156
+ for (let i = start; i < text.length; i++) {
157
+ const c = text[i]!;
158
+ if (c === '"') {
159
+ for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++;
160
+ } else if (c === "#" || (c === "/" && text[i + 1] === "/")) {
161
+ while (i < text.length && text[i] !== "\n") i++;
162
+ } else if (c === "{") depth++;
163
+ else if (c === "}" && --depth === 0) return text.slice(start, i);
164
+ }
165
+ return text.slice(start);
166
+ }
167
+
168
+ /** A string attribute of a block body at nesting depth zero (`path = "web"`, not one inside `values = { ... }`). */
169
+ function topLevelString(body: string, name: string): string | undefined {
170
+ const attr = new RegExp(`^\\s*${name}\\s*=\\s*"([^"]*)"`);
171
+ let depth = 0;
172
+ for (const line of body.split("\n")) {
173
+ if (depth === 0) {
174
+ const m = attr.exec(line);
175
+ if (m) return m[1];
176
+ }
177
+ const code = line.replace(/"(?:[^"\\]|\\.)*"/g, '""');
178
+ depth += (code.match(/[{[]/g) ?? []).length - (code.match(/[}\]]/g) ?? []).length;
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ const SKIP_DIRS = new Set([".git", ".terragrunt-cache", "node_modules", ".terraform"]);
184
+
185
+ /** Every `terragrunt.stack.hcl` under `dir`, generated ones included, relative to `dir`. */
186
+ export function findTerragruntStackFiles(dir: string): string[] {
187
+ const out: string[] = [];
188
+ const walk = (rel: string): void => {
189
+ let entries;
190
+ try {
191
+ entries = readdirSync(join(dir, rel), { withFileTypes: true });
192
+ } catch {
193
+ return;
194
+ }
195
+ for (const e of entries) {
196
+ const p = rel ? `${rel}/${e.name}` : e.name;
197
+ if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(p);
198
+ else if (e.isFile() && e.name === "terragrunt.stack.hcl") out.push(p);
199
+ }
200
+ };
201
+ walk("");
202
+ return out.sort();
203
+ }
204
+
205
+ /** The stack file a generated unit came from: the one beside the `.terragrunt-stack` it sits in. */
206
+ function generatingStackFile(unitPath: string): string | undefined {
207
+ const i = unitPath.lastIndexOf("/.terragrunt-stack/");
208
+ return i < 0 ? undefined : `${unitPath.slice(0, i)}/terragrunt.stack.hcl`;
209
+ }
210
+
211
+ /**
212
+ * The affected units: what Terragrunt selected, each with the changed files
213
+ * that explain it, plus the three supplements. A unit Terragrunt selected
214
+ * that discovery does not list is `removed` when its `terragrunt.hcl` is gone,
215
+ * and otherwise left out with a note (discovery excluded it).
216
+ */
217
+ export function terragruntAffected(input: TerragruntAffectedInput): TerragruntAffected {
218
+ const changed = [...new Set(input.changedFiles.map(clean))].sort();
219
+ const byPath = new Map(input.units.map((u) => [u.path, u]));
220
+ const reasons = new Map<string, TerragruntAffectedReason[]>();
221
+ const notes: string[] = [];
222
+ const add = (path: string, reason: TerragruntAffectedReason): void => {
223
+ const list = reasons.get(path) ?? [];
224
+ list.push(reason);
225
+ reasons.set(path, list);
226
+ };
227
+
228
+ for (const raw of new Set(input.terragruntSelected.map(clean))) {
229
+ const unit = byPath.get(raw);
230
+ if (!unit) {
231
+ if (existsSync(join(input.dir, raw, "terragrunt.hcl"))) {
232
+ notes.push(`${raw}: Terragrunt's git range selected it, but discovery left it out, so it is not affected here`);
233
+ } else {
234
+ add(raw, { kind: "removed", files: changed.filter((f) => under(raw, f)) });
235
+ }
236
+ continue;
237
+ }
238
+ const tracked = new Set([...(unit.reading ?? []), ...Object.values(unit.include ?? {}), generatingStackFile(unit.path)].filter((f): f is string => !!f));
239
+ add(raw, { kind: "terragrunt", files: changed.filter((f) => under(unit.path, f) || tracked.has(f)) });
240
+ }
241
+
242
+ for (const unit of input.units) {
243
+ const reading = new Set(unit.reading ?? []);
244
+ const moduleDirs = terragruntModuleDirs(unit);
245
+ const called = new Map<string, string>();
246
+ for (const m of moduleDirs) {
247
+ for (const d of rootWatchPaths(input.dir, { dir: m })) if (!moduleDirs.includes(d) && !called.has(d)) called.set(d, m);
248
+ }
249
+ const viaFile = new Map<string, string[]>();
250
+ const viaCall = new Map<string, string[]>();
251
+ for (const f of changed) {
252
+ if (reading.has(f)) continue;
253
+ const call = [...called.keys()].filter((d) => under(d, f)).sort((a, b) => b.length - a.length)[0];
254
+ if (call !== undefined) {
255
+ viaCall.set(call, [...(viaCall.get(call) ?? []), f]);
256
+ continue;
257
+ }
258
+ const own = moduleDirs.filter((d) => under(d, f)).sort((a, b) => b.length - a.length)[0];
259
+ if (own !== undefined) viaFile.set(own, [...(viaFile.get(own) ?? []), f]);
260
+ }
261
+ for (const [via, files] of viaFile) add(unit.path, { kind: "module-file", files, via });
262
+ for (const [via, files] of viaCall) add(unit.path, { kind: "module-call", files, via });
263
+ }
264
+
265
+ for (const stackFile of findTerragruntStackFiles(input.dir)) {
266
+ let text: string;
267
+ try {
268
+ text = readFileSync(join(input.dir, stackFile), "utf8");
269
+ } catch {
270
+ continue;
271
+ }
272
+ for (const block of readTerragruntStackFile(input.dir, stackFile, text)) {
273
+ if (block.template === undefined) continue;
274
+ const files = changed.filter((f) => under(block.template!, f));
275
+ if (files.length === 0) continue;
276
+ const targets = block.type === "unit" ? (byPath.has(block.generated) ? [block.generated] : []) : input.units.filter((u) => under(block.generated, u.path)).map((u) => u.path);
277
+ if (targets.length === 0) {
278
+ notes.push(
279
+ `${block.generated}: generated by ${block.type} "${block.name}" in ${stackFile} from the changed template ${block.template}, ` +
280
+ "but discovery did not list it; run `terragrunt stack generate` before discovery",
281
+ );
282
+ }
283
+ for (const t of targets) add(t, { kind: "stack-template", files, via: block.template });
284
+ }
285
+ }
286
+
287
+ const units = [...reasons.entries()]
288
+ .map(([path, list]) => ({
289
+ path,
290
+ reasons: list.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || (a.via ?? "").localeCompare(b.via ?? "")),
291
+ }))
292
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
293
+ return { units, notes: [...new Set(notes)].sort() };
294
+ }
295
+
296
+ /** One line per reason, for a report or a job log. */
297
+ export function describeTerragruntAffectedReason(reason: TerragruntAffectedReason): string {
298
+ const files = reason.files.join(", ");
299
+ switch (reason.kind) {
300
+ case "terragrunt":
301
+ return files ? `Terragrunt's git range selected it (${files})` : "Terragrunt's git range selected it";
302
+ case "removed":
303
+ return "removed in the range; Terragrunt plans its destroy";
304
+ case "module-file":
305
+ return `${files} changed under its module ${reason.via}, which Terragrunt does not track`;
306
+ case "module-call":
307
+ return `${files} changed under ${reason.via}, a module its module calls`;
308
+ case "stack-template":
309
+ return `${files} changed under ${reason.via}, the stack template it is generated from`;
310
+ }
311
+ }
312
+
313
+ export interface FindTerragruntAffectedOptions extends TerragruntRunOptions, TerragruntFindOptions {
314
+ /** The range's base commit or ref (`origin/main`). */
315
+ base: string;
316
+ /** The range's head. Default `HEAD`. */
317
+ head?: string;
318
+ /** The project's units at head; discovered when unset. Stacks must be generated before discovery. */
319
+ units?: readonly TerragruntUnit[];
320
+ /** The `git` executable. Default `git` on PATH. */
321
+ git?: string;
322
+ }
323
+
324
+ /** Run git and Terragrunt's git filter in `dir` and return the affected units with their reasons. */
325
+ export async function findTerragruntAffected(options: FindTerragruntAffectedOptions): Promise<TerragruntAffected> {
326
+ const exec = options.exec ?? defaultTerragruntExec;
327
+ const tg = options.terragrunt ?? "terragrunt";
328
+ const git = options.git ?? "git";
329
+ const diffArgs = terragruntAffectedDiffArgs(options);
330
+ const diff = await exec(git, diffArgs, { cwd: options.dir, env: {} });
331
+ if (diff.code !== 0) throw new TerragruntError(`\`${git} ${diffArgs.join(" ")}\` failed (exit ${diff.code}): ${diff.stderr.trim()}`);
332
+ const findArgs = terragruntAffectedFindArgs(options);
333
+ const found = await exec(tg, findArgs, { cwd: options.dir, env: terragruntEnv(options.binary) });
334
+ if (found.code !== 0) throw new TerragruntError(`\`${tg} ${findArgs.join(" ")}\` failed (exit ${found.code}): ${found.stderr.trim()}`);
335
+ let selected: string[];
336
+ try {
337
+ selected = parseTerragruntFind(found.stdout).map((u) => u.path);
338
+ } catch (err) {
339
+ throw new TerragruntError(`could not read \`terragrunt find\` output: ${err instanceof Error ? err.message : String(err)}`);
340
+ }
341
+ const units = options.units ?? (await discoverTerragruntUnits(options)).units;
342
+ return terragruntAffected({
343
+ dir: options.dir,
344
+ units,
345
+ terragruntSelected: selected,
346
+ changedFiles: diff.stdout.split("\n").map((l) => l.trim()).filter((l) => l !== ""),
347
+ });
348
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Terragrunt units as roots (#3414). `./units`, `./wave` and `./mocks` are pure and
3
+ * bundle without TypeScript; `./run` spawns `terragrunt`. `./affected` picks
4
+ * the units a git range touches (#3415).
5
+ */
6
+
7
+ export * from "./units";
8
+ export * from "./wave";
9
+ export * from "./mocks";
10
+ export * from "./run";
11
+ export * from "./affected";
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Mock outputs in Terragrunt waves (#3416), against a real `terragrunt` 1.1+
3
+ * and `tofu`, on the five-unit fixture: every unit is new, so wave 1 holds
4
+ * new upstreams and wave 2 their new dependents. Wave 2 is refused until
5
+ * wave 1 applied, and its plan then holds the real upstream values. A
6
+ * provisional plan of wave 2 made before that reads the mocks, is marked,
7
+ * and never applies.
8
+ *
9
+ * Skipped, with the reason in the title, when `terragrunt` or `tofu` is not
10
+ * on PATH. Release binaries: https://github.com/gruntwork-io/terragrunt/releases.
11
+ */
12
+
13
+ import { execSync } from "node:child_process";
14
+ import { cpSync, existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { afterAll, describe, expect, it } from "vitest";
19
+ import { composeChangeSet } from "@intentius/chant/change-set";
20
+ import { groupChangeSet } from "@intentius/chant/plan-summary";
21
+ import { applyTerragruntWave, discoverTerragruntUnits, planTerragruntWave, TerragruntMockRefusal, terragruntMockWarnings, terragruntWaves } from "./index";
22
+
23
+ function onPath(cmd: string): boolean {
24
+ try {
25
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ const skipReason = !onPath("terragrunt") ? "no terragrunt on PATH" : !onPath("tofu") ? "no tofu on PATH" : "";
33
+ const fixture = join(dirname(fileURLToPath(import.meta.url)), "../__fixtures__/terragrunt/five-units");
34
+ const tmp = mkdtempSync(join(tmpdir(), "chant-tg-mocks-accept-"));
35
+ afterAll(() => rmSync(tmp, { recursive: true, force: true }));
36
+
37
+ /** The `upstream` input each unit's `terraform_data` plans, from the wave's change set. */
38
+ function upstreamInputs(parts: Parameters<typeof composeChangeSet>[0]): Record<string, unknown> {
39
+ const doc = composeChangeSet(parts);
40
+ return Object.fromEntries(
41
+ doc.entries.map((e) => [e.member, (e.attributes.find((a) => a.path === "input")?.after as { upstream?: unknown } | undefined)?.upstream]),
42
+ );
43
+ }
44
+
45
+ describe.skipIf(skipReason !== "")(`mock outputs in Terragrunt waves against a real terragrunt${skipReason ? ` (skipped: ${skipReason})` : ""}`, () => {
46
+ const dir = join(tmp, "repo");
47
+ cpSync(fixture, dir, { recursive: true });
48
+
49
+ it(
50
+ "refuses wave 2 until wave 1 applied, then plans it on the real upstream values",
51
+ async () => {
52
+ const { units } = await discoverTerragruntUnits({ dir, binary: "tofu" });
53
+ const [wave1, wave2] = terragruntWaves(units);
54
+ expect(wave1).toEqual(["live/dev/vpc", "live/prod/vpc"]);
55
+ expect(wave2).toEqual(["live/dev/db", "live/prod/app"]);
56
+
57
+ // Before wave 1 applied: refused, naming each upstream, and nothing planned.
58
+ const refused = await planTerragruntWave({ dir, units: wave2!, workDir: join(tmp, "w2"), binary: "tofu" }).catch((e: unknown) => e);
59
+ expect(refused).toBeInstanceOf(TerragruntMockRefusal);
60
+ expect((refused as TerragruntMockRefusal).reads.map((r) => [r.unit, r.upstream, r.reason])).toEqual([
61
+ ["live/dev/db", "live/dev/vpc", "no-outputs"],
62
+ ["live/prod/app", "live/prod/vpc", "no-outputs"],
63
+ ]);
64
+ expect((refused as Error).message).toMatch(/Apply live\/dev\/vpc, live\/prod\/vpc first/);
65
+ expect(existsSync(join(tmp, "w2", "plans"))).toBe(false);
66
+
67
+ // The PR-time preview of the same wave reads the mocks, says so, and never applies.
68
+ const preview = await planTerragruntWave({ dir, units: wave2!, workDir: join(tmp, "w2-preview"), binary: "tofu", provisional: true });
69
+ expect(preview.code, preview.log).toBe(0);
70
+ expect(preview.parts.map((p) => [p.member.member, p.member.status, p.member.provisional])).toEqual([
71
+ ["live/dev/db", "planned", true],
72
+ ["live/prod/app", "planned", true],
73
+ ]);
74
+ expect(upstreamInputs(preview.parts)).toEqual({ "live/dev/db": ["mock"], "live/prod/app": ["mock"] });
75
+ // Terragrunt's own warning, which the after-the-fact check reads, names the same units.
76
+ expect(terragruntMockWarnings(preview.log, [dir, realpathSync(dir)])).toEqual([
77
+ { unit: "live/dev/db", upstream: "live/dev/vpc" },
78
+ { unit: "live/prod/app", upstream: "live/prod/vpc" },
79
+ ]);
80
+ expect(groupChangeSet(composeChangeSet(preview.parts)).groups.every((g) => g.provisional === true)).toBe(true);
81
+ await expect(applyTerragruntWave({ dir, units: wave2!, workDir: join(tmp, "w2-preview"), binary: "tofu" })).rejects.toThrow(/provisional/);
82
+ expect(existsSync(join(dir, ".state/live/dev/db/terraform.tfstate"))).toBe(false);
83
+
84
+ // Wave 1 plans (its units read nothing) and applies.
85
+ const plan1 = await planTerragruntWave({ dir, units: wave1!, workDir: join(tmp, "w1"), binary: "tofu" });
86
+ expect(plan1.code, plan1.log).toBe(0);
87
+ const apply1 = await applyTerragruntWave({ dir, units: wave1!, workDir: join(tmp, "w1"), binary: "tofu" });
88
+ expect(apply1.code, apply1.log).toBe(0);
89
+
90
+ // Now wave 2 plans, and its plan holds the real upstream values.
91
+ const plan2 = await planTerragruntWave({ dir, units: wave2!, workDir: join(tmp, "w2"), binary: "tofu" });
92
+ expect(plan2.code, plan2.log).toBe(0);
93
+ expect(plan2.provisional).toBe(false);
94
+ expect(plan2.parts.map((p) => [p.member.member, p.member.status, p.member.provisional])).toEqual([
95
+ ["live/dev/db", "planned", undefined],
96
+ ["live/prod/app", "planned", undefined],
97
+ ]);
98
+ expect(upstreamInputs(plan2.parts)).toEqual({ "live/dev/db": ["live-dev-vpc-id"], "live/prod/app": ["live-prod-vpc-id"] });
99
+ expect(plan2.log).not.toMatch(/mock outputs provided/);
100
+ const apply2 = await applyTerragruntWave({ dir, units: wave2!, workDir: join(tmp, "w2"), binary: "tofu" });
101
+ expect(apply2.code, apply2.log).toBe(0);
102
+ },
103
+ 180_000,
104
+ );
105
+ });