@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
package/src/index.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  // Plugin
2
2
  export { terraformPlugin } from "./plugin";
3
3
 
4
+ // The `terraform-apply` component verb (#3049), contributed to core's
5
+ // capability-plugin seam. Core loads `terraformCapabilityPlugin` when a
6
+ // project's chant.config lists this lexicon, so a component can deploy a root
7
+ // and a fan-out can run many of them in gated waves.
8
+ export {
9
+ terraformCapabilityPlugin,
10
+ terraformApplyCapability,
11
+ TERRAFORM_VERB_FAMILIES,
12
+ type TerraformApplyStepInput,
13
+ type TerraformApplyStepOutput,
14
+ } from "./components/capability-plugin";
15
+
4
16
  // Serializer
5
17
  export { terraformSerializer } from "./serializer";
6
18
 
@@ -55,6 +67,7 @@ export {
55
67
  choudoufuLiveLs,
56
68
  choudoufuLiveCheck,
57
69
  choudoufuAdopt,
70
+ terraformPinRollout,
58
71
  } from "./op/builders";
59
72
 
60
73
  // The adoption ledger over `live-plan -json`'s document (#2105). Pure, and
@@ -90,6 +103,11 @@ export type {
90
103
  export { TerraformAdoptOp } from "./composites/terraform-adopt-op";
91
104
  export type { TerraformAdoptOpConfig, TerraformAdoptOpResources } from "./composites/terraform-adopt-op";
92
105
 
106
+ // One pin-bump PR per wave (#3189). The pin edit, wave plan and rollout run
107
+ // are in the `@intentius/chant-lexicon-terraform/pin` subpath.
108
+ export { TerraformPinRolloutOp } from "./composites/terraform-pin-rollout-op";
109
+ export type { TerraformPinRolloutOpConfig, TerraformPinRolloutOpResources } from "./composites/terraform-pin-rollout-op";
110
+
93
111
  // Live observation over `terraform show -json` (#2087). The plugin reaches
94
112
  // `describeResources` through a dynamic import; the ownership keys are here
95
113
  // because `ownershipChannel` declares them eagerly.
@@ -469,6 +469,88 @@ export const terraformAuditCatalog: Record<string, RuleMeta> = {
469
469
  authority: [AWS_ECR_TAG_IMMUTABILITY],
470
470
  yamlBased: false,
471
471
  },
472
+ TF038: {
473
+ id: "TF038",
474
+ tier: "merge-worthy",
475
+ fixKind: "guidance",
476
+ category: "correctness",
477
+ title: "OCI module source has no tag or digest, or a mutable tag",
478
+ remediation:
479
+ "Pin the `oci://` source with `?digest=sha256:...` (a digest cannot move) or an exact version tag " +
480
+ "(`?tag=1.4.0`). A source with no tag pulls `latest`.",
481
+ yamlBased: false,
482
+ },
483
+ TF039: {
484
+ id: "TF039",
485
+ tier: "report-only",
486
+ fixKind: "guidance",
487
+ category: "best-practice",
488
+ title: "Registry module version is a range, not an exact version",
489
+ remediation: 'Set an exact `version` (`version = "1.4.0"`) for projects that pin modules. Off by default.',
490
+ yamlBased: false,
491
+ },
492
+ TF040: {
493
+ id: "TF040",
494
+ tier: "report-only",
495
+ fixKind: "guidance",
496
+ category: "best-practice",
497
+ title: "Root has no committed .terraform.lock.hcl",
498
+ remediation:
499
+ "Run `terraform init` in the root and commit the `.terraform.lock.hcl` it writes. Off by default; " +
500
+ "checked only where the root's directory is known.",
501
+ yamlBased: false,
502
+ },
503
+ TF041: {
504
+ id: "TF041",
505
+ tier: "merge-worthy",
506
+ fixKind: "guidance",
507
+ category: "correctness",
508
+ title: "Terragrunt dependency lets mock_outputs stand in for apply",
509
+ remediation:
510
+ 'Set `mock_outputs_allowed_terraform_commands = ["validate", "plan"]` on every `dependency` that sets `mock_outputs`. ' +
511
+ "Terragrunt reads a missing or empty list as every command, apply included.",
512
+ yamlBased: false,
513
+ },
514
+ TF042: {
515
+ id: "TF042",
516
+ tier: "merge-worthy",
517
+ fixKind: "guidance",
518
+ category: "correctness",
519
+ title: "Terragrunt dependency sets skip_outputs together with mock_outputs",
520
+ remediation:
521
+ "Drop `skip_outputs`, or pass the values as `inputs`. With `skip_outputs = true` the upstream outputs are never read, so every run uses the mocks.",
522
+ yamlBased: false,
523
+ },
524
+ TF043: {
525
+ id: "TF043",
526
+ tier: "merge-worthy",
527
+ fixKind: "guidance",
528
+ category: "correctness",
529
+ title: "Terragrunt terraform.source names no version (git ref, tfr version or oci tag)",
530
+ remediation:
531
+ "Pin the source: `?ref=` for git, `?version=` for `tfr://`, `?digest=` or `?tag=` for `oci://`.",
532
+ yamlBased: false,
533
+ },
534
+ TF044: {
535
+ id: "TF044",
536
+ tier: "merge-worthy",
537
+ fixKind: "guidance",
538
+ category: "correctness",
539
+ title: "Terragrunt config keeps state in a local backend",
540
+ remediation:
541
+ 'Use a shared backend (S3, GCS) in `remote_state`. State written by `backend = "local"` stays on the runner and is lost with it.',
542
+ yamlBased: false,
543
+ },
544
+ TF045: {
545
+ id: "TF045",
546
+ tier: "report-only",
547
+ fixKind: "guidance",
548
+ category: "best-practice",
549
+ title: "No Terragrunt config sets terragrunt_version_constraint",
550
+ remediation:
551
+ 'Set `terragrunt_version_constraint = ">= 1.1.0"` in the root config. Off by default; judged only across the configs scanned.',
552
+ yamlBased: false,
553
+ },
472
554
  };
