@intentius/chant-lexicon-terraform 0.102.0 → 0.104.1

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,58 @@
1
+ /**
2
+ * Which `terraform.roots` a change touches (#3183): a file in the root's
3
+ * directory, in a local module it calls (and modules those call), or one of
4
+ * its var files.
5
+ */
6
+
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import { afterAll, describe, expect, test } from "vitest";
11
+ import { changedRoots, rootWatchPaths } from "./changed-roots";
12
+
13
+ const project = mkdtempSync(join(tmpdir(), "chant-changed-roots-"));
14
+ afterAll(() => rmSync(project, { recursive: true, force: true }));
15
+
16
+ function write(path: string, text: string): void {
17
+ mkdirSync(dirname(join(project, path)), { recursive: true });
18
+ writeFileSync(join(project, path), text);
19
+ }
20
+
21
+ write("roots/net/main.tf", 'module "vpc" {\n source = "../../modules/vpc"\n}\n');
22
+ write("roots/app/main.tf", 'module "svc" {\n source = "../../modules/svc//inner"\n}\nmodule "reg" {\n source = "terraform-aws-modules/vpc/aws"\n}\n');
23
+ write("roots/dns/main.tofu", 'resource "terraform_data" "z" {}\n');
24
+ write("modules/vpc/main.tf", 'module "subnets" {\n source = "./subnets"\n}\n');
25
+ write("modules/vpc/subnets/main.tf", "");
26
+ write("modules/svc/inner/main.tf", "");
27
+ write("envs/prod.tfvars", "");
28
+
29
+ const ROOTS = {
30
+ net: { dir: "roots/net" },
31
+ app: { dir: "./roots/app", varFiles: ["../../envs/prod.tfvars"] },
32
+ dns: { dir: "roots/dns" },
33
+ };
34
+
35
+ describe("rootWatchPaths", () => {
36
+ test("a root watches its directory, each local module it reaches and its var files", () => {
37
+ expect(rootWatchPaths(project, ROOTS.net)).toEqual(["modules/vpc", "modules/vpc/subnets", "roots/net"]);
38
+ expect(rootWatchPaths(project, ROOTS.app)).toEqual(["envs/prod.tfvars", "modules/svc/inner", "roots/app"]);
39
+ });
40
+ });
41
+
42
+ describe("changedRoots", () => {
43
+ test("a file in a root's directory touches that root alone", () => {
44
+ expect(changedRoots(project, ROOTS, ["roots/dns/main.tofu"])).toEqual(["dns"]);
45
+ });
46
+
47
+ test("a file in a module two calls deep touches the root that calls it", () => {
48
+ expect(changedRoots(project, ROOTS, ["modules/vpc/subnets/main.tf"])).toEqual(["net"]);
49
+ });
50
+
51
+ test("a var file touches the root that reads it", () => {
52
+ expect(changedRoots(project, ROOTS, ["envs/prod.tfvars"])).toEqual(["app"]);
53
+ });
54
+
55
+ test("a registry source is not followed, and a file nothing reads touches nothing", () => {
56
+ expect(changedRoots(project, ROOTS, ["README.md", "roots/netsplit/main.tf"])).toEqual([]);
57
+ });
58
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Which `terraform.roots` a change touched (#3183), from the changed files
3
+ * alone.
4
+ *
5
+ * A root is hand-written HCL, not chant source, so `chant lifecycle affected`
6
+ * never sees it move: its artifact diff covers what chant builds. A pull
7
+ * request's plan needs the roots a change reaches, and this answers that
8
+ * from paths: a root is touched when a changed file sits in its directory,
9
+ * in a local module it calls (followed through modules that call modules),
10
+ * or is one of its var files.
11
+ *
12
+ * The module calls are read with a regex over the root's `.tf` and `.tofu`
13
+ * files, the way `./op/activities/live-detect.ts` reads a live block, so
14
+ * this stays out of the HCL parser's dependency graph. Only a `source` that
15
+ * starts with `./` or `../` is followed; a registry or git source is a
16
+ * version pin in the root's own files, which a change to it already touches.
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { join, posix, relative, resolve, sep } from "node:path";
21
+ import type { TerraformRootConfig } from "./config";
22
+
23
+ /** `source = "./..."` or `"../..."` inside a module block. Other sources are not on disk here. */
24
+ const LOCAL_SOURCE = /\bsource\s*=\s*"(\.{1,2}\/[^"]*)"/g;
25
+
26
+ const toPosix = (p: string): string => p.split(sep).join("/");
27
+
28
+ /** `dir` relative to `projectRoot`, `/`-separated, without a trailing slash; `.` for the root itself. */
29
+ function projectRelative(projectRoot: string, dir: string): string {
30
+ const rel = toPosix(relative(projectRoot, dir));
31
+ return rel === "" ? "." : posix.normalize(rel).replace(/\/+$/, "");
32
+ }
33
+
34
+ /** Local module directories `dir` calls, read from its HCL files. */
35
+ function localModuleDirs(dir: string): string[] {
36
+ let names: string[];
37
+ try {
38
+ names = readdirSync(dir);
39
+ } catch {
40
+ return [];
41
+ }
42
+ const out: string[] = [];
43
+ for (const name of names) {
44
+ if (!name.endsWith(".tf") && !name.endsWith(".tofu")) continue;
45
+ let text: string;
46
+ try {
47
+ text = readFileSync(join(dir, name), "utf8");
48
+ } catch {
49
+ continue;
50
+ }
51
+ for (const m of text.matchAll(LOCAL_SOURCE)) {
52
+ // A `//subdir` suffix names a directory inside the source.
53
+ const target = resolve(dir, m[1].replace("//", "/"));
54
+ if (existsSync(target) && statSync(target).isDirectory()) out.push(target);
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Every path, relative to `projectRoot`, whose change touches `root`: its
62
+ * directory, each local module it reaches, and its var files.
63
+ */
64
+ export function rootWatchPaths(projectRoot: string, root: Pick<TerraformRootConfig, "dir" | "varFiles">): string[] {
65
+ const rootDir = resolve(projectRoot, root.dir);
66
+ const seen = new Set<string>();
67
+ const queue = [rootDir];
68
+ while (queue.length > 0) {
69
+ const dir = queue.shift()!;
70
+ if (seen.has(dir)) continue;
71
+ seen.add(dir);
72
+ queue.push(...localModuleDirs(dir));
73
+ }
74
+ const paths = [...seen].map((d) => projectRelative(projectRoot, d));
75
+ for (const file of root.varFiles ?? []) paths.push(projectRelative(projectRoot, resolve(rootDir, file)));
76
+ return [...new Set(paths)].sort();
77
+ }
78
+
79
+ /** Whether `file` is `path` or sits under it. Both relative to the project, `/`-separated. */
80
+ function covers(path: string, file: string): boolean {
81
+ if (path === ".") return !file.startsWith("../");
82
+ return file === path || file.startsWith(`${path}/`);
83
+ }
84
+
85
+ /**
86
+ * The names of the roots in `roots` that a change to `changedFiles` touches,
87
+ * sorted. `changedFiles` are relative to `projectRoot` with `/` separators,
88
+ * as `git diff --name-only --relative` prints them.
89
+ */
90
+ export function changedRoots(
91
+ projectRoot: string,
92
+ roots: Record<string, Pick<TerraformRootConfig, "dir" | "varFiles">>,
93
+ changedFiles: readonly string[],
94
+ ): string[] {
95
+ const files = changedFiles.map((f) => posix.normalize(f));
96
+ const touched: string[] = [];
97
+ for (const [name, root] of Object.entries(roots)) {
98
+ const watch = rootWatchPaths(projectRoot, root);
99
+ if (files.some((f) => watch.some((p) => covers(p, f)))) touched.push(name);
100
+ }
101
+ return touched.sort();
102
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `chant terraform pin-rollout`: one run of a pin-bump rollout (#3189), from
3
+ * the command line. The `TerraformPinRolloutOp` composite runs the same code
4
+ * as an Op step.
5
+ *
6
+ * chant terraform pin-rollout --module <source> --from <pin> --to <pin>
7
+ * [--root <dir>]... [--depends-on <dir>=<dir>]... [--ts-source <dir>=<file>]...
8
+ * [--canary <dir>]... [--waves-from <live-waves.json> [--waves-prefix <dir>]]
9
+ * [--pull-request] [--base <branch>] [--remote <name>] [--applied-check <name>] [--json]
10
+ *
11
+ * Without `--pull-request` it reports the next wave and opens nothing. Exit
12
+ * 0 when the rollout is complete or a wave's PR was opened (or would be), 3
13
+ * when it is waiting on a merge or an apply (a gate is a fact, #2119), and 1
14
+ * when it stopped on a failed root or a closed PR.
15
+ */
16
+
17
+ import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
18
+ import { splitJoinedFlags, unknownFlagError } from "@intentius/chant/cli/command-group";
19
+ import type { TerraformPinRolloutArgs } from "./op/activities/pin-rollout";
20
+ import type { PinRoot } from "./pin";
21
+
22
+ const USAGE =
23
+ "usage: chant terraform pin-rollout --module <source> --from <pin> --to <pin> [--root <dir>]... [--depends-on <dir>=<dir>]... " +
24
+ "[--ts-source <dir>=<file>]... [--canary <dir>]... [--waves-from <file> [--waves-prefix <dir>]] [--pull-request] " +
25
+ "[--base <branch>] [--remote <name>] [--applied-check <name>] [--json]";
26
+
27
+ const VALUE_FLAGS = new Set(["--module", "--from", "--to", "--root", "--depends-on", "--ts-source", "--canary", "--waves-from", "--waves-prefix", "--base", "--remote", "--applied-check"]);
28
+ const BOOLEAN_FLAGS = new Set(["--pull-request", "--json"]);
29
+
30
+ /** Parse the verb's flags into the activity's args. Throws on an unknown flag or a missing value. */
31
+ export function parsePinRolloutArgs(rawArgs: string[]): { args: TerraformPinRolloutArgs; json: boolean } {
32
+ const tokens = splitJoinedFlags(rawArgs, BOOLEAN_FLAGS);
33
+ const values = new Map<string, string[]>();
34
+ const flags = new Set<string>();
35
+ for (let i = 0; i < tokens.length; i++) {
36
+ const t = tokens[i]!;
37
+ if (BOOLEAN_FLAGS.has(t)) {
38
+ flags.add(t);
39
+ continue;
40
+ }
41
+ if (!VALUE_FLAGS.has(t)) throw t.startsWith("-") ? unknownFlagError(t, USAGE) : new Error(`unexpected argument "${t}"\n${USAGE}`);
42
+ const v = tokens[++i];
43
+ if (v === undefined || v.startsWith("--")) throw new Error(`${t} needs a value\n${USAGE}`);
44
+ values.set(t, [...(values.get(t) ?? []), v]);
45
+ }
46
+ const one = (flag: string): string | undefined => values.get(flag)?.at(-1);
47
+ const pairs = (flag: string): Array<[string, string]> =>
48
+ (values.get(flag) ?? []).map((v) => {
49
+ const eq = v.indexOf("=");
50
+ if (eq <= 0 || eq === v.length - 1) throw new Error(`${flag} takes <dir>=<value>, got "${v}"`);
51
+ return [v.slice(0, eq), v.slice(eq + 1)];
52
+ });
53
+ for (const required of ["--module", "--from", "--to"]) if (!one(required)) throw new Error(`${required} is required\n${USAGE}`);
54
+
55
+ const dependsOn = pairs("--depends-on");
56
+ const tsSource = new Map(pairs("--ts-source"));
57
+ const named = [...new Set([...(values.get("--root") ?? []), ...dependsOn.map(([r]) => r), ...tsSource.keys()])];
58
+ const roots: PinRoot[] | undefined =
59
+ named.length > 0
60
+ ? named.map((root) => {
61
+ const deps = dependsOn.filter(([r]) => r === root).map(([, d]) => d);
62
+ return { root, ...(deps.length > 0 ? { dependsOn: deps } : {}), ...(tsSource.has(root) ? { tsSource: tsSource.get(root)! } : {}) };
63
+ })
64
+ : undefined;
65
+
66
+ const args: TerraformPinRolloutArgs = {
67
+ module: one("--module")!,
68
+ from: one("--from")!,
69
+ to: one("--to")!,
70
+ mode: flags.has("--pull-request") ? "pull-request" : "report",
71
+ ...(roots ? { roots } : {}),
72
+ ...(values.has("--canary") ? { canaries: values.get("--canary")! } : {}),
73
+ ...(one("--waves-from") ? { wavesFrom: one("--waves-from")! } : {}),
74
+ ...(one("--waves-prefix") ? { wavesPrefix: one("--waves-prefix")! } : {}),
75
+ ...(one("--base") ? { base: one("--base")! } : {}),
76
+ ...(one("--remote") ? { remote: one("--remote")! } : {}),
77
+ ...(one("--applied-check") ? { appliedCheck: one("--applied-check")! } : {}),
78
+ };
79
+ return { args, json: flags.has("--json") };
80
+ }
81
+
82
+ /** Exit status for a rollout's status. */
83
+ export function pinRolloutExitCode(status: string): number {
84
+ if (status === "waiting") return 3;
85
+ if (status === "stopped") return 1;
86
+ return 0;
87
+ }
88
+
89
+ async function runPinRolloutVerb(ctx: CommandGroupContext): Promise<number> {
90
+ let parsed: ReturnType<typeof parsePinRolloutArgs>;
91
+ try {
92
+ parsed = parsePinRolloutArgs(ctx.rawArgs);
93
+ } catch (err) {
94
+ console.error(err instanceof Error ? err.message : String(err));
95
+ return 1;
96
+ }
97
+ const { readPinRollout } = await import("./op/activities/pin-rollout");
98
+ try {
99
+ const result = await readPinRollout(parsed.args);
100
+ console.log(parsed.json ? JSON.stringify(result, null, 2) : result.summary);
101
+ return pinRolloutExitCode(result.status);
102
+ } catch (err) {
103
+ console.error(err instanceof Error ? err.message : String(err));
104
+ return 1;
105
+ }
106
+ }
107
+
108
+ export const terraformCommands: CommandGroup = {
109
+ name: "terraform",
110
+ description: "Terraform and OpenTofu roots: roll a module version out as one pin-bump PR per wave",
111
+ commands: [{ name: "pin-rollout", description: "Read a pin-bump rollout's state and open the next wave's PR when it is due", handler: runPinRolloutVerb }],
112
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `terraform-apply` (#3049): plan answers with the root and its #2300 digest,
3
+ * and run applies the plan a wave approved rather than planning again.
4
+ */
5
+
6
+ import { beforeEach, describe, expect, test, vi } from "vitest";
7
+
8
+ const calls: string[] = [];
9
+ let applyResult: Record<string, unknown> = { applied: true };
10
+ let changeSet: Record<string, unknown> | undefined;
11
+
12
+ vi.mock("../op/activities/terraform", () => ({
13
+ terraformInit: vi.fn(async (args: { root: string }) => {
14
+ calls.push(`init ${args.root}`);
15
+ return { dir: `/x/${args.root}` };
16
+ }),
17
+ terraformPlan: vi.fn(async (args: { root: string; vars?: Record<string, unknown> }) => {
18
+ calls.push(`plan ${args.root} ${JSON.stringify(args.vars ?? {})}`);
19
+ return { planFile: "chant.tfplan", planDigest: `jcs1-sha256:${args.root}`, ...(changeSet ? { changeSet } : {}) };
20
+ }),
21
+ terraformApply: vi.fn(async (args: { root: string; planFile: string }) => {
22
+ calls.push(`apply ${args.root} ${args.planFile}`);
23
+ return { planFile: args.planFile, dir: `/x/${args.root}`, ...applyResult };
24
+ }),
25
+ terraformOutputs: vi.fn(async (args: { root: string }) => {
26
+ calls.push(`output ${args.root}`);
27
+ return { id: `${args.root}-id` };
28
+ }),
29
+ }));
30
+
31
+ const { terraformApplyCapability, terraformCapabilityPlugin } = await import("./capability-plugin");
32
+
33
+ beforeEach(() => {
34
+ calls.length = 0;
35
+ applyResult = { applied: true };
36
+ changeSet = undefined;
37
+ });
38
+
39
+ describe("terraform-apply", () => {
40
+ test("the plugin contributes it", () => {
41
+ expect(terraformCapabilityPlugin.capabilities().map((c) => c.kind)).toEqual(["terraform-apply"]);
42
+ });
43
+
44
+ test("plan inits and plans the root, with its vars, and names the root as the member", async () => {
45
+ const planned = await terraformApplyCapability.plan!({ env: "test", component: "app" }, { root: "app", vars: { vpc: "v-1" } });
46
+ expect(planned).toEqual({
47
+ member: "app",
48
+ planDigest: "jcs1-sha256:app",
49
+ artifact: { planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" },
50
+ });
51
+ expect(calls).toEqual(["init app", 'plan app {"vpc":"v-1"}']);
52
+ });
53
+
54
+ test("plan hands on the root's change-set part, outside the artifact run receives (#3183)", async () => {
55
+ changeSet = { member: { member: "app" }, entries: [] };
56
+ const planned = await terraformApplyCapability.plan!({ env: "test", component: "app" }, { root: "app" });
57
+ expect(planned.changeSet).toEqual(changeSet);
58
+ expect(planned.artifact).toEqual({ planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" });
59
+ });
60
+
61
+ test("outputs inits the root and reads what its state exposes, planning nothing (#3183)", async () => {
62
+ expect(await terraformApplyCapability.outputs!({ env: "test", component: "net" }, { root: "net" })).toEqual({ id: "net-id" });
63
+ expect(calls).toEqual(["init net", "output net"]);
64
+ });
65
+
66
+ test("run applies the wave's plan file without planning again, and returns the root's outputs", async () => {
67
+ const out = await terraformApplyCapability.run(
68
+ { env: "test", component: "app", plans: { app: { planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" } } },
69
+ { root: "app" },
70
+ );
71
+ expect(calls).toEqual(["apply app chant.tfplan", "output app"]);
72
+ expect(out).toEqual({ root: "app", planDigest: "jcs1-sha256:app", fromWavePlan: true, outputs: { id: "app-id" } });
73
+ });
74
+
75
+ test("run without a wave plan plans and applies in one go", async () => {
76
+ const out = await terraformApplyCapability.run({ env: "test", component: "app" }, { root: "app" });
77
+ expect(calls).toEqual(["init app", "plan app {}", "apply app chant.tfplan", "output app"]);
78
+ expect(out.fromWavePlan).toBe(false);
79
+ });
80
+
81
+ test("a refused plan fails the step", async () => {
82
+ applyResult = { applied: false, refused: "approval-mismatch", refusal: "The approved plan no longer matches the live system" };
83
+ await expect(
84
+ terraformApplyCapability.run(
85
+ { env: "test", component: "app", plans: { app: { planFile: "chant.tfplan", planDigest: "d" } } },
86
+ { root: "app" },
87
+ ),
88
+ ).rejects.toThrow(/refused the plan it was given \(approval-mismatch\)/);
89
+ expect(calls).not.toContain("output app");
90
+ });
91
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `terraformCapabilityPlugin` — the terraform lexicon's capability plugin
3
+ * (#3049). It contributes `terraform-apply`, which lets a component deploy a
4
+ * root named in `terraform.roots`, so a change across many roots runs as a
5
+ * `chant components fan-out`: in an order derived from each component's
6
+ * `dependsOn`, and, with `--wave-gate`, a gate per wave bound to the wave's
7
+ * set digest.
8
+ *
9
+ * The step is `{ kind: "terraform-apply", root: "<name>" }`, with optional
10
+ * `vars` (passed as `-var`, so `stackOutput()` can hand one root's output to
11
+ * another) and `cwd`. It works the same for `terraform`, `tofu` and
12
+ * `choudoufu`, since all three go through the lexicon's activities.
13
+ *
14
+ * ## Plan, then apply exactly that plan
15
+ *
16
+ * `plan` runs `init` and `plan -out`, and answers with the root as the member
17
+ * and `terraformPlanDigest` as its digest: the value a `TerraformApplyOp`
18
+ * gate on this root alone binds (#2300). A gated-wave fan-out folds those into
19
+ * the wave's set digest and hands the plan file back to `run`, which applies
20
+ * that file and nothing else. terraform and tofu refuse a saved plan whose
21
+ * state moved since it was written; choudoufu re-plans against the live
22
+ * system and refuses with exit 3 when its fresh plan differs (choudoufu
23
+ * #878). Either refusal fails the step.
24
+ *
25
+ * Run without a wave plan (`chant run --components`, or a fan-out with no
26
+ * wave gate), `run` plans and applies in one go, which is what `terraform
27
+ * apply` with no plan file does too.
28
+ */
29
+
30
+ import type { Capability, DeployContext, CapabilityPlan } from "@intentius/chant/components/capability";
31
+ import type { ChangeSetPart } from "@intentius/chant/change-set";
32
+ import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
33
+ import { terraformApply, terraformInit, terraformOutputs, terraformPlan } from "../op/activities/terraform";
34
+
35
+ /** The `terraform-apply` step's input. */
36
+ export interface TerraformApplyStepInput {
37
+ /** Key into `terraform.roots`. Also the member's name in a wave's set digest. */
38
+ root: string;
39
+ /** Input variables, after the root's var files. See `TerraformPlanArgs.vars`. */
40
+ vars?: Record<string, unknown>;
41
+ /** Where the `chant.config.*` search starts. Default: the current directory. */
42
+ cwd?: string;
43
+ }
44
+
45
+ /** What `terraform-apply` returns. `outputs` is what a dependent's `stackOutput()` reads. */
46
+ export interface TerraformApplyStepOutput {
47
+ root: string;
48
+ planDigest: string;
49
+ /** Whether the plan applied came from the wave's plan rather than one made here. */
50
+ fromWavePlan: boolean;
51
+ /** `terraform output -json`, as `{ name: value }`. */
52
+ outputs: Record<string, unknown>;
53
+ }
54
+
55
+ /** What `plan` hands `run` through `DeployContext.plans`. */
56
+ interface PlannedRoot {
57
+ planFile: string;
58
+ planDigest: string;
59
+ }
60
+
61
+ const rootArgs = (input: TerraformApplyStepInput) => ({ root: input.root, ...(input.cwd ? { cwd: input.cwd } : {}) });
62
+
63
+ function requireRoot(input: TerraformApplyStepInput): void {
64
+ if (typeof input.root !== "string" || input.root === "") {
65
+ throw new Error('terraform-apply: "root" is required and names an entry in terraform.roots');
66
+ }
67
+ }
68
+
69
+ async function planRoot(input: TerraformApplyStepInput): Promise<PlannedRoot & { changeSet?: ChangeSetPart }> {
70
+ requireRoot(input);
71
+ await terraformInit(rootArgs(input));
72
+ const planned = await terraformPlan({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
73
+ return {
74
+ planFile: planned.planFile,
75
+ planDigest: planned.planDigest,
76
+ ...(planned.changeSet ? { changeSet: planned.changeSet } : {}),
77
+ };
78
+ }
79
+
80
+ function isPlannedRoot(value: unknown): value is PlannedRoot {
81
+ const v = value as Partial<PlannedRoot> | undefined;
82
+ return typeof v?.planFile === "string" && typeof v.planDigest === "string";
83
+ }
84
+
85
+ export const terraformApplyCapability: Capability<TerraformApplyStepInput, TerraformApplyStepOutput> = {
86
+ kind: "terraform-apply",
87
+ // A root's apply has no undo short of applying the previous configuration,
88
+ // which is a change of its own. COMP003 asks the component to say so.
89
+ rollbackPolicy: "needs-opt-out",
90
+ async plan(_ctx: DeployContext, input: TerraformApplyStepInput): Promise<CapabilityPlan> {
91
+ const { changeSet, ...planned } = await planRoot(input);
92
+ return { member: input.root, planDigest: planned.planDigest, artifact: planned, ...(changeSet ? { changeSet } : {}) };
93
+ },
94
+ // The root's outputs as its state holds them now (#3183): what a dependent
95
+ // planned before this root applies reads through `stackOutput()`.
96
+ async outputs(_ctx: DeployContext, input: TerraformApplyStepInput): Promise<Record<string, unknown>> {
97
+ requireRoot(input);
98
+ await terraformInit(rootArgs(input));
99
+ return terraformOutputs(rootArgs(input));
100
+ },
101
+ async run(ctx: DeployContext, input: TerraformApplyStepInput): Promise<TerraformApplyStepOutput> {
102
+ const fromWave = ctx.plans?.[input.root];
103
+ const planned: PlannedRoot = isPlannedRoot(fromWave) ? fromWave : await planRoot(input);
104
+ const applied = await terraformApply({ ...rootArgs(input), planFile: planned.planFile });
105
+ if (!applied.applied) {
106
+ throw new Error(
107
+ `terraform-apply: root "${input.root}" refused the plan it was given (${applied.refused ?? "refused"}). ` +
108
+ (applied.refusal ?? ""),
109
+ );
110
+ }
111
+ const outputs = await terraformOutputs(rootArgs(input));
112
+ return { root: input.root, planDigest: planned.planDigest, fromWavePlan: isPlannedRoot(fromWave), outputs };
113
+ },
114
+ };
115
+
116
+ export const TERRAFORM_VERB_FAMILIES = {
117
+ apply: ["terraform-apply"],
118
+ } as const;
119
+
120
+ export const terraformCapabilityPlugin: CapabilityPlugin = {
121
+ name: "terraform",
122
+ get version(): string {
123
+ return ownPackageVersion(import.meta.url);
124
+ },
125
+ capabilities(): Array<Capability<never, unknown>> {
126
+ return [terraformApplyCapability as Capability<never, unknown>];
127
+ },
128
+ families(): Record<string, readonly string[]> {
129
+ return TERRAFORM_VERB_FAMILIES;
130
+ },
131
+ };