@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,332 @@
1
+ /**
2
+ * Terragrunt units as roots (#3414), against output recorded from Terragrunt
3
+ * 1.1.6 and OpenTofu 1.12.5 on the five-unit fixture
4
+ * (`../__fixtures__/terragrunt/`, re-recorded by its `record.ts`). Nothing
5
+ * here runs Terragrunt; `terragrunt.acceptance.test.ts` does.
6
+ */
7
+
8
+ import { mkdirSync, mkdtempSync, readFileSync, 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 { composeChangeSet } from "@intentius/chant/change-set";
14
+ import {
15
+ applyTerragruntWave,
16
+ discoverTerragruntUnits,
17
+ matchesUnitGlob,
18
+ parseTerragruntFind,
19
+ parseTerragruntReport,
20
+ planTerragruntWave,
21
+ stackOfUnit,
22
+ terraformBinaryWarnings,
23
+ terragruntDependents,
24
+ terragruntEnv,
25
+ terragruntFindArgs,
26
+ terragruntUnitResults,
27
+ terragruntVersionProblem,
28
+ terragruntWaveArgs,
29
+ TerragruntWaveError,
30
+ terragruntWaveParts,
31
+ terragruntWaves,
32
+ type TerragruntExec,
33
+ } from "./index";
34
+
35
+ const recorded = join(dirname(fileURLToPath(import.meta.url)), "../__fixtures__/terragrunt/recorded");
36
+ const read = (p: string): string => readFileSync(join(recorded, p), "utf8");
37
+ const units = parseTerragruntFind(read("find.json"));
38
+
39
+ const tmps: string[] = [];
40
+ afterAll(() => {
41
+ for (const t of tmps) rmSync(t, { recursive: true, force: true });
42
+ });
43
+ const tmp = (): string => {
44
+ const t = mkdtempSync(join(tmpdir(), "chant-tg-test-"));
45
+ tmps.push(t);
46
+ return t;
47
+ };
48
+
49
+ describe("Terragrunt version floor", () => {
50
+ it("runs 1.1 and later, release candidates included", () => {
51
+ expect(terragruntVersionProblem(read("version.txt"))).toBeUndefined();
52
+ expect(terragruntVersionProblem("terragrunt version v1.2.0-rc1")).toBeUndefined();
53
+ expect(terragruntVersionProblem("terragrunt version v2.0.0")).toBeUndefined();
54
+ });
55
+ it("refuses 1.0 and the 0.x line, and output with no version", () => {
56
+ expect(terragruntVersionProblem("terragrunt version v1.0.3")).toMatch(/older than 1\.1\.0/);
57
+ expect(terragruntVersionProblem("terragrunt version v0.93.4")).toMatch(/older than 1\.1\.0/);
58
+ expect(terragruntVersionProblem("command not found")).toMatch(/could not read a version/);
59
+ });
60
+ });
61
+
62
+ describe("discovery from terragrunt find", () => {
63
+ it("asks for the DAG with dependencies, excludes catalog templates and the cache, and leaves .terragrunt-filters on", () => {
64
+ const args = terragruntFindArgs({ exclude: ["sandbox/**"] });
65
+ expect(args.slice(0, 4)).toEqual(["find", "--json", "--dag", "--dependencies"]);
66
+ expect(args).toContain("!./catalog/**");
67
+ expect(args).toContain("!./**/.terragrunt-cache/**");
68
+ expect(args).toContain("!./sandbox/**");
69
+ expect(args).not.toContain("--no-filters-file");
70
+ });
71
+
72
+ it("reads the five units with their edges, and not the catalog template", () => {
73
+ expect(units.map((u) => u.path)).toEqual(["live/dev/vpc", "live/prod/vpc", "live/dev/db", "live/prod/app", "live/dev/app"]);
74
+ expect(Object.fromEntries(units.map((u) => [u.path, u.dependencies]))).toEqual({
75
+ "live/dev/vpc": [],
76
+ "live/prod/vpc": [],
77
+ "live/dev/db": ["live/dev/vpc"],
78
+ "live/prod/app": ["live/prod/vpc"],
79
+ "live/dev/app": ["live/dev/db", "live/dev/vpc"],
80
+ });
81
+ expect(units[0]!.include).toEqual({ root: "root.hcl" });
82
+ expect(units[0]!.reading).toEqual(["modules/thing/main.tf", "root.hcl"]);
83
+ });
84
+
85
+ it("drops stacks and malformed rows, and refuses output that is not an array", () => {
86
+ expect(parseTerragruntFind([{ type: "stack", path: "live" }, { type: "unit" }, { type: "unit", path: "./a/" }])).toEqual([
87
+ { path: "a", dependencies: [] },
88
+ ]);
89
+ expect(parseTerragruntFind("")).toEqual([]);
90
+ expect(() => parseTerragruntFind("{}")).toThrow(/did not print an array/);
91
+ });
92
+
93
+ it("runs find in the project directory and passes TG_TF_PATH only when a binary is named", async () => {
94
+ const calls: Array<{ args: readonly string[]; env: Record<string, string> }> = [];
95
+ const exec: TerragruntExec = async (_f, args, o) => {
96
+ calls.push({ args, env: o.env });
97
+ return { code: 0, stdout: read("find.json"), stderr: "" };
98
+ };
99
+ const dir = tmp();
100
+ const found = await discoverTerragruntUnits({ dir, exec });
101
+ expect(found.units).toHaveLength(5);
102
+ expect(calls[0]!.env).toEqual({});
103
+ await discoverTerragruntUnits({ dir, exec, binary: "tofu" });
104
+ expect(calls[1]!.env).toEqual({ TG_TF_PATH: "tofu" });
105
+ });
106
+ });
107
+
108
+ describe("terraform_binary beside a named binary", () => {
109
+ it("warns for a unit, or a file it includes, that names another binary, and not for one naming the same", () => {
110
+ const dir = tmp();
111
+ const write = (p: string, text: string): void => {
112
+ mkdirSync(dirname(join(dir, p)), { recursive: true });
113
+ writeFileSync(join(dir, p), text);
114
+ };
115
+ write("a/terragrunt.hcl", 'terraform_binary = "terraform"\n');
116
+ write("b/terragrunt.hcl", 'include "root" { path = find_in_parent_folders("root.hcl") }\n');
117
+ write("root.hcl", 'terraform_binary = get_env("TF", "terraform")\n');
118
+ write("c/terragrunt.hcl", 'terraform_binary = "tofu"\n');
119
+ write("d/terragrunt.hcl", "inputs = {}\n");
120
+ const warnings = terraformBinaryWarnings(
121
+ dir,
122
+ [
123
+ { path: "a", dependencies: [] },
124
+ { path: "b", dependencies: [], include: { root: "root.hcl" } },
125
+ { path: "c", dependencies: [] },
126
+ { path: "d", dependencies: [] },
127
+ ],
128
+ "tofu",
129
+ );
130
+ expect(warnings).toHaveLength(2);
131
+ expect(warnings[0]).toMatch(/^a: a\/terragrunt\.hcl sets terraform_binary = "terraform".*TG_TF_PATH=tofu overrides it/);
132
+ expect(warnings[1]).toMatch(/^b: root\.hcl sets terraform_binary = get_env/);
133
+ });
134
+ });
135
+
136
+ describe("the unit graph as an order source", () => {
137
+ it("layers the units into waves, roots first", () => {
138
+ expect(terragruntWaves(units)).toEqual([["live/dev/vpc", "live/prod/vpc"], ["live/dev/db", "live/prod/app"], ["live/dev/app"]]);
139
+ });
140
+
141
+ it("puts canary globs first, layered among themselves", () => {
142
+ expect(terragruntWaves(units, { canary: ["live/dev/**"] })).toEqual([
143
+ ["live/dev/vpc"],
144
+ ["live/dev/db"],
145
+ ["live/dev/app"],
146
+ ["live/prod/vpc"],
147
+ ["live/prod/app"],
148
+ ]);
149
+ });
150
+
151
+ it("refuses a canary that reads a unit that is not a canary", () => {
152
+ expect(() => terragruntWaves(units, { canary: ["live/dev/app"] })).toThrow(TerragruntWaveError);
153
+ expect(() => terragruntWaves(units, { canary: ["live/dev/app"] })).toThrow(/depends on live\/dev\/db, live\/dev\/vpc/);
154
+ });
155
+
156
+ it("orders a subset without waiting on units outside it", () => {
157
+ const subset = units.filter((u) => ["live/dev/db", "live/dev/app"].includes(u.path));
158
+ expect(terragruntWaves(subset)).toEqual([["live/dev/db"], ["live/dev/app"]]);
159
+ });
160
+
161
+ it("refuses a cycle", () => {
162
+ expect(() =>
163
+ terragruntWaves([
164
+ { path: "a", dependencies: ["b"] },
165
+ { path: "b", dependencies: ["a"] },
166
+ ]),
167
+ ).toThrow(/cycle among: a, b/);
168
+ });
169
+
170
+ it("finds dependents through the graph, and through the edges a caller chooses", () => {
171
+ expect(terragruntDependents(units, ["live/dev/vpc"])).toEqual(["live/dev/app", "live/dev/db"]);
172
+ expect(terragruntDependents(units, ["live/prod/app"])).toEqual([]);
173
+ const dependencyOnly = (u: { path: string; dependencies: string[] }): string[] => (u.path === "live/dev/app" ? ["live/dev/db"] : u.dependencies);
174
+ expect(terragruntDependents(units, ["live/dev/vpc"], dependencyOnly)).toEqual(["live/dev/app", "live/dev/db"]);
175
+ expect(terragruntDependents(units, ["live/dev/db"], () => [])).toEqual([]);
176
+ });
177
+
178
+ it("matches unit globs by segment", () => {
179
+ expect(matchesUnitGlob("live/dev/vpc", "live/dev/**")).toBe(true);
180
+ expect(matchesUnitGlob("live/dev/vpc", "live/*/vpc")).toBe(true);
181
+ expect(matchesUnitGlob("live/dev/vpc", "live/*")).toBe(false);
182
+ expect(matchesUnitGlob("live/dev/vpc", "**/vpc")).toBe(true);
183
+ expect(matchesUnitGlob("live/dev/vpc", "./live/dev/vpc")).toBe(true);
184
+ expect(stackOfUnit("live/dev/vpc")).toBe("live/dev");
185
+ expect(stackOfUnit("vpc")).toBe(".");
186
+ });
187
+ });
188
+
189
+ describe("one run --all per wave", () => {
190
+ it("plans exactly the wave's units by path, with the filters file off and every output named", () => {
191
+ const args = terragruntWaveArgs({
192
+ units: ["live/prod/vpc", "live/dev/vpc"],
193
+ command: "plan",
194
+ outDir: "/w/plans",
195
+ jsonOutDir: "/w/json",
196
+ reportFile: "/w/report.json",
197
+ parallelism: 16,
198
+ });
199
+ expect(args).toEqual([
200
+ "run", "--all", "--non-interactive", "--no-color", "--no-filters-file",
201
+ "--filter", "{./live/dev/vpc}", "--filter", "{./live/prod/vpc}",
202
+ "--out-dir", "/w/plans", "--json-out-dir", "/w/json",
203
+ "--report-file", "/w/report.json", "--report-format", "json",
204
+ "--parallelism", "16",
205
+ "--", "plan",
206
+ ]);
207
+ });
208
+
209
+ it("applies with the same filters from the saved plans, and a destroy wave allows destroys on both", () => {
210
+ const apply = terragruntWaveArgs({ units: ["live/dev/vpc"], command: "apply", outDir: "/w/plans", jsonOutDir: "/w/json", reportFile: "/w/a.json", destroy: true });
211
+ expect(apply).toContain("--filter-allow-destroy");
212
+ expect(apply).not.toContain("--json-out-dir");
213
+ expect(apply.slice(apply.indexOf("--"))).toEqual(["--", "apply"]);
214
+ const plan = terragruntWaveArgs({ units: ["live/dev/vpc"], command: "plan", outDir: "/w/plans", reportFile: "/w/p.json", destroy: true });
215
+ expect(plan).toContain("--filter-allow-destroy");
216
+ expect(plan.slice(plan.indexOf("--"))).toEqual(["--", "plan", "-destroy"]);
217
+ });
218
+
219
+ it("refuses an empty wave, which with no filter would run every unit", () => {
220
+ expect(() => terragruntWaveArgs({ units: [], command: "plan", outDir: "o", reportFile: "r" })).toThrow(/at least one unit/);
221
+ expect(() => terragruntWaveArgs({ units: ["a"], command: "plan", outDir: "o", reportFile: "r", parallelism: 0 })).toThrow(/parallelism/);
222
+ });
223
+
224
+ it("sets TG_TF_PATH only when the project names a binary", () => {
225
+ expect(terragruntEnv(undefined)).toEqual({});
226
+ expect(terragruntEnv("choudoufu")).toEqual({ TG_TF_PATH: "choudoufu" });
227
+ });
228
+ });
229
+
230
+ describe("the run report reaches the change set", () => {
231
+ it("makes each planned unit a member named by its path, scoped to its stack", () => {
232
+ const wave = ["live/dev/db", "live/prod/app"];
233
+ const parts = terragruntWaveParts({
234
+ units: wave,
235
+ report: parseTerragruntReport(read("wave-2/plan-report.json")),
236
+ planFor: (u) => JSON.parse(read(`wave-2/json/${u}/tfplan.json`)),
237
+ planner: "tofu",
238
+ });
239
+ const doc = composeChangeSet(parts);
240
+ expect(doc.members.map((m) => [m.member, m.status, m.scope, m.planner])).toEqual([
241
+ ["live/dev/db", "planned", "live/dev", "tofu"],
242
+ ["live/prod/app", "planned", "live/prod", "tofu"],
243
+ ]);
244
+ expect(doc.members.every((m) => typeof m.planDigest === "string")).toBe(true);
245
+ const db = doc.entries.find((e) => e.member === "live/dev/db")!;
246
+ expect(db).toMatchObject({ address: "terraform_data.this", action: "create" });
247
+ // Planned after wave 1 applied: the upstream is the real output, not the mock.
248
+ expect(db.attributes.find((a) => a.path === "input")?.after).toEqual({ name: "live-dev-db", upstream: ["live-dev-vpc-id"] });
249
+ });
250
+
251
+ it("makes a failed unit, one that exited early and one the report leaves out failed members", () => {
252
+ const report = parseTerragruntReport(read("failed-plan-report.json"));
253
+ const wave = ["live/dev/db", "live/dev/app", "live/prod/app", "live/prod/vpc"];
254
+ const results = terragruntUnitResults(wave, report);
255
+ expect(results.map((r) => [r.unit, r.status, r.result])).toEqual([
256
+ ["live/dev/app", "failed", "early exit"],
257
+ ["live/dev/db", "failed", "failed"],
258
+ ["live/prod/app", "succeeded", "succeeded"],
259
+ ["live/prod/vpc", "failed", "not run"],
260
+ ]);
261
+ expect(results[0]!.error).toBe("early exit (ancestor error: db)");
262
+ expect(results[1]!.error).toMatch(/^failed \(run error: error occurred:/);
263
+
264
+ const prodApp = JSON.parse(read("wave-2/json/live/prod/app/tfplan.json"));
265
+ const doc = composeChangeSet(
266
+ terragruntWaveParts({ units: wave, report, planFor: (u) => (u === "live/prod/app" ? prodApp : undefined), planner: "tofu" }),
267
+ );
268
+ expect(doc.summary.failed).toEqual(["live/dev/app", "live/dev/db", "live/prod/vpc"]);
269
+ expect(doc.members.find((m) => m.member === "live/prod/vpc")).toMatchObject({ status: "failed", planDigest: null, error: expect.stringMatching(/run report/) });
270
+ });
271
+
272
+ it("does not take a succeeded unit with no plan JSON as planned", () => {
273
+ const report = parseTerragruntReport(read("wave-1/plan-report.json"));
274
+ const [part] = terragruntWaveParts({ units: ["live/dev/vpc"], report, planFor: () => undefined, planner: "tofu" });
275
+ expect(part!.member).toMatchObject({ status: "failed", planDigest: null, error: expect.stringMatching(/no plan JSON/) });
276
+ });
277
+ });
278
+
279
+ describe("a wave's plan and apply, with Terragrunt stubbed", () => {
280
+ /** A stub that writes what Terragrunt wrote for wave 1 into the directories the args name. */
281
+ const stub = (seen: string[][]): TerragruntExec => async (_f, args) => {
282
+ // The wave's units read no dependency, so the mock check renders nothing to follow.
283
+ if (args[0] === "render") return { code: 0, stdout: '{"dependency":null}', stderr: "" };
284
+ seen.push([...args]);
285
+ const at = (flag: string): string | undefined => (args.includes(flag) ? args[args.indexOf(flag) + 1] : undefined);
286
+ const report = at("--report-file")!;
287
+ mkdirSync(dirname(report), { recursive: true });
288
+ const cmd = args[args.indexOf("--") + 1];
289
+ writeFileSync(report, read(cmd === "plan" ? "wave-1/plan-report.json" : "wave-1/apply-report.json"));
290
+ if (cmd === "plan") {
291
+ for (const u of ["live/dev/vpc", "live/prod/vpc"]) {
292
+ for (const [dir, file, text] of [
293
+ [at("--json-out-dir")!, "tfplan.json", read(`wave-1/json/${u}/tfplan.json`)],
294
+ [at("--out-dir")!, "tfplan.tfplan", "binary plan"],
295
+ ] as const) {
296
+ mkdirSync(join(dir, u), { recursive: true });
297
+ writeFileSync(join(dir, u, file), text);
298
+ }
299
+ }
300
+ }
301
+ return { code: 0, stdout: "", stderr: "" };
302
+ };
303
+
304
+ it("plans into the work directory, clears what an earlier attempt left, and applies the saved plans", async () => {
305
+ const dir = tmp();
306
+ const seen: string[][] = [];
307
+ const workDir = ".chant/terragrunt/wave-1";
308
+ // A plan JSON from an earlier attempt for a unit that will not plan this time.
309
+ mkdirSync(join(dir, workDir, "json/live/dev/app"), { recursive: true });
310
+ writeFileSync(join(dir, workDir, "json/live/dev/app/tfplan.json"), "{}");
311
+ const plan = await planTerragruntWave({ dir, units: ["live/dev/vpc", "live/prod/vpc"], workDir, exec: stub(seen) });
312
+ expect(plan.parts.map((p) => [p.member.member, p.member.status])).toEqual([
313
+ ["live/dev/vpc", "planned"],
314
+ ["live/prod/vpc", "planned"],
315
+ ]);
316
+ expect(plan.parts[0]!.member.planner).toBe("tofu");
317
+ expect(() => readFileSync(join(dir, workDir, "json/live/dev/app/tfplan.json"))).toThrow();
318
+
319
+ const applied = await applyTerragruntWave({ dir, units: ["live/dev/vpc", "live/prod/vpc"], workDir, exec: stub(seen) });
320
+ expect(applied.results.every((r) => r.status === "succeeded")).toBe(true);
321
+ const filters = (args: string[]): string[] => args.filter((_, i) => args[i - 1] === "--filter");
322
+ expect(filters(seen[1]!)).toEqual(filters(seen[0]!));
323
+ expect(seen[1]![seen[1]!.indexOf("--out-dir") + 1]).toBe(seen[0]![seen[0]!.indexOf("--out-dir") + 1]);
324
+ });
325
+
326
+ it("refuses to apply a unit with no saved plan, before running anything", async () => {
327
+ const dir = tmp();
328
+ const seen: string[][] = [];
329
+ await expect(applyTerragruntWave({ dir, units: ["live/dev/vpc"], workDir: "w", exec: stub(seen) })).rejects.toThrow(/no saved plan for live\/dev\/vpc/);
330
+ expect(seen).toEqual([]);
331
+ });
332
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Terragrunt units as roots (#3414): what a unit is, how discovery finds the
3
+ * units, and how the unit graph orders them into waves.
4
+ *
5
+ * A Terragrunt unit is a root: its own state, plan file and plan digest. The
6
+ * units and their edges come from `terragrunt find --json --dag
7
+ * --dependencies`, run by Terragrunt itself, so chant never evaluates
8
+ * Terragrunt's HCL to learn the graph. Directories that group units (implicit
9
+ * stacks) are labels: a unit's stack is its parent directory.
10
+ *
11
+ * This module runs nothing and imports only chant's `gated-waves` subpath, so
12
+ * terragucci can bundle it without TypeScript (#3421). `./run.ts` spawns.
13
+ */
14
+
15
+ import { layerWaves } from "@intentius/chant/gated-waves";
16
+ import type { WaveNode } from "@intentius/chant/gated-waves";
17
+
18
+ /** The oldest Terragrunt this runner drives: `run --all`, `find --dag` and `--filter` as 1.1 ships them. */
19
+ export const TERRAGRUNT_MIN_VERSION = "1.1.0";
20
+
21
+ /**
22
+ * Filters every discovery adds: catalog unit templates, which parse only
23
+ * inside a stack, and Terragrunt's module cache. A project's own `exclude`
24
+ * globs are added after these.
25
+ */
26
+ export const TERRAGRUNT_DISCOVERY_EXCLUDES: readonly string[] = ["catalog/**", "**/.terragrunt-cache/**"];
27
+
28
+ /** One unit as `find --json --dag --dependencies --include --reading` reports it. */
29
+ export interface TerragruntUnit {
30
+ /** The unit's directory, relative to where discovery ran, with `/` separators. Also its change-set member name. */
31
+ path: string;
32
+ /** Units this one must follow, from `dependency` and `dependencies` blocks alike. */
33
+ dependencies: string[];
34
+ /** Included files by include name, relative to where discovery ran. */
35
+ include?: Record<string, string>;
36
+ /** Files Terragrunt reports the unit reading. */
37
+ reading?: string[];
38
+ }
39
+
40
+ /** A Terragrunt version string, parsed. */
41
+ export interface TerragruntVersion {
42
+ major: number;
43
+ minor: number;
44
+ patch: number;
45
+ /** `rc1` in `1.2.0-rc1`. */
46
+ pre?: string;
47
+ raw: string;
48
+ }
49
+
50
+ /** Read `terragrunt --version` output (`terragrunt version v1.1.6`). Undefined when no version is in it. */
51
+ export function parseTerragruntVersion(output: string): TerragruntVersion | undefined {
52
+ const m = /v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(output);
53
+ if (!m) return undefined;
54
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), ...(m[4] ? { pre: m[4] } : {}), raw: m[0] };
55
+ }
56
+
57
+ /**
58
+ * Why this Terragrunt cannot run units, or undefined when it can. A release
59
+ * candidate of 1.1.0 or later counts as that release line.
60
+ */
61
+ export function terragruntVersionProblem(output: string): string | undefined {
62
+ const v = parseTerragruntVersion(output);
63
+ if (!v) return `could not read a version from \`terragrunt --version\` (got ${JSON.stringify(output.trim().slice(0, 80))})`;
64
+ const [minMajor, minMinor] = TERRAGRUNT_MIN_VERSION.split(".").map(Number) as [number, number];
65
+ if (v.major > minMajor || (v.major === minMajor && v.minor >= minMinor)) return undefined;
66
+ return `Terragrunt ${v.raw} is older than ${TERRAGRUNT_MIN_VERSION}, the oldest release chant runs units with (run --all, find --dag and --filter as 1.1 ships them)`;
67
+ }
68
+
69
+ /** A path filter that selects exactly one unit. Braced so a path with glob characters stays a path. */
70
+ export function unitPathFilter(path: string): string {
71
+ return `{./${path}}`;
72
+ }
73
+
74
+ /** A filter that drops everything under `glob`, relative to where discovery runs. */
75
+ export function excludeFilter(glob: string): string {
76
+ return `!./${glob.replace(/^\.\//, "")}`;
77
+ }
78
+
79
+ export interface TerragruntFindOptions {
80
+ /** Globs to leave out beside {@link TERRAGRUNT_DISCOVERY_EXCLUDES}, relative to the project directory. */
81
+ exclude?: readonly string[];
82
+ }
83
+
84
+ /**
85
+ * Arguments to `terragrunt` for discovery. `.terragrunt-filters` is read, as
86
+ * Terragrunt reads it by default; the excludes narrow what it selects.
87
+ */
88
+ export function terragruntFindArgs(options: TerragruntFindOptions = {}): string[] {
89
+ const excludes = [...TERRAGRUNT_DISCOVERY_EXCLUDES, ...(options.exclude ?? [])];
90
+ return [
91
+ "find",
92
+ "--json",
93
+ "--dag",
94
+ "--dependencies",
95
+ "--include",
96
+ "--reading",
97
+ "--no-color",
98
+ ...excludes.flatMap((g) => ["--filter", excludeFilter(g)]),
99
+ ];
100
+ }
101
+
102
+ const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
103
+ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
104
+ const clean = (p: string): string => p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
105
+
106
+ /**
107
+ * The units in `find --json` output, in the order Terragrunt printed them
108
+ * (its DAG order under `--dag`). Stacks and anything else that is not a unit
109
+ * are left out. Throws when the output is not a JSON array.
110
+ */
111
+ export function parseTerragruntFind(output: string | unknown): TerragruntUnit[] {
112
+ const doc = typeof output === "string" ? (JSON.parse(output.trim() === "" ? "[]" : output) as unknown) : output;
113
+ if (!Array.isArray(doc)) throw new Error("terragrunt find --json did not print an array");
114
+ const units: TerragruntUnit[] = [];
115
+ for (const raw of doc) {
116
+ if (!isObject(raw) || raw.type !== "unit" || typeof raw.path !== "string") continue;
117
+ const include = isObject(raw.include)
118
+ ? Object.fromEntries(Object.entries(raw.include).filter((e): e is [string, string] => typeof e[1] === "string").map(([k, v]) => [k, clean(v)]))
119
+ : undefined;
120
+ const reading = Array.isArray(raw.reading) ? strings(raw.reading).map(clean) : undefined;
121
+ units.push({
122
+ path: clean(raw.path),
123
+ dependencies: [...new Set(strings(raw.dependencies).map(clean))].sort(),
124
+ ...(include && Object.keys(include).length > 0 ? { include } : {}),
125
+ ...(reading ? { reading } : {}),
126
+ });
127
+ }
128
+ return units;
129
+ }
130
+
131
+ /** A unit's implicit stack: its parent directory, or `.` for a unit at the top. */
132
+ export function stackOfUnit(path: string): string {
133
+ const i = path.lastIndexOf("/");
134
+ return i <= 0 ? "." : path.slice(0, i);
135
+ }
136
+
137
+ /** Whether `path` matches `glob` (`*` within a segment, `**` across segments, `?` one character). */
138
+ export function matchesUnitGlob(path: string, glob: string): boolean {
139
+ const g = clean(glob);
140
+ let re = "";
141
+ for (let i = 0; i < g.length; i++) {
142
+ const c = g[i]!;
143
+ if (c === "*" && g[i + 1] === "*") {
144
+ // `a/**/b` and `a/**` also match zero directories.
145
+ if (g[i + 2] === "/") {
146
+ re += "(?:.*/)?";
147
+ i += 2;
148
+ } else {
149
+ re += ".*";
150
+ i += 1;
151
+ }
152
+ } else if (c === "*") re += "[^/]*";
153
+ else if (c === "?") re += "[^/]";
154
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
155
+ }
156
+ return new RegExp(`^${re}$`).test(path);
157
+ }
158
+
159
+ /** The units, as the nodes gated waves (#3049) and a fan-out (#2417) layer. */
160
+ export function terragruntWaveNodes(units: readonly TerragruntUnit[]): WaveNode[] {
161
+ return units.map((u) => ({ name: u.path, dependsOn: u.dependencies }));
162
+ }
163
+
164
+ export interface TerragruntWavesOptions {
165
+ /** Unit globs that form wave 1 (`live/dev/**`). A glob matching no unit in the set adds nothing. */
166
+ canary?: readonly string[];
167
+ }
168
+
169
+ /** Thrown when units cannot be cut into waves. The message names the units. */
170
+ export class TerragruntWaveError extends Error {
171
+ constructor(message: string) {
172
+ super(message);
173
+ this.name = "TerragruntWaveError";
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Cut units into waves by Terragrunt's unit graph: each unit after every unit
179
+ * it depends on. A dependency outside `units` holds nothing back; it is not
180
+ * part of this change. Each wave is sorted by path.
181
+ *
182
+ * Canaries go first, layered among themselves, so a canary that reads
183
+ * another canary still waits for it to apply. A canary that depends on a unit
184
+ * of the set that is not a canary is refused: it would apply before what it
185
+ * reads.
186
+ */
187
+ export function terragruntWaves(units: readonly TerragruntUnit[], options: TerragruntWavesOptions = {}): string[][] {
188
+ const canaries = new Set((options.canary ?? []).flatMap((g) => units.filter((u) => matchesUnitGlob(u.path, g)).map((u) => u.path)));
189
+ const names = new Set(units.map((u) => u.path));
190
+ for (const u of units) {
191
+ if (!canaries.has(u.path)) continue;
192
+ const outside = u.dependencies.filter((d) => names.has(d) && !canaries.has(d));
193
+ if (outside.length > 0) {
194
+ throw new TerragruntWaveError(`canary ${u.path} depends on ${outside.join(", ")}, which is not a canary, so the canary wave would apply it first`);
195
+ }
196
+ }
197
+ const nodes = terragruntWaveNodes(units);
198
+ const layer = (subset: WaveNode[]): string[][] => {
199
+ try {
200
+ return layerWaves(subset);
201
+ } catch (err) {
202
+ throw new TerragruntWaveError(err instanceof Error ? err.message : String(err));
203
+ }
204
+ };
205
+ return [...layer(nodes.filter((n) => canaries.has(n.name))), ...layer(nodes.filter((n) => !canaries.has(n.name)))];
206
+ }
207
+
208
+ /**
209
+ * Every unit that depends on one of `changed`, directly or through other
210
+ * units, not counting `changed` itself. Sorted by path.
211
+ *
212
+ * `find` reports `dependency` and ordering-only `dependencies` edges as one
213
+ * list, so this follows both. A caller that follows `dependency` edges only
214
+ * passes `edges` built from the units' own blocks.
215
+ */
216
+ export function terragruntDependents(
217
+ units: readonly TerragruntUnit[],
218
+ changed: readonly string[],
219
+ edges: (unit: TerragruntUnit) => readonly string[] = (u) => u.dependencies,
220
+ ): string[] {
221
+ const seeds = new Set(changed.map(clean));
222
+ const reached = new Set<string>(seeds);
223
+ let grew = true;
224
+ while (grew) {
225
+ grew = false;
226
+ for (const u of units) {
227
+ if (reached.has(u.path)) continue;
228
+ if (edges(u).some((d) => reached.has(d))) {
229
+ reached.add(u.path);
230
+ grew = true;
231
+ }
232
+ }
233
+ }
234
+ return [...reached].filter((p) => !seeds.has(p)).sort();
235
+ }