473
555
 
474
556
  // Prior art credits, if any, live beside the rules in ./audit-lineage.ts (see
@@ -208,7 +208,9 @@ describe("every fixture root gets the same verdict from chant audit as from chan
208
208
  entities,
209
209
  buildResult: { outputs: new Map(), entities, warnings, errors: [], sourceFileCount: entities.size },
210
210
  } as unknown as PostSynthContext;
211
- return verdictOf((terraformPlugin.postSynthChecks?.() ?? []).flatMap((check) => check.check(ctx)));
211
+ // An `auditOptIn` check (TF040, #3190) is the one place the audit is meant to
212
+ // differ from the unfiltered build: it needs `lint.rules` there.
213
+ return verdictOf((terraformPlugin.postSynthChecks?.() ?? []).filter((check) => !check.auditOptIn).flatMap((check) => check.check(ctx)));
212
214
  }
213
215
 
214
216
  async function auditVerdict(dir: string): Promise<string[]> {
@@ -0,0 +1,3 @@
1
+ module "network" {
2
+ source = "oci://registry.example.com/platform/network"
3
+ }
@@ -0,0 +1,4 @@
1
+ module "vpc" {
2
+ source = "terraform-aws-modules/vpc/aws"
3
+ version = "~> 5.0"
4
+ }
@@ -0,0 +1,12 @@
1
+ dependency "vpc" {
2
+ config_path = "../vpc"
3
+
4
+ mock_outputs = {
5
+ vpc_id = "vpc-00000000"
6
+ }
7
+ mock_outputs_allowed_terraform_commands = ["validate", "plan"]
8
+ }
9
+
10
+ dependency "db" {
11
+ config_path = "../db"
12
+ }
@@ -0,0 +1,7 @@
1
+ dependency "vpc" {
2
+ config_path = "../vpc"
3
+
4
+ mock_outputs = {
5
+ vpc_id = "vpc-00000000"
6
+ }
7
+ }
@@ -0,0 +1,8 @@
1
+ dependency "vpc" {
2
+ config_path = "../vpc"
3
+
4
+ mock_outputs = {
5
+ vpc_id = "vpc-00000000"
6
+ }
7
+ mock_outputs_allowed_terraform_commands = ["validate", "plan", "apply"]
8
+ }
@@ -0,0 +1,13 @@
1
+ dependency "vpc" {
2
+ config_path = "../vpc"
3
+ skip_outputs = true
4
+ }
5
+
6
+ dependency "db" {
7
+ config_path = "../db"
8
+ skip_outputs = false
9
+
10
+ mock_outputs = {
11
+ endpoint = "db.example.com"
12
+ }
13
+ }
@@ -0,0 +1,9 @@
1
+ dependency "vpc" {
2
+ config_path = "../vpc"
3
+ skip_outputs = true
4
+
5
+ mock_outputs = {
6
+ vpc_id = "vpc-00000000"
7
+ }
8
+ mock_outputs_allowed_terraform_commands = ["validate", "plan"]
9
+ }
@@ -0,0 +1,3 @@
1
+ terraform {
2
+ source = "git::https://github.com/example/modules.git//vpc?ref=v1.4.0"
3
+ }
@@ -0,0 +1,3 @@
1
+ terraform {
2
+ source = "${get_repo_root()}/modules/vpc"
3
+ }
@@ -0,0 +1,3 @@
1
+ terraform {
2
+ source = "git::https://github.com/example/modules.git//vpc"
3
+ }
@@ -0,0 +1,3 @@
1
+ terraform {
2
+ source = "oci://registry.example.com/platform/vpc"
3
+ }
@@ -0,0 +1,3 @@
1
+ terraform {
2
+ source = "tfr:///terraform-aws-modules/vpc/aws"
3
+ }
@@ -0,0 +1,9 @@
1
+ remote_state {
2
+ backend = "s3"
3
+
4
+ config = {
5
+ bucket = "example-tfstate"
6
+ key = "${path_relative_to_include()}/terraform.tfstate"
7
+ region = "us-east-1"
8
+ }
9
+ }
@@ -0,0 +1,7 @@
1
+ remote_state {
2
+ backend = "local"
3
+
4
+ config = {
5
+ path = "${get_parent_terragrunt_dir()}/.state/${path_relative_to_include()}/terraform.tfstate"
6
+ }
7
+ }
@@ -0,0 +1,9 @@
1
+ generate "backend" {
2
+ path = "backend.tf"
3
+ if_exists = "overwrite"
4
+ contents = <<-EOT
5
+ terraform {
6
+ backend "local" {}
7
+ }
8
+ EOT
9
+ }
@@ -0,0 +1 @@
1
+ terragrunt_version_constraint = ">= 1.1.0"
@@ -0,0 +1,3 @@
1
+ include "root" {
2
+ path = find_in_parent_folders("root.hcl")
3
+ }
@@ -0,0 +1,9 @@
1
+ remote_state {
2
+ backend = "s3"
3
+
4
+ config = {
5
+ bucket = "example-tfstate"
6
+ key = "${path_relative_to_include()}/terraform.tfstate"
7
+ region = "us-east-1"
8
+ }
9
+ }
@@ -0,0 +1,3 @@
1
+ include "root" {
2
+ path = find_in_parent_folders("root.hcl")
3
+ }
@@ -0,0 +1,3 @@
1
+ include "root" {
2
+ path = find_in_parent_folders("root.hcl")
3
+ }
@@ -36,6 +36,14 @@ import { tf034 } from "./tf034";
36
36
  import { tf035 } from "./tf035";
37
37
  import { tf036 } from "./tf036";
38
38
  import { tf037 } from "./tf037";
39
+ import { tf038 } from "./tf038";
40
+ import { tf039 } from "./tf039";
41
+ import { tf040 } from "./tf040";
42
+ import { tf041 } from "./tf041";
43
+ import { tf042 } from "./tf042";
44
+ import { tf043 } from "./tf043";
45
+ import { tf044 } from "./tf044";
46
+ import { tf045 } from "./tf045";
39
47
 
40
48
  export const postSynthChecks: PostSynthCheck[] = [
41
49
  tf001,
@@ -74,4 +82,12 @@ export const postSynthChecks: PostSynthCheck[] = [
74
82
  tf035,
75
83
  tf036,
76
84
  tf037,
85
+ tf038,
86
+ tf039,
87
+ tf040,
88
+ tf041,
89
+ tf042,
90
+ tf043,
91
+ tf044,
92
+ tf045,
77
93
  ];
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { classifyModuleSource, isDefaultBranch, isPinnedRef } from "./module-source";
2
+ import { classifyModuleSource, isDefaultBranch, isExactVersionConstraint, isMutableOciTag, isPinnedRef } from "./module-source";
3
3
 
4
4
  describe("classifyModuleSource", () => {
5
5
  test("local paths", () => {
@@ -73,3 +73,44 @@ describe("isDefaultBranch", () => {
73
73
  expect(isDefaultBranch("some-feature")).toBe(false);
74
74
  });
75
75
  });
76
+
77
+ describe("classifyModuleSource: oci sources (#3190)", () => {
78
+ const cases: Array<[string, string, string | undefined, string | undefined]> = [
79
+ ["tag query", "oci://example.com/org/mod?tag=v1.4.0", "v1.4.0", undefined],
80
+ ["digest query", "oci://example.com/org/mod?digest=sha256:abc123", undefined, "sha256:abc123"],
81
+ ["tag suffix", "oci://example.com/org/mod:1.4.0", "1.4.0", undefined],
82
+ ["digest suffix", "oci://example.com/org/mod@sha256:abc123", undefined, "sha256:abc123"],
83
+ ["tag and digest suffix", "oci://example.com/org/mod:1.4.0@sha256:abc123", "1.4.0", "sha256:abc123"],
84
+ ["tag and digest query", "oci://example.com/org/mod?tag=1.4.0&digest=sha256:abc123", "1.4.0", "sha256:abc123"],
85
+ ["neither", "oci://example.com/org/mod", undefined, undefined],
86
+ ["host with a port, no tag", "oci://registry.example.com:5000/org/mod", undefined, undefined],
87
+ ["host with a port and a tag", "oci://registry.example.com:5000/org/mod:2.0.1", "2.0.1", undefined],
88
+ ["host with a port and a digest", "oci://registry.example.com:5000/org/mod@sha256:def", undefined, "sha256:def"],
89
+ ["subdirectory before the query", "oci://example.com/org/mod//modules/vpc?tag=v1.0.0", "v1.0.0", undefined],
90
+ ["subdirectory, no pin", "oci://example.com/org/mod//modules/vpc", undefined, undefined],
91
+ ];
92
+
93
+ test.each(cases)("%s", (_name, source, tag, digest) => {
94
+ const c = classifyModuleSource(source);
95
+ expect(c.kind).toBe("oci");
96
+ expect(c.tag).toBe(tag);
97
+ expect(c.digest).toBe(digest);
98
+ });
99
+ });
100
+
101
+ describe("isMutableOciTag", () => {
102
+ test("names that move are mutable, versions are not", () => {
103
+ expect(isMutableOciTag("latest")).toBe(true);
104
+ expect(isMutableOciTag("LATEST")).toBe(true);
105
+ expect(isMutableOciTag("main")).toBe(true);
106
+ expect(isMutableOciTag("1.4.0")).toBe(false);
107
+ expect(isMutableOciTag("v1.4.0")).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe("isExactVersionConstraint", () => {
112
+ test.each([["1.4.0", true], ["= 1.4.0", true], ["=1.4.0", true], ["1.4.0-rc1", true], ["~> 1.4", false], [">= 1.4", false], [">= 1.0, < 2.0", false], ["!= 1.4.0", false], ["< 2", false]])(
113
+ "%s",
114
+ (v, exact) => expect(isExactVersionConstraint(v as string)).toBe(exact),
115
+ );
116
+ });
@@ -18,14 +18,27 @@
18
18
  * recognize, scp-style `git@host:path` addresses, and any source ending in
19
19
  * `.git`. `local` is `./` or `../` (checkov's `UNKNOWN` case for module
20
20
  * pinning: a local path cannot be pinned to anything and is never flagged).
21
+ * `oci` is an OpenTofu `oci://host/repo` source (#3190). OpenTofu selects a
22
+ * version with `?tag=` or `?digest=` (https://opentofu.org/docs/language/modules/sources/),
23
+ * and a source naming neither resolves the `latest` tag. The container-image
24
+ * shapes `repo:tag` and `repo@sha256:...` are read too, so a source written
25
+ * the way `docker pull` spells it is not misread as unpinned. A `:` inside the
26
+ * host (a registry port) is never a tag: only a colon in the last path
27
+ * segment is. The tag and digest rules were checked against the docs page
28
+ * above; the suffix forms are not documented there and are not verified
29
+ * against OpenTofu's parser.
21
30
  */
22
31
 
23
- export type ModuleSourceKind = "local" | "registry" | "git" | "other";
32
+ export type ModuleSourceKind = "local" | "registry" | "git" | "oci" | "other";
24
33
 
25
34
  export interface ModuleSourceClassification {
26
35
  kind: ModuleSourceKind;
27
36
  /** The `?ref=`/`&ref=` value (falling back to `rev`), when `kind` is `"git"`. */
28
37
  ref?: string;
38
+ /** The tag an `oci://` source names, from `?tag=` or a `:tag` suffix on the repository. */
39
+ tag?: string;
40
+ /** The digest an `oci://` source names, from `?digest=` or an `@sha256:...` suffix. */
41
+ digest?: string;
29
42
  }
30
43
 
31
44
  const REGISTRY_NAME = /^[0-9A-Za-z](?:[0-9A-Za-z_-]{0,62}[0-9A-Za-z])?$/;
@@ -77,6 +90,25 @@ function isGitShaped(rest: string): boolean {
77
90
  return false;
78
91
  }
79
92
 
93
+ function classifyOci(base: string, query: string): ModuleSourceClassification {
94
+ const params = parseQuery(query);
95
+ let path = stripSubdir(base).replace(/^oci:\/\//i, "");
96
+ let tag = params.get("tag") || undefined;
97
+ let digest = params.get("digest") || undefined;
98
+
99
+ const at = path.indexOf("@");
100
+ if (at !== -1) {
101
+ digest = digest ?? (path.slice(at + 1) || undefined);
102
+ path = path.slice(0, at);
103
+ }
104
+ const lastSlash = path.lastIndexOf("/");
105
+ const colon = path.indexOf(":", lastSlash + 1); // a port sits before the first slash
106
+ if (colon !== -1) {
107
+ tag = tag ?? (path.slice(colon + 1) || undefined);
108
+ }
109
+ return { kind: "oci", ...(tag !== undefined ? { tag } : {}), ...(digest !== undefined ? { digest } : {}) };
110
+ }
111
+
80
112
  export function classifyModuleSource(source: string): ModuleSourceClassification {
81
113
  const trimmed = source.trim();
82
114
  if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith(".\\") || trimmed.startsWith("..\\")) {
@@ -87,6 +119,8 @@ export function classifyModuleSource(source: string): ModuleSourceClassification
87
119
  const base = queryIdx === -1 ? trimmed : trimmed.slice(0, queryIdx);
88
120
  const query = queryIdx === -1 ? "" : trimmed.slice(queryIdx + 1);
89
121
 
122
+ if (/^oci:\/\//i.test(base)) return classifyOci(base, query);
123
+
90
124
  const forced = /^(git|hg)::/i.exec(base);
91
125
  const rest = forced ? base.slice(forced[0].length) : base;
92
126
 
@@ -119,3 +153,24 @@ export function isPinnedRef(ref: string): boolean {
119
153
  export function isDefaultBranch(ref: string): boolean {
120
154
  return DEFAULT_BRANCHES.has(ref);
121
155
  }
156
+
157
+ const MUTABLE_TAGS = new Set(["latest", "main", "master", "develop", "dev", "stable", "edge", "nightly"]);
158
+
159
+ /**
160
+ * Is `tag` a name that conventionally moves (`latest`, a branch-like name)
161
+ * rather than an exact version. Used by TF038. A tag is a mutable pointer in
162
+ * most registries whatever it looks like, so the rule is a warning and this
163
+ * only decides what reads as obviously floating.
164
+ */
165
+ export function isMutableOciTag(tag: string): boolean {
166
+ return MUTABLE_TAGS.has(tag.toLowerCase());
167
+ }
168
+
169
+ /**
170
+ * Is a registry `version` constraint an exact version (`1.4.0`, `= 1.4.0`,
171
+ * `=1.4.0`) rather than a range (`~> 1.4`, `>= 1.4`, `>= 1.0, < 2.0`). Used
172
+ * by TF039. A bare version is an exact constraint in Terraform.
173
+ */
174
+ export function isExactVersionConstraint(version: string): boolean {
175
+ return /^(?:=\s*)?v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version.trim()) && !version.includes(",");
176
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared reading for the Terragrunt rules TF041 to TF045 (#3417).
3
+ *
4
+ * These rules look at `terragrunt.hcl` and `root.hcl`, which the parse reads
5
+ * into their own entity types (`TERRAGRUNT_*_TYPE` in `../../hcl/parse.ts`)
6
+ * and never into the module types the other TF rules check. Like the module
7
+ * source rules, they read the literal values hcl2json hands back: an
8
+ * attribute built from a function or a `local.` reference arrives as a
9
+ * `"${...}"` string and is treated as not determined, never as a finding.
10
+ */
11
+
12
+ import { isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
13
+ import type { BlockBody } from "../../hcl/parse";
14
+ import { classifyModuleSource } from "./module-source";
15
+
16
+ /** Entities of one Terragrunt type, with their keys, in map order. */
17
+ export function terragruntEntities(
18
+ entities: ReadonlyMap<string, Declarable>,
19
+ entityType: string,
20
+ ): Array<{ key: string; address: string; file: string; body: BlockBody }> {
21
+ const out: Array<{ key: string; address: string; file: string; body: BlockBody }> = [];
22
+ for (const [key, entity] of entities) {
23
+ if (entity.entityType !== entityType || !isResourceDeclarable(entity)) continue;
24
+ const props = entity.props as { address?: unknown; file?: unknown; body?: unknown };
25
+ out.push({
26
+ key,
27
+ address: typeof props.address === "string" ? props.address : key,
28
+ file: typeof props.file === "string" ? props.file : "terragrunt.hcl",
29
+ body: (typeof props.body === "object" && props.body !== null ? props.body : {}) as BlockBody,
30
+ });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** An HCL expression hcl2json left as `"${...}"`: its value is not known without evaluating it. */
36
+ export function isExpression(value: unknown): boolean {
37
+ return typeof value === "string" && value.includes("${");
38
+ }
39
+
40
+ export type TerragruntSourceKind = "local" | "git" | "tfr" | "oci" | "other";
41
+
42
+ export interface TerragruntSourceClassification {
43
+ kind: TerragruntSourceKind;
44
+ /** Whether the source names a version: `?ref=` for git, `?version=` for `tfr://`, a tag or digest for `oci://`. Undefined for `local` and `other`. */
45
+ pinned?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Classify a unit's `terraform.source`. Git and `oci://` sources go through
50
+ * {@link classifyModuleSource}, the reader TF005 and TF038 use. `tfr://` is
51
+ * Terragrunt's own scheme for registry modules and takes its version from the
52
+ * `?version=` query argument. A source that holds an interpolation, or that
53
+ * names an absolute path or an archive URL, is `other` or `local` and is
54
+ * never reported.
55
+ */
56
+ export function classifyTerragruntSource(source: string): TerragruntSourceClassification {
57
+ const trimmed = source.trim();
58
+ if (trimmed.includes("${")) return { kind: "other" };
59
+ if (/^tfr:\/\//i.test(trimmed)) {
60
+ const query = trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : "";
61
+ const version = new URLSearchParams(query).get("version");
62
+ return { kind: "tfr", pinned: version !== null && version.trim() !== "" };
63
+ }
64
+ if (trimmed.startsWith("/")) return { kind: "local" };
65
+ const classified = classifyModuleSource(trimmed);
66
+ switch (classified.kind) {
67
+ case "local":
68
+ return { kind: "local" };
69
+ case "git":
70
+ return { kind: "git", pinned: classified.ref !== undefined };
71
+ case "oci":
72
+ return { kind: "oci", pinned: classified.tag !== undefined || classified.digest !== undefined };
73
+ default:
74
+ return { kind: "other" };
75
+ }
76
+ }
@@ -51,7 +51,7 @@ function providerNameFromType(type: string): string {
51
51
  }
52
52
 
53
53
  /** One implied provider name to the entity key that first implied it in file order. */
54
- function impliedProviders(entities: PostSynthContext["entities"], root: string): Map<string, string> {
54
+ export function impliedProviders(entities: PostSynthContext["entities"], root: string): Map<string, string> {
55
55
  const implied = new Map<string, string>();
56
56
  const fromProviderBlock = new Set<string>();
57
57
 
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { blocksToEntities } from "../../hcl/parse";
4
+ import { loadFixture } from "./fixtures/load";
5
+ import { tf038 } from "./tf038";
6
+
7
+ async function withSource(source: string): Promise<PostSynthContext> {
8
+ const hcl = `module "m" {\n source = ${JSON.stringify(source)}\n}\n`;
9
+ const entities = await blocksToEntities([{ name: "main.tf", source: hcl }], "app");
10
+ return { outputs: new Map(), entities } as unknown as PostSynthContext;
11
+ }
12
+
13
+ describe("TF038: OCI module source with no tag or digest, or a mutable tag", () => {
14
+ test("flags the documented positive fixture", async () => {
15
+ expect(tf038.check(await loadFixture("TF038", "positive"))).toHaveLength(1);
16
+ });
17
+
18
+ test("check metadata", () => {
19
+ expect(tf038.id).toBe("TF038");
20
+ });
21
+
22
+ test.each([
23
+ ["no tag or digest", "oci://example.com/org/mod"],
24
+ ["no tag, registry host with a port", "oci://registry.example.com:5000/org/mod"],
25
+ ["latest as a query tag", "oci://example.com/org/mod?tag=latest"],
26
+ ["latest as a suffix tag", "oci://example.com/org/mod:latest"],
27
+ ["no pin, with a subdirectory", "oci://example.com/org/mod//modules/vpc"],
28
+ ])("flags %s", async (_n, source) => {
29
+ const diags = tf038.check(await withSource(source));
30
+ expect(diags).toHaveLength(1);
31
+ expect(diags[0]).toMatchObject({ checkId: "TF038", severity: "warning", entity: "app/module.m", lexicon: "terraform" });
32
+ expect(diags[0].message).toContain(source);
33
+ });
34
+
35
+ test.each([
36
+ ["an exact tag", "oci://example.com/org/mod?tag=1.4.0"],
37
+ ["an exact suffix tag", "oci://example.com/org/mod:v1.4.0"],
38
+ ["a digest", "oci://example.com/org/mod?digest=sha256:abc123"],
39
+ ["a suffix digest", "oci://example.com/org/mod@sha256:abc123"],
40
+ ["a digest beside latest", "oci://example.com/org/mod:latest@sha256:abc123"],
41
+ ["a tag on a host with a port", "oci://registry.example.com:5000/org/mod:1.4.0"],
42
+ ])("passes %s", async (_n, source) => {
43
+ expect(tf038.check(await withSource(source))).toEqual([]);
44
+ });
45
+
46
+ test("ignores a registry, git and local source", async () => {
47
+ for (const s of ["terraform-aws-modules/vpc/aws", "git::https://example.com/vpc.git", "./modules/x"]) {
48
+ expect(tf038.check(await withSource(s))).toEqual([]);
49
+ }
50
+ });
51
+ });