@intentius/chant-lexicon-terraform 0.101.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 (374) 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/codegen/package.d.ts +1 -1
  10. package/dist/commands.d.ts +26 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/components/capability-plugin.d.ts +54 -0
  13. package/dist/components/capability-plugin.d.ts.map +1 -0
  14. package/dist/composites/terraform-pin-rollout-op.d.ts +44 -0
  15. package/dist/composites/terraform-pin-rollout-op.d.ts.map +1 -0
  16. package/dist/config.d.ts +3 -3
  17. package/dist/hcl/descend.d.ts +1 -1
  18. package/dist/hcl/descend.d.ts.map +1 -1
  19. package/dist/hcl/edges.d.ts +2 -2
  20. package/dist/hcl/edges.d.ts.map +1 -1
  21. package/dist/hcl/jsonencode.d.ts +106 -0
  22. package/dist/hcl/jsonencode.d.ts.map +1 -0
  23. package/dist/hcl/marker.d.ts.map +1 -1
  24. package/dist/hcl/parse.d.ts +29 -1
  25. package/dist/hcl/parse.d.ts.map +1 -1
  26. package/dist/hcl/roots.d.ts +1 -1
  27. package/dist/hcl/roots.d.ts.map +1 -1
  28. package/dist/hcl/suppressions.d.ts.map +1 -1
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/integrity.json +22 -4
  32. package/dist/lint/audit-catalog.d.ts.map +1 -1
  33. package/dist/lint/audit-lineage.d.ts +1 -1
  34. package/dist/lint/audit-lineage.d.ts.map +1 -1
  35. package/dist/lint/post-synth/flat-attributes.d.ts +69 -0
  36. package/dist/lint/post-synth/flat-attributes.d.ts.map +1 -0
  37. package/dist/lint/post-synth/index.d.ts.map +1 -1
  38. package/dist/lint/post-synth/module-source.d.ts +27 -1
  39. package/dist/lint/post-synth/module-source.d.ts.map +1 -1
  40. package/dist/lint/post-synth/terragrunt.d.ts +37 -0
  41. package/dist/lint/post-synth/terragrunt.d.ts.map +1 -0
  42. package/dist/lint/post-synth/tf002.d.ts +3 -1
  43. package/dist/lint/post-synth/tf002.d.ts.map +1 -1
  44. package/dist/lint/post-synth/tf030.d.ts +51 -0
  45. package/dist/lint/post-synth/tf030.d.ts.map +1 -0
  46. package/dist/lint/post-synth/tf031.d.ts +55 -0
  47. package/dist/lint/post-synth/tf031.d.ts.map +1 -0
  48. package/dist/lint/post-synth/tf032.d.ts +71 -0
  49. package/dist/lint/post-synth/tf032.d.ts.map +1 -0
  50. package/dist/lint/post-synth/tf033.d.ts +50 -0
  51. package/dist/lint/post-synth/tf033.d.ts.map +1 -0
  52. package/dist/lint/post-synth/tf034.d.ts +25 -0
  53. package/dist/lint/post-synth/tf034.d.ts.map +1 -0
  54. package/dist/lint/post-synth/tf035.d.ts +33 -0
  55. package/dist/lint/post-synth/tf035.d.ts.map +1 -0
  56. package/dist/lint/post-synth/tf036.d.ts +33 -0
  57. package/dist/lint/post-synth/tf036.d.ts.map +1 -0
  58. package/dist/lint/post-synth/tf037.d.ts +31 -0
  59. package/dist/lint/post-synth/tf037.d.ts.map +1 -0
  60. package/dist/lint/post-synth/tf038.d.ts +22 -0
  61. package/dist/lint/post-synth/tf038.d.ts.map +1 -0
  62. package/dist/lint/post-synth/tf039.d.ts +23 -0
  63. package/dist/lint/post-synth/tf039.d.ts.map +1 -0
  64. package/dist/lint/post-synth/tf040.d.ts +30 -0
  65. package/dist/lint/post-synth/tf040.d.ts.map +1 -0
  66. package/dist/lint/post-synth/tf041.d.ts +20 -0
  67. package/dist/lint/post-synth/tf041.d.ts.map +1 -0
  68. package/dist/lint/post-synth/tf042.d.ts +18 -0
  69. package/dist/lint/post-synth/tf042.d.ts.map +1 -0
  70. package/dist/lint/post-synth/tf043.d.ts +17 -0
  71. package/dist/lint/post-synth/tf043.d.ts.map +1 -0
  72. package/dist/lint/post-synth/tf044.d.ts +17 -0
  73. package/dist/lint/post-synth/tf044.d.ts.map +1 -0
  74. package/dist/lint/post-synth/tf045.d.ts +22 -0
  75. package/dist/lint/post-synth/tf045.d.ts.map +1 -0
  76. package/dist/manifest.json +1 -1
  77. package/dist/okf/index.md +16 -0
  78. package/dist/okf/rules/TF030.md +11 -0
  79. package/dist/okf/rules/TF031.md +11 -0
  80. package/dist/okf/rules/TF032.md +11 -0
  81. package/dist/okf/rules/TF033.md +11 -0
  82. package/dist/okf/rules/TF034.md +11 -0
  83. package/dist/okf/rules/TF035.md +11 -0
  84. package/dist/okf/rules/TF036.md +11 -0
  85. package/dist/okf/rules/TF037.md +11 -0
  86. package/dist/okf/rules/TF038.md +11 -0
  87. package/dist/okf/rules/TF039.md +11 -0
  88. package/dist/okf/rules/TF040.md +11 -0
  89. package/dist/okf/rules/TF041.md +11 -0
  90. package/dist/okf/rules/TF042.md +11 -0
  91. package/dist/okf/rules/TF043.md +11 -0
  92. package/dist/okf/rules/TF044.md +11 -0
  93. package/dist/okf/rules/TF045.md +11 -0
  94. package/dist/op/activities/index.d.ts +4 -0
  95. package/dist/op/activities/index.d.ts.map +1 -1
  96. package/dist/op/activities/pin-rollout.d.ts +46 -0
  97. package/dist/op/activities/pin-rollout.d.ts.map +1 -0
  98. package/dist/op/activities/terraform.d.ts +24 -50
  99. package/dist/op/activities/terraform.d.ts.map +1 -1
  100. package/dist/op/activity-contracts.d.ts +66 -18
  101. package/dist/op/activity-contracts.d.ts.map +1 -1
  102. package/dist/op/builders.d.ts +9 -0
  103. package/dist/op/builders.d.ts.map +1 -1
  104. package/dist/pin/edit-ts.d.ts +21 -0
  105. package/dist/pin/edit-ts.d.ts.map +1 -0
  106. package/dist/pin/edit.d.ts +52 -0
  107. package/dist/pin/edit.d.ts.map +1 -0
  108. package/dist/pin/forge.d.ts +39 -0
  109. package/dist/pin/forge.d.ts.map +1 -0
  110. package/dist/pin/index.d.ts +22 -0
  111. package/dist/pin/index.d.ts.map +1 -0
  112. package/dist/pin/rollout.d.ts +116 -0
  113. package/dist/pin/rollout.d.ts.map +1 -0
  114. package/dist/pin/scan.d.ts +54 -0
  115. package/dist/pin/scan.d.ts.map +1 -0
  116. package/dist/pin/source.d.ts +93 -0
  117. package/dist/pin/source.d.ts.map +1 -0
  118. package/dist/pin/waves.d.ts +66 -0
  119. package/dist/pin/waves.d.ts.map +1 -0
  120. package/dist/plan-digest.d.ts +59 -0
  121. package/dist/plan-digest.d.ts.map +1 -0
  122. package/dist/plugin.d.ts.map +1 -1
  123. package/dist/rules/flat-attributes.ts +146 -0
  124. package/dist/rules/module-source.ts +56 -1
  125. package/dist/rules/terragrunt.ts +76 -0
  126. package/dist/rules/tf002.ts +1 -1
  127. package/dist/rules/tf030.ts +290 -0
  128. package/dist/rules/tf031.ts +370 -0
  129. package/dist/rules/tf032.ts +226 -0
  130. package/dist/rules/tf033.ts +160 -0
  131. package/dist/rules/tf034.ts +63 -0
  132. package/dist/rules/tf035.ts +74 -0
  133. package/dist/rules/tf036.ts +119 -0
  134. package/dist/rules/tf037.ts +101 -0
  135. package/dist/rules/tf038.ts +71 -0
  136. package/dist/rules/tf039.ts +64 -0
  137. package/dist/rules/tf040.ts +76 -0
  138. package/dist/rules/tf041.ts +56 -0
  139. package/dist/rules/tf042.ts +42 -0
  140. package/dist/rules/tf043.ts +49 -0
  141. package/dist/rules/tf044.ts +51 -0
  142. package/dist/rules/tf045.ts +47 -0
  143. package/dist/terragrunt/affected.d.ts +119 -0
  144. package/dist/terragrunt/affected.d.ts.map +1 -0
  145. package/dist/terragrunt/index.d.ts +11 -0
  146. package/dist/terragrunt/index.d.ts.map +1 -0
  147. package/dist/terragrunt/mocks.d.ts +147 -0
  148. package/dist/terragrunt/mocks.d.ts.map +1 -0
  149. package/dist/terragrunt/run.d.ts +130 -0
  150. package/dist/terragrunt/run.d.ts.map +1 -0
  151. package/dist/terragrunt/units.d.ts +103 -0
  152. package/dist/terragrunt/units.d.ts.map +1 -0
  153. package/dist/terragrunt/wave.d.ts +89 -0
  154. package/dist/terragrunt/wave.d.ts.map +1 -0
  155. package/package.json +13 -3
  156. package/src/__fixtures__/change-set/choudoufu-set-plan.change-set.golden.json +95 -0
  157. package/src/__fixtures__/change-set/choudoufu-set-plan.json +76 -0
  158. package/src/__fixtures__/change-set/choudoufu.part.golden.json +173 -0
  159. package/src/__fixtures__/change-set/choudoufu.plan.json +610 -0
  160. package/src/__fixtures__/change-set/terraform.import-forget.plan.json +81 -0
  161. package/src/__fixtures__/change-set/terraform.part.golden.json +173 -0
  162. package/src/__fixtures__/change-set/terraform.plan.json +589 -0
  163. package/src/__fixtures__/change-set/tofu.part.golden.json +173 -0
  164. package/src/__fixtures__/change-set/tofu.plan.json +588 -0
  165. package/src/__fixtures__/change-set/v1.tf.txt +21 -0
  166. package/src/__fixtures__/change-set/v2-module-app.tf.txt +4 -0
  167. package/src/__fixtures__/change-set/v2.tf.txt +21 -0
  168. package/src/__fixtures__/pin/choudoufu-live-waves-n5.json +83 -0
  169. package/src/__fixtures__/pin/largeset-oci-n5/apply-order.txt +5 -0
  170. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/estate.chdf.hcl +6 -0
  171. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/main.tf +33 -0
  172. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/estate.chdf.hcl +6 -0
  173. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/main.tf +45 -0
  174. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/estate.chdf.hcl +6 -0
  175. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/main.tf +47 -0
  176. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/estate.chdf.hcl +6 -0
  177. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/main.tf +31 -0
  178. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/estate.chdf.hcl +6 -0
  179. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/main.tf +27 -0
  180. package/src/__fixtures__/pin/largeset-oci-n5/fixture.json +77 -0
  181. package/src/__fixtures__/plan-summary/largeset-n5-bump.json +2071 -0
  182. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.json +95 -0
  183. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.md +22 -0
  184. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.txt +12 -0
  185. package/src/__fixtures__/terragrunt/affected/catalog/units/web/terragrunt.hcl +3 -0
  186. package/src/__fixtures__/terragrunt/affected/gitignore +3 -0
  187. package/src/__fixtures__/terragrunt/affected/live/dev/app/terragrunt.hcl +3 -0
  188. package/src/__fixtures__/terragrunt/affected/live/dev/vpc/terragrunt.hcl +3 -0
  189. package/src/__fixtures__/terragrunt/affected/live/prod/app/terragrunt.hcl +3 -0
  190. package/src/__fixtures__/terragrunt/affected/live/stg/terragrunt.stack.hcl +5 -0
  191. package/src/__fixtures__/terragrunt/affected/modules/app/main.tf +6 -0
  192. package/src/__fixtures__/terragrunt/affected/modules/app/policy.json +1 -0
  193. package/src/__fixtures__/terragrunt/affected/modules/net/main.tf +2 -0
  194. package/src/__fixtures__/terragrunt/affected/modules/thing/main.tf +3 -0
  195. package/src/__fixtures__/terragrunt/affected/root.hcl +5 -0
  196. package/src/__fixtures__/terragrunt/affected-cases.ts +126 -0
  197. package/src/__fixtures__/terragrunt/five-units/catalog/units/thing/terragrunt.hcl +3 -0
  198. package/src/__fixtures__/terragrunt/five-units/live/dev/app/terragrunt.hcl +13 -0
  199. package/src/__fixtures__/terragrunt/five-units/live/dev/db/terragrunt.hcl +8 -0
  200. package/src/__fixtures__/terragrunt/five-units/live/dev/vpc/terragrunt.hcl +3 -0
  201. package/src/__fixtures__/terragrunt/five-units/live/prod/app/terragrunt.hcl +8 -0
  202. package/src/__fixtures__/terragrunt/five-units/live/prod/vpc/terragrunt.hcl +3 -0
  203. package/src/__fixtures__/terragrunt/five-units/modules/thing/main.tf +7 -0
  204. package/src/__fixtures__/terragrunt/five-units/root.hcl +5 -0
  205. package/src/__fixtures__/terragrunt/record.ts +56 -0
  206. package/src/__fixtures__/terragrunt/recorded/affected-find.json +50 -0
  207. package/src/__fixtures__/terragrunt/recorded/failed-plan-report.json +36 -0
  208. package/src/__fixtures__/terragrunt/recorded/find.json +67 -0
  209. package/src/__fixtures__/terragrunt/recorded/version.txt +1 -0
  210. package/src/__fixtures__/terragrunt/recorded/wave-1/apply-report.json +22 -0
  211. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/dev/vpc/tfplan.json +1 -0
  212. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/prod/vpc/tfplan.json +1 -0
  213. package/src/__fixtures__/terragrunt/recorded/wave-1/plan-report.json +22 -0
  214. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/dev/db/tfplan.json +1 -0
  215. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/prod/app/tfplan.json +1 -0
  216. package/src/__fixtures__/terragrunt/recorded/wave-2/plan-report.json +22 -0
  217. package/src/change-set-combined-run.test.ts +156 -0
  218. package/src/change-set.test.ts +151 -0
  219. package/src/change-set.ts +273 -0
  220. package/src/changed-roots.test.ts +58 -0
  221. package/src/changed-roots.ts +102 -0
  222. package/src/commands.ts +112 -0
  223. package/src/components/capability-plugin.test.ts +91 -0
  224. package/src/components/capability-plugin.ts +131 -0
  225. package/src/components/pr-loop.tofu.test.ts +244 -0
  226. package/src/composites/terraform-pin-rollout-op.test.ts +69 -0
  227. package/src/composites/terraform-pin-rollout-op.ts +60 -0
  228. package/src/hcl/descend.ts +1 -1
  229. package/src/hcl/edges.ts +2 -2
  230. package/src/hcl/jsonencode.test.ts +226 -0
  231. package/src/hcl/jsonencode.ts +454 -0
  232. package/src/hcl/parse.test.ts +25 -0
  233. package/src/hcl/parse.ts +91 -6
  234. package/src/hcl/roots.ts +1 -1
  235. package/src/hcl/suppressions.ts +8 -2
  236. package/src/index.ts +18 -0
  237. package/src/lint/audit-catalog.ts +230 -0
  238. package/src/lint/audit-lineage.ts +155 -1
  239. package/src/lint/audit.test.ts +3 -1
  240. package/src/lint/post-synth/estate-2284.test.ts +68 -0
  241. package/src/lint/post-synth/fixtures/TF030/negative-rules.tf +45 -0
  242. package/src/lint/post-synth/fixtures/TF030/negative.tf +55 -0
  243. package/src/lint/post-synth/fixtures/TF030/not-determined-port.tf +28 -0
  244. package/src/lint/post-synth/fixtures/TF030/not-determined-standalone.tf +32 -0
  245. package/src/lint/post-synth/fixtures/TF030/not-determined-vpc-rule.tf +21 -0
  246. package/src/lint/post-synth/fixtures/TF030/not-determined.tf +39 -0
  247. package/src/lint/post-synth/fixtures/TF030/positive-ipv6.tf +44 -0
  248. package/src/lint/post-synth/fixtures/TF030/positive-mixed.tf +36 -0
  249. package/src/lint/post-synth/fixtures/TF030/positive-standalone.tf +25 -0
  250. package/src/lint/post-synth/fixtures/TF030/positive.tf +43 -0
  251. package/src/lint/post-synth/fixtures/TF031/negative.tf +89 -0
  252. package/src/lint/post-synth/fixtures/TF031/not-determined.tf +139 -0
  253. package/src/lint/post-synth/fixtures/TF031/positive-data-source.tf +57 -0
  254. package/src/lint/post-synth/fixtures/TF031/positive-heredoc.tf +149 -0
  255. package/src/lint/post-synth/fixtures/TF031/positive-jsonencode.tf +115 -0
  256. package/src/lint/post-synth/fixtures/TF032/negative-heredoc.tf +20 -0
  257. package/src/lint/post-synth/fixtures/TF032/negative-references.tf +214 -0
  258. package/src/lint/post-synth/fixtures/TF032/negative.tf +99 -0
  259. package/src/lint/post-synth/fixtures/TF032/positive-heredoc.tf +22 -0
  260. package/src/lint/post-synth/fixtures/TF032/positive-sidecar.tf +197 -0
  261. package/src/lint/post-synth/fixtures/TF032/positive.tf +54 -0
  262. package/src/lint/post-synth/fixtures/TF033/negative.tf +39 -0
  263. package/src/lint/post-synth/fixtures/TF033/not-determined-aurora.tf +25 -0
  264. package/src/lint/post-synth/fixtures/TF033/positive-absent.tf +93 -0
  265. package/src/lint/post-synth/fixtures/TF033/positive.tf +50 -0
  266. package/src/lint/post-synth/fixtures/TF034/negative.tf +24 -0
  267. package/src/lint/post-synth/fixtures/TF034/positive-absent.tf +36 -0
  268. package/src/lint/post-synth/fixtures/TF034/positive.tf +14 -0
  269. package/src/lint/post-synth/fixtures/TF035/negative-absent.tf +43 -0
  270. package/src/lint/post-synth/fixtures/TF035/negative.tf +54 -0
  271. package/src/lint/post-synth/fixtures/TF035/positive.tf +37 -0
  272. package/src/lint/post-synth/fixtures/TF036/negative.tf +61 -0
  273. package/src/lint/post-synth/fixtures/TF036/not-determined.tf +28 -0
  274. package/src/lint/post-synth/fixtures/TF036/positive.tf +38 -0
  275. package/src/lint/post-synth/fixtures/TF037/negative-exclusion.tf +63 -0
  276. package/src/lint/post-synth/fixtures/TF037/negative.tf +27 -0
  277. package/src/lint/post-synth/fixtures/TF037/not-determined.tf +27 -0
  278. package/src/lint/post-synth/fixtures/TF037/positive-absent.tf +15 -0
  279. package/src/lint/post-synth/fixtures/TF037/positive.tf +26 -0
  280. package/src/lint/post-synth/fixtures/TF038/positive.tf +3 -0
  281. package/src/lint/post-synth/fixtures/TF039/positive.tf +4 -0
  282. package/src/lint/post-synth/fixtures/TF041/negative/terragrunt.hcl +12 -0
  283. package/src/lint/post-synth/fixtures/TF041/positive/terragrunt.hcl +7 -0
  284. package/src/lint/post-synth/fixtures/TF041/positive-apply/terragrunt.hcl +8 -0
  285. package/src/lint/post-synth/fixtures/TF042/negative/terragrunt.hcl +13 -0
  286. package/src/lint/post-synth/fixtures/TF042/positive/terragrunt.hcl +9 -0
  287. package/src/lint/post-synth/fixtures/TF043/negative/terragrunt.hcl +3 -0
  288. package/src/lint/post-synth/fixtures/TF043/negative-other/terragrunt.hcl +3 -0
  289. package/src/lint/post-synth/fixtures/TF043/positive-git/terragrunt.hcl +3 -0
  290. package/src/lint/post-synth/fixtures/TF043/positive-oci/terragrunt.hcl +3 -0
  291. package/src/lint/post-synth/fixtures/TF043/positive-tfr/terragrunt.hcl +3 -0
  292. package/src/lint/post-synth/fixtures/TF044/negative/terragrunt.hcl +9 -0
  293. package/src/lint/post-synth/fixtures/TF044/positive/terragrunt.hcl +7 -0
  294. package/src/lint/post-synth/fixtures/TF044/positive-generate/terragrunt.hcl +9 -0
  295. package/src/lint/post-synth/fixtures/TF045/negative/root.hcl +1 -0
  296. package/src/lint/post-synth/fixtures/TF045/negative/terragrunt.hcl +3 -0
  297. package/src/lint/post-synth/fixtures/TF045/positive/root.hcl +9 -0
  298. package/src/lint/post-synth/fixtures/TF045/positive/terragrunt.hcl +3 -0
  299. package/src/lint/post-synth/fixtures/TF045/unit-only/terragrunt.hcl +3 -0
  300. package/src/lint/post-synth/fixtures/estate-2284/clean/main.tf +132 -0
  301. package/src/lint/post-synth/fixtures/estate-2284/violating/main.tf +112 -0
  302. package/src/lint/post-synth/flat-attributes.ts +146 -0
  303. package/src/lint/post-synth/index.ts +32 -0
  304. package/src/lint/post-synth/module-source.test.ts +42 -1
  305. package/src/lint/post-synth/module-source.ts +56 -1
  306. package/src/lint/post-synth/terragrunt.ts +76 -0
  307. package/src/lint/post-synth/tf002.ts +1 -1
  308. package/src/lint/post-synth/tf030.test.ts +219 -0
  309. package/src/lint/post-synth/tf030.ts +290 -0
  310. package/src/lint/post-synth/tf031.test.ts +229 -0
  311. package/src/lint/post-synth/tf031.ts +370 -0
  312. package/src/lint/post-synth/tf032.test.ts +127 -0
  313. package/src/lint/post-synth/tf032.ts +226 -0
  314. package/src/lint/post-synth/tf033.test.ts +126 -0
  315. package/src/lint/post-synth/tf033.ts +160 -0
  316. package/src/lint/post-synth/tf034.test.ts +71 -0
  317. package/src/lint/post-synth/tf034.ts +63 -0
  318. package/src/lint/post-synth/tf035.test.ts +68 -0
  319. package/src/lint/post-synth/tf035.ts +74 -0
  320. package/src/lint/post-synth/tf036.test.ts +87 -0
  321. package/src/lint/post-synth/tf036.ts +119 -0
  322. package/src/lint/post-synth/tf037.test.ts +88 -0
  323. package/src/lint/post-synth/tf037.ts +101 -0
  324. package/src/lint/post-synth/tf038.test.ts +51 -0
  325. package/src/lint/post-synth/tf038.ts +71 -0
  326. package/src/lint/post-synth/tf039.test.ts +40 -0
  327. package/src/lint/post-synth/tf039.ts +64 -0
  328. package/src/lint/post-synth/tf040.test.ts +55 -0
  329. package/src/lint/post-synth/tf040.ts +76 -0
  330. package/src/lint/post-synth/tf041.test.ts +50 -0
  331. package/src/lint/post-synth/tf041.ts +56 -0
  332. package/src/lint/post-synth/tf042.test.ts +19 -0
  333. package/src/lint/post-synth/tf042.ts +42 -0
  334. package/src/lint/post-synth/tf043.test.ts +65 -0
  335. package/src/lint/post-synth/tf043.ts +49 -0
  336. package/src/lint/post-synth/tf044.test.ts +27 -0
  337. package/src/lint/post-synth/tf044.ts +51 -0
  338. package/src/lint/post-synth/tf045.test.ts +30 -0
  339. package/src/lint/post-synth/tf045.ts +47 -0
  340. package/src/op/activities/index.ts +4 -0
  341. package/src/op/activities/pin-rollout.ts +77 -0
  342. package/src/op/activities/terraform.test.ts +13 -0
  343. package/src/op/activities/terraform.ts +46 -86
  344. package/src/op/activity-contracts.test.ts +4 -1
  345. package/src/op/activity-contracts.ts +42 -0
  346. package/src/op/builders.ts +13 -0
  347. package/src/pin/bundle.test.ts +53 -0
  348. package/src/pin/edit-ts.test.ts +39 -0
  349. package/src/pin/edit-ts.ts +77 -0
  350. package/src/pin/edit.test.ts +199 -0
  351. package/src/pin/edit.ts +157 -0
  352. package/src/pin/forge.ts +85 -0
  353. package/src/pin/index.ts +22 -0
  354. package/src/pin/rollout.test.ts +232 -0
  355. package/src/pin/rollout.ts +451 -0
  356. package/src/pin/scan.ts +309 -0
  357. package/src/pin/source.ts +206 -0
  358. package/src/pin/waves.test.ts +101 -0
  359. package/src/pin/waves.ts +125 -0
  360. package/src/plan-digest.ts +99 -0
  361. package/src/plan-summary.test.ts +154 -0
  362. package/src/plugin.ts +25 -2
  363. package/src/terragrunt/affected.acceptance.test.ts +83 -0
  364. package/src/terragrunt/affected.test.ts +177 -0
  365. package/src/terragrunt/affected.ts +348 -0
  366. package/src/terragrunt/index.ts +11 -0
  367. package/src/terragrunt/mocks.acceptance.test.ts +105 -0
  368. package/src/terragrunt/mocks.test.ts +309 -0
  369. package/src/terragrunt/mocks.ts +324 -0
  370. package/src/terragrunt/run.ts +369 -0
  371. package/src/terragrunt/terragrunt.acceptance.test.ts +122 -0
  372. package/src/terragrunt/terragrunt.test.ts +332 -0
  373. package/src/terragrunt/units.ts +235 -0
  374. package/src/terragrunt/wave.ts +181 -0
@@ -0,0 +1,22 @@
1
+ [
2
+ {
3
+ "Started": "2026-10-03T21:52:46.408006-06:00",
4
+ "Ended": "2026-10-03T21:52:46.541637-06:00",
5
+ "Name": "live/dev/vpc",
6
+ "Result": "succeeded",
7
+ "Cmd": "plan",
8
+ "Args": [
9
+ "-no-color"
10
+ ]
11
+ },
12
+ {
13
+ "Started": "2026-10-03T21:52:46.408024-06:00",
14
+ "Ended": "2026-10-03T21:52:46.53842-06:00",
15
+ "Name": "live/prod/vpc",
16
+ "Result": "succeeded",
17
+ "Cmd": "plan",
18
+ "Args": [
19
+ "-no-color"
20
+ ]
21
+ }
22
+ ]
@@ -0,0 +1 @@
1
+ {"format_version":"1.2","terraform_version":"1.12.5","variables":{"name":{"value":"live-dev-db"},"upstream":{"value":["live-dev-vpc-id"]}},"planned_values":{"outputs":{"id":{"sensitive":false,"type":"string","value":"live-dev-db-id"}},"root_module":{"resources":[{"address":"terraform_data.this","mode":"managed","type":"terraform_data","name":"this","provider_name":"terraform.io/builtin/terraform","schema_version":0,"values":{"input":{"name":"live-dev-db","upstream":["live-dev-vpc-id"]},"triggers_replace":null},"sensitive_values":{"input":{"upstream":[false]},"output":{}}}]}},"resource_changes":[{"address":"terraform_data.this","mode":"managed","type":"terraform_data","name":"this","provider_name":"terraform.io/builtin/terraform","change":{"actions":["create"],"before":null,"after":{"input":{"name":"live-dev-db","upstream":["live-dev-vpc-id"]},"triggers_replace":null},"after_unknown":{"id":true,"input":{"upstream":[false]},"output":true},"before_sensitive":false,"after_sensitive":{"input":{"upstream":[false]},"output":{}}}}],"output_changes":{"id":{"actions":["create"],"before":null,"after":"live-dev-db-id","after_unknown":false,"before_sensitive":false,"after_sensitive":false}},"prior_state":{"format_version":"1.0","terraform_version":"1.12.5","values":{"outputs":{"id":{"sensitive":false,"value":"live-dev-db-id","type":"string"}},"root_module":{}}},"configuration":{"provider_config":{"terraform":{"name":"terraform","full_name":"terraform.io/builtin/terraform"}},"root_module":{"outputs":{"id":{"expression":{"references":["var.name"]}}},"resources":[{"address":"terraform_data.this","mode":"managed","type":"terraform_data","name":"this","provider_config_key":"terraform","expressions":{"input":{"references":["var.name","var.upstream"]}},"schema_version":0}],"variables":{"name":{"type":"string","required":true},"upstream":{"type":["list","string"],"default":[]}}}},"timestamp":"2026-10-04T03:52:47Z","errored":false}
@@ -0,0 +1 @@
1
+ {"format_version":"1.2","terraform_version":"1.12.5","variables":{"name":{"value":"live-prod-app"},"upstream":{"value":["live-prod-vpc-id"]}},"planned_values":{"outputs":{"id":{"sensitive":false,"type":"string","value":"live-prod-app-id"}},"root_module":{"resources":[{"address":"terraform_data.this","mode":"managed","type":"terraform_data","name":"this","provider_name":"terraform.io/builtin/terraform","schema_version":0,"values":{"input":{"name":"live-prod-app","upstream":["live-prod-vpc-id"]},"triggers_replace":null},"sensitive_values":{"input":{"upstream":[false]},"output":{}}}]}},"resource_changes":[{"address":"terraform_data.this","mode":"managed","type":"terraform_data","name":"this","provider_name":"terraform.io/builtin/terraform","change":{"actions":["create"],"before":null,"after":{"input":{"name":"live-prod-app","upstream":["live-prod-vpc-id"]},"triggers_replace":null},"after_unknown":{"id":true,"input":{"upstream":[false]},"output":true},"before_sensitive":false,"after_sensitive":{"input":{"upstream":[false]},"output":{}}}}],"output_changes":{"id":{"actions":["create"],"before":null,"after":"live-prod-app-id","after_unknown":false,"before_sensitive":false,"after_sensitive":false}},"prior_state":{"format_version":"1.0","terraform_version":"1.12.5","values":{"outputs":{"id":{"sensitive":false,"value":"live-prod-app-id","type":"string"}},"root_module":{}}},"configuration":{"provider_config":{"terraform":{"name":"terraform","full_name":"terraform.io/builtin/terraform"}},"root_module":{"outputs":{"id":{"expression":{"references":["var.name"]}}},"resources":[{"address":"terraform_data.this","mode":"managed","type":"terraform_data","name":"this","provider_config_key":"terraform","expressions":{"input":{"references":["var.name","var.upstream"]}},"schema_version":0}],"variables":{"name":{"type":"string","required":true},"upstream":{"type":["list","string"],"default":[]}}}},"timestamp":"2026-10-04T03:52:47Z","errored":false}
@@ -0,0 +1,22 @@
1
+ [
2
+ {
3
+ "Started": "2026-10-03T21:52:47.031211-06:00",
4
+ "Ended": "2026-10-03T21:52:47.180184-06:00",
5
+ "Name": "live/dev/db",
6
+ "Result": "succeeded",
7
+ "Cmd": "plan",
8
+ "Args": [
9
+ "-no-color"
10
+ ]
11
+ },
12
+ {
13
+ "Started": "2026-10-03T21:52:47.031218-06:00",
14
+ "Ended": "2026-10-03T21:52:47.199669-06:00",
15
+ "Name": "live/prod/app",
16
+ "Result": "succeeded",
17
+ "Cmd": "plan",
18
+ "Args": [
19
+ "-no-color"
20
+ ]
21
+ }
22
+ ]
@@ -0,0 +1,156 @@
1
+ /**
2
+ * One change set for a combined run, and the gate binds its digest (#3181).
3
+ *
4
+ * The workspace has three members: `delivery`, a chant project on the aws
5
+ * lexicon; `estate`, a choudoufu estate; and `warden`, a github-warden whose
6
+ * plan is a reconcile change set in a file. One Op plans all three, joins
7
+ * them with `composeChangeSet`, and gates the apply on the document's digest.
8
+ *
9
+ * The plans are the real fixtures the adapter tests pin: chant's `lifecycle
10
+ * plan --json` against floci (`packages/core/src/__fixtures__/change-set`),
11
+ * choudoufu 0.16.0's `show -json` and the warden's reconcile change set. The
12
+ * two planner activities that need a cloud are stand-ins returning what the
13
+ * real ones return for those plans. `composeChangeSet` and
14
+ * `readChangeSetPart` are the real activities. The scenario is #2300's on a
15
+ * set: approve, change one member's plan, re-run, and the run stops naming
16
+ * both digests.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { describe, expect, test } from "vitest";
22
+ import { lifecyclePlanPart, type ChangeSetDocument } from "@intentius/chant/change-set";
23
+ import type { ChangeSet as LifecycleChangeSet } from "@intentius/chant/lifecycle/change-set";
24
+ import type { GateResolutionRecord, PendingGateRecord } from "@intentius/chant/lifecycle/gate-ledger";
25
+ import {
26
+ composeChangeSet,
27
+ gate,
28
+ lifecyclePlanChangeSet,
29
+ readChangeSetPart,
30
+ runOpLocally,
31
+ stepOutput,
32
+ type ActivityProfile,
33
+ type GateLedgerPort,
34
+ type OpConfig,
35
+ } from "@intentius/chant/op";
36
+ import * as coreActivities from "@intentius/chant/op/activities";
37
+ import { terraformChangeSetPart } from "./change-set";
38
+ import { terraformPlan } from "./op/builders";
39
+
40
+ const REPO = join(import.meta.dirname, "..", "..", "..");
41
+ const CORE_FIXTURES = join(REPO, "packages", "core", "src", "__fixtures__", "change-set");
42
+ const TF_FIXTURES = join(import.meta.dirname, "__fixtures__", "change-set");
43
+ const PROFILES: Record<string, ActivityProfile> = {
44
+ longInfra: { timeout: "5m", retry: { maximumAttempts: 1 } },
45
+ fastIdempotent: { timeout: "5m", retry: { maximumAttempts: 1 } },
46
+ };
47
+
48
+ /** `chant approve` between two runs: a resolution the next run reads. */
49
+ function approvableLedger(): GateLedgerPort & { approve(planDigest: string, at: string): void } {
50
+ const resolutions: GateResolutionRecord[] = [];
51
+ const pending: PendingGateRecord[] = [];
52
+ return {
53
+ approve(planDigest, at) {
54
+ resolutions.push({ version: 1, op: "combined-apply", gate: "approve-run", resolvedBy: "alex", timestamp: at, planDigest });
55
+ },
56
+ async read() {
57
+ return { resolutions: [...resolutions], pending: [...pending] };
58
+ },
59
+ async appendPending(input) {
60
+ const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
61
+ pending.push(record);
62
+ return { record, pushed: true };
63
+ },
64
+ };
65
+ }
66
+
67
+ function harness() {
68
+ const estatePlan = JSON.parse(readFileSync(join(TF_FIXTURES, "choudoufu.plan.json"), "utf-8")) as {
69
+ resource_changes: Array<{ address: string; change: { after: Record<string, unknown> | null } }>;
70
+ };
71
+ const deliveryPlan = JSON.parse(readFileSync(join(CORE_FIXTURES, "lifecycle-plan.floci.json"), "utf-8")) as LifecycleChangeSet;
72
+ const applied: string[] = [];
73
+ const documents: ChangeSetDocument[] = [];
74
+
75
+ const activities = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>([
76
+ ["composeChangeSet", async (args) => {
77
+ const out = await coreActivities.composeChangeSet(args as never);
78
+ documents.push(out.document);
79
+ return out;
80
+ }],
81
+ ["readChangeSetPart", coreActivities.readChangeSetPart as never],
82
+ // Stand-ins for the two planners that need a cloud, returning what the real activities return.
83
+ ["terraformPlan", async (args) => ({
84
+ planFile: "chant.tfplan",
85
+ changeSet: terraformChangeSetPart({ member: String(args.root), plan: estatePlan, planner: "choudoufu", scope: "est-estate" }),
86
+ })],
87
+ ["lifecyclePlanChangeSet", async (args) => ({ part: lifecyclePlanPart({ member: String(args.member), plan: deliveryPlan }) })],
88
+ ["applyAll", async () => { applied.push("applied"); return {}; }],
89
+ ]);
90
+
91
+ const estate = terraformPlan("estate", { id: "estate" });
92
+ const delivery = lifecyclePlanChangeSet({ member: "delivery", env: "dev", id: "delivery" });
93
+ const warden = readChangeSetPart({ member: "warden", planner: "warden", file: join(CORE_FIXTURES, "warden.plan.json"), id: "warden" });
94
+ const changeSet = composeChangeSet({
95
+ parts: [estate.out.changeSet, delivery.out.part, warden.out.part],
96
+ id: "change-set",
97
+ });
98
+ const config: OpConfig = {
99
+ name: "combined-apply",
100
+ overview: "",
101
+ phases: [
102
+ { name: "Plan", steps: [estate, delivery, warden, changeSet] },
103
+ { name: "Approve", steps: [gate("approve-run", { plan: stepOutput("change-set", "digest") })] },
104
+ { name: "Apply", steps: [{ kind: "activity", fn: "applyAll", args: {} }] },
105
+ ],
106
+ };
107
+ /** The document the latest run composed. */
108
+ const latest = (): ChangeSetDocument => documents[documents.length - 1];
109
+ return { estatePlan, applied, activities, config, latest };
110
+ }
111
+
112
+ describe("a combined run over a chant member, a choudoufu member and a warden (#3181)", () => {
113
+ test("one change set, and the gate records its digest", async () => {
114
+ const { activities, config, applied, latest } = harness();
115
+ const gates = approvableLedger();
116
+ const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-03T12:00:00.000Z" });
117
+ expect(first.status).toBe("gated");
118
+ expect(applied).toEqual([]);
119
+
120
+ const doc = latest();
121
+ expect(doc.members.map((m) => [m.member, m.planner])).toEqual([["delivery", "chant"], ["estate", "choudoufu"], ["warden", "warden"]]);
122
+ expect(doc.summary.deletes.map((d) => `${d.member}:${d.address}`)).toEqual(["estate:terraform_data.old", "warden:team.contractors"]);
123
+ expect(first.gate?.planDigest).toBe(doc.digest);
124
+ });
125
+
126
+ test("approve the digest, re-run unchanged, and it applies", async () => {
127
+ const { activities, config, applied, latest } = harness();
128
+ const gates = approvableLedger();
129
+ await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-03T12:00:00.000Z" });
130
+ gates.approve(latest().digest, "2026-10-03T12:05:00.000Z");
131
+ const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-03T12:10:00.000Z" });
132
+ expect(second.status).toBe("ok");
133
+ expect(applied).toEqual(["applied"]);
134
+ });
135
+
136
+ test("approve, change the choudoufu member's plan, re-run: the run stops, naming both digests", async () => {
137
+ const { estatePlan, activities, config, applied, latest } = harness();
138
+ const gates = approvableLedger();
139
+ await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-03T12:00:00.000Z" });
140
+ const approved = latest().digest;
141
+ gates.approve(approved, "2026-10-03T12:05:00.000Z");
142
+
143
+ // The estate's config input moves from "large" to "xlarge": one value in one member.
144
+ const config2 = estatePlan.resource_changes.find((r) => r.address === "terraform_data.config")!;
145
+ config2.change.after = { ...config2.change.after, input: { size: "xlarge", tags: { team: "core" } } };
146
+
147
+ const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-03T12:10:00.000Z" });
148
+ const planned = latest().digest;
149
+ expect(planned).not.toBe(approved);
150
+ expect(second.status).toBe("gated");
151
+ expect(applied).toEqual([]);
152
+ const refusal = second.records.find((r) => r.fn === "gate:approve-run")?.refusal;
153
+ expect(refusal).toContain(`approved: ${approved}`);
154
+ expect(refusal).toContain(`planned: ${planned}`);
155
+ });
156
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The terraform-family change-set adapters (#3181), against real plans.
3
+ *
4
+ * `__fixtures__/change-set/{terraform,tofu,choudoufu}.plan.json` are `show
5
+ * -json` output from terraform 1.15.8, OpenTofu 1.12.5 and choudoufu 0.16.0:
6
+ * `v1.tf.txt` applied, then `v2.tf.txt` (with `v2-module-app.tf.txt` as
7
+ * `modules/app/main.tf`) planned. The plan has a no-op, an in-place update, an
8
+ * update of a sensitive value, a delete-then-create replacement, a delete and
9
+ * two creates under a module with `count`. `choudoufu-set-plan.json` is
10
+ * choudoufu's own golden for `live-plan-set -json`
11
+ * (`internal/live/setplan/testdata/document.golden.json` at choudoufu
12
+ * 0dc53834d5): two planned roots and one that failed at init.
13
+ *
14
+ * The `*.golden.json` files are what the adapters produce. Regenerate them
15
+ * with `UPDATE_GOLDEN=1`. `packages/core/src/change-set.test.ts` validates
16
+ * them against the change-set schema.
17
+ */
18
+
19
+ import { readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { describe, expect, test } from "vitest";
22
+ import { composeChangeSet, type ChangeSetPart } from "@intentius/chant/change-set";
23
+ import { actionFor, choudoufuSetPlanParts, parseTerraformAddress, plannerForBinary, terraformChangeSetPart } from "./change-set";
24
+ import { terraformPlanDigest } from "./plan-digest";
25
+
26
+ const DIR = join(import.meta.dirname, "__fixtures__", "change-set");
27
+ const read = (name: string): unknown => JSON.parse(readFileSync(join(DIR, name), "utf-8"));
28
+
29
+ function golden(name: string, value: unknown): void {
30
+ const path = join(DIR, name);
31
+ const text = JSON.stringify(value, null, 2) + "\n";
32
+ if (process.env.UPDATE_GOLDEN) writeFileSync(path, text);
33
+ expect(text).toBe(readFileSync(path, "utf-8"));
34
+ }
35
+
36
+ describe("terraformChangeSetPart, on real plans", () => {
37
+ test.each(["terraform", "tofu", "choudoufu"] as const)("%s show -json matches its golden part", (planner) => {
38
+ const plan = read(`${planner}.plan.json`);
39
+ const part = terraformChangeSetPart({ member: "estate", plan, planner });
40
+ golden(`${planner}.part.golden.json`, part);
41
+ expect(part.member.planDigest).toBe(terraformPlanDigest(plan));
42
+ });
43
+
44
+ test("the three binaries' plans project to the same entries", () => {
45
+ const entries = (planner: "terraform" | "tofu" | "choudoufu") =>
46
+ terraformChangeSetPart({ member: "estate", plan: read(`${planner}.plan.json`), planner }).entries.map((e) => ({ ...e, planner: undefined, attributes: e.attributes.map((a) => a.path) }));
47
+ expect(entries("tofu")).toEqual(entries("terraform"));
48
+ expect(entries("choudoufu")).toEqual(entries("terraform"));
49
+ });
50
+
51
+ test("actions, disruption, module and index, and a sensitive value left out", () => {
52
+ const { entries } = terraformChangeSetPart({ member: "estate", plan: read("tofu.plan.json"), planner: "tofu" });
53
+ const at = (address: string) => entries.find((e) => e.address === address)!;
54
+ expect(at("terraform_data.keep")).toMatchObject({ action: "no-op", attributes: [] });
55
+ expect(at("terraform_data.old")).toMatchObject({ action: "delete", disruption: "destroy" });
56
+ expect(at("terraform_data.worker")).toMatchObject({ action: "replace", disruption: "destroy" });
57
+ expect(at("terraform_data.worker").attributes.find((a) => a.path === "triggers_replace")).toEqual({
58
+ path: "triggers_replace", before: ["build-1"], after: ["build-2"], forcesReplacement: true,
59
+ });
60
+ expect(at("terraform_data.config")).toMatchObject({ action: "update", disruption: "in-place" });
61
+ expect(at("terraform_data.config").attributes.find((a) => a.path === "input")).toEqual({
62
+ path: "input", before: { size: "small", tags: { team: "core" } }, after: { size: "large", tags: { team: "core" } },
63
+ });
64
+ const secret = at("terraform_data.secret").attributes.find((a) => a.path === "input")!;
65
+ expect(secret).toEqual({ path: "input", sensitive: true });
66
+ expect(JSON.stringify(secret)).not.toContain("s3cr3t");
67
+ // Redaction follows the planner's marks. All three binaries leave
68
+ // terraform_data's `output` unmarked in before_sensitive, so its old value
69
+ // is carried, as it is in the plan JSON itself.
70
+ expect(at("terraform_data.secret").attributes.find((a) => a.path === "output")).toEqual({ path: "output", before: "s3cr3t-1", unknown: true });
71
+ expect(at("module.app.terraform_data.instance[1]")).toMatchObject({ action: "create", module: "module.app", name: "instance", index: 1 });
72
+ });
73
+ });
74
+
75
+ describe("imports, forgets and triggered actions", () => {
76
+ // terraform 1.15.8 show -json: a `removed` block with destroy = false (action
77
+ // "forget"), an `import` block (change.importing, action "update") and a
78
+ // create. The plan has no field named for a forget beyond its action.
79
+ const plan = read("terraform.import-forget.plan.json") as { resource_changes: unknown[] };
80
+
81
+ test("a forget keeps the action forget and an import is flagged beside its own action", () => {
82
+ const { entries } = terraformChangeSetPart({ member: "root", plan });
83
+ const at = (address: string) => entries.find((e) => e.address === address)!;
84
+ expect(at("terraform_data.a")).toMatchObject({ action: "forget", attributes: [] });
85
+ expect(at("terraform_data.a").importing).toBeUndefined();
86
+ expect(at("terraform_data.b")).toMatchObject({ action: "update", importing: true });
87
+ expect(at("terraform_data.c").importing).toBeUndefined();
88
+ });
89
+
90
+ test("action_invocations become side effects, and bind the plan digest only when present", () => {
91
+ const invocation = { address: "action.aws_lambda_invoke.notify", type: "aws_lambda_invoke", lifecycle_action_trigger: { triggering_resource_address: "aws_lambda_function.worker", action_trigger_event: "after_update", actions_block_index: 0, action_trigger_block_index: 0 } };
92
+ const withAction = { ...plan, action_invocations: [invocation] };
93
+ const part = terraformChangeSetPart({ member: "root", plan: withAction });
94
+ expect(part.sideEffects).toEqual([{ address: "action.aws_lambda_invoke.notify", type: "aws_lambda_invoke", trigger: "aws_lambda_function.worker", event: "after_update" }]);
95
+ expect(terraformChangeSetPart({ member: "root", plan }).sideEffects).toBeUndefined();
96
+ expect(terraformPlanDigest(withAction)).not.toBe(terraformPlanDigest(plan));
97
+ expect(terraformPlanDigest({ ...plan, action_invocations: [] })).toBe(terraformPlanDigest(plan));
98
+ expect(composeChangeSet([part]).sideEffects).toEqual([{ member: "root", ...part.sideEffects![0] }]);
99
+ });
100
+ });
101
+
102
+ describe("choudoufuSetPlanParts, on choudoufu's golden set plan", () => {
103
+ test("one part per root, the failed root a failed member, and the golden document", () => {
104
+ const parts = choudoufuSetPlanParts({ document: read("choudoufu-set-plan.json") });
105
+ expect(parts.map((p) => [p.member.member, p.member.status, p.member.scope])).toEqual([
106
+ ["estates/e01", "planned", "est-e01"],
107
+ ["estates/e02", "planned", "est-e02"],
108
+ ["estates/e03", "failed", "est-e03"],
109
+ ]);
110
+ expect(parts[2].member).toMatchObject({ planDigest: null, error: "e03 broke at init on purpose" });
111
+ // choudoufu's own root digest rides along; the member's plan digest is chant's.
112
+ expect(parts[0].member.nativeDigest).toBe("sha256:bbe048fb0f7df99fcd65f00351d8b3c3be9b8a2a25a99a706b1c16d81277bb97");
113
+ // The golden plan carries only address and actions, so type and name come off the address.
114
+ expect(parts[0].entries[0]).toMatchObject({ address: "terraform_data.x", type: "terraform_data", name: "x", action: "create" });
115
+ const doc = composeChangeSet(parts);
116
+ expect(doc.summary.failed).toEqual(["estates/e03"]);
117
+ golden("choudoufu-set-plan.change-set.golden.json", doc);
118
+ });
119
+
120
+ test("memberFor names the workspace member a root belongs to", () => {
121
+ const parts: ChangeSetPart[] = choudoufuSetPlanParts({ document: read("choudoufu-set-plan.json"), memberFor: (root) => root.replace("estates/", "") });
122
+ expect(parts.map((p) => p.member.member)).toEqual(["e01", "e02", "e03"]);
123
+ });
124
+
125
+ test("a document without roots is refused", () => {
126
+ expect(() => choudoufuSetPlanParts({ document: { format_version: "1" } })).toThrow(/no top-level "roots"/);
127
+ });
128
+ });
129
+
130
+ describe("address and action parsing", () => {
131
+ test.each([
132
+ ["aws_s3_bucket.logs", { type: "aws_s3_bucket", name: "logs" }],
133
+ ["data.aws_iam_policy_document.p", { type: "aws_iam_policy_document", name: "p" }],
134
+ ["module.net.module.sub[2].aws_subnet.a[0]", { module: "module.net.module.sub[2]", type: "aws_subnet", name: "a", index: 0 }],
135
+ ['module.app["eu.west"].aws_instance.web["a.b"]', { module: 'module.app["eu.west"]', type: "aws_instance", name: "web", index: "a.b" }],
136
+ ])("%s", (address, expected) => {
137
+ expect(parseTerraformAddress(address)).toEqual(expected);
138
+ });
139
+
140
+ test("create-before-destroy is a replace with replace disruption, and an unknown action is refused", () => {
141
+ const plan = { resource_changes: [{ address: "a.b", type: "a", name: "b", change: { actions: ["create", "delete"], before: {}, after: {} } }] };
142
+ expect(terraformChangeSetPart({ member: "m", plan }).entries[0]).toMatchObject({ action: "replace", disruption: "replace" });
143
+ expect(() => actionFor(["teleport"])).toThrow(/no action/);
144
+ });
145
+
146
+ test("the planner a binary names", () => {
147
+ expect(plannerForBinary("/usr/local/bin/tofu")).toBe("tofu");
148
+ expect(plannerForBinary("choudoufu")).toBe("choudoufu");
149
+ expect(plannerForBinary(undefined)).toBe("terraform");
150
+ });
151
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Change-set adapters for the terraform family (#3181): a `terraform show
3
+ * -json` plan from terraform, tofu or choudoufu, and a choudoufu set plan
4
+ * document (`live-plan-set -json`, choudoufu#1752), each into
5
+ * `@intentius/chant/change-set` parts.
6
+ *
7
+ * A member's plan digest is {@link terraformPlanDigest}, the value a
8
+ * `TerraformApplyOp` gate on that root binds (#2300), so a change set over
9
+ * one root and a gate over the same root agree on what changed.
10
+ *
11
+ * This module imports `./plan-digest` and chant's `change-set` and
12
+ * `lifecycle/plan-digest` subpaths, never the lexicon's entry point, so it
13
+ * bundles without TypeScript or the lint rules (#3421).
14
+ */
15
+
16
+ import type {
17
+ ChangeSetAction,
18
+ ChangeSetAttribute,
19
+ ChangeSetDisruption,
20
+ ChangeSetEntry,
21
+ ChangeSetPart,
22
+ ChangeSetPlanner,
23
+ ChangeSetSideEffect,
24
+ } from "@intentius/chant/change-set";
25
+ import { terraformPlanDigest } from "./plan-digest";
26
+
27
+ type Json = Record<string, unknown>;
28
+
29
+ const isObject = (v: unknown): v is Json => v !== null && typeof v === "object" && !Array.isArray(v);
30
+
31
+ /** Whether a sensitivity or unknown map marks anything under it `true`. */
32
+ function marks(v: unknown): boolean {
33
+ if (v === true) return true;
34
+ if (Array.isArray(v)) return v.some(marks);
35
+ if (isObject(v)) return Object.values(v).some(marks);
36
+ return false;
37
+ }
38
+
39
+ function sameJson(a: unknown, b: unknown): boolean {
40
+ if (a === b) return true;
41
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => sameJson(x, b[i]));
42
+ if (isObject(a) && isObject(b)) {
43
+ const ka = Object.keys(a);
44
+ return ka.length === Object.keys(b).length && ka.every((k) => k in b && sameJson(a[k], b[k]));
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * The planner a root's binary names: `choudoufu` and `tofu` by name, and
51
+ * `terraform` for anything else, a path to terraform included.
52
+ */
53
+ export function plannerForBinary(binary: string | undefined): ChangeSetPlanner {
54
+ const base = (binary ?? "terraform").split(/[\\/]/).pop() ?? "";
55
+ if (base.startsWith("choudoufu")) return "choudoufu";
56
+ if (base.startsWith("tofu")) return "tofu";
57
+ return "terraform";
58
+ }
59
+
60
+ /**
61
+ * The action a `resource_changes` entry's `actions` array means. Delete then
62
+ * create and create then delete are both a replace; the order is the
63
+ * disruption ({@link disruptionFor}).
64
+ */
65
+ export function actionFor(actions: unknown): ChangeSetAction {
66
+ const a = Array.isArray(actions) ? actions.map(String) : [];
67
+ if (a.length === 2 && a.includes("delete") && a.includes("create")) return "replace";
68
+ if (a.length === 1) {
69
+ switch (a[0]) {
70
+ case "create": case "update": case "delete": case "read": case "no-op": case "forget":
71
+ return a[0];
72
+ }
73
+ }
74
+ throw new Error(`a plan entry has actions ${JSON.stringify(actions)}, which the change set has no action for`);
75
+ }
76
+
77
+ function disruptionFor(actions: unknown, action: ChangeSetAction): ChangeSetDisruption | undefined {
78
+ switch (action) {
79
+ case "update": return "in-place";
80
+ case "delete": return "destroy";
81
+ case "replace": return (actions as string[])[0] === "create" ? "replace" : "destroy";
82
+ default: return undefined;
83
+ }
84
+ }
85
+
86
+ /** A resource address's module path, name and index, read off the address when the entry does not carry them. */
87
+ export function parseTerraformAddress(address: string): { module?: string; type: string; name: string; index?: string | number } {
88
+ // Module segments, then [data.]type.name[index]. Index brackets may hold a quoted key with dots in it.
89
+ const re = /^((?:module\.[^.[\]]+(?:\[(?:"(?:[^"\\]|\\.)*"|\d+)\])?\.)*)(?:data\.)?([^.[\]]+)\.([^.[\]]+)(?:\[("(?:[^"\\]|\\.)*"|\d+)\])?$/;
90
+ const m = re.exec(address);
91
+ if (!m) return { type: "unknown", name: address };
92
+ const module = m[1] ? m[1].slice(0, -1) : undefined;
93
+ const index = m[4] === undefined ? undefined : m[4].startsWith('"') ? (JSON.parse(m[4]) as string) : Number(m[4]);
94
+ return { ...(module ? { module } : {}), type: m[2], name: m[3], ...(index !== undefined ? { index } : {}) };
95
+ }
96
+
97
+ /** The attributes one change writes, top-level, with sensitive values left out. */
98
+ function attributesFor(change: Json, action: ChangeSetAction): ChangeSetAttribute[] {
99
+ if (action === "delete" || action === "no-op" || action === "forget" || action === "read") return [];
100
+ const before = isObject(change.before) ? change.before : {};
101
+ const after = isObject(change.after) ? change.after : {};
102
+ const afterUnknown = isObject(change.after_unknown) ? change.after_unknown : {};
103
+ const beforeSensitive = isObject(change.before_sensitive) ? change.before_sensitive : {};
104
+ const afterSensitive = isObject(change.after_sensitive) ? change.after_sensitive : {};
105
+ const forcing = new Set(
106
+ (Array.isArray(change.replace_paths) ? change.replace_paths : [])
107
+ .map((p) => (Array.isArray(p) ? p[0] : undefined))
108
+ .filter((k): k is string => typeof k === "string"),
109
+ );
110
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after), ...Object.keys(afterUnknown)])].sort();
111
+ const out: ChangeSetAttribute[] = [];
112
+ for (const k of keys) {
113
+ const unknown = marks(afterUnknown[k]);
114
+ const hasBefore = action !== "create" && before[k] !== undefined && before[k] !== null;
115
+ const hasAfter = after[k] !== undefined && after[k] !== null;
116
+ if (action === "create" ? !hasAfter && !unknown : !unknown && sameJson(before[k] ?? null, after[k] ?? null)) continue;
117
+ const sensitive = marks(beforeSensitive[k]) || marks(afterSensitive[k]);
118
+ out.push({
119
+ path: k,
120
+ ...(sensitive ? { sensitive: true as const } : {
121
+ ...(hasBefore ? { before: before[k] } : {}),
122
+ ...(hasAfter && !unknown ? { after: after[k] } : {}),
123
+ }),
124
+ ...(unknown ? { unknown: true as const } : {}),
125
+ ...(forcing.has(k) ? { forcesReplacement: true as const } : {}),
126
+ });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function regionOf(change: Json): string | undefined {
132
+ for (const side of [change.after, change.before]) {
133
+ if (isObject(side) && typeof side.region === "string" && side.region !== "") return side.region;
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ export interface TerraformChangeSetPartInput {
139
+ /** The member's name: the root's name in `terraform.roots`, which a workspace member of kind terraform or choudoufu shares. */
140
+ member: string;
141
+ /** `terraform show -json <planfile>`, parsed. */
142
+ plan: unknown;
143
+ /** The binary that planned it, or the planner by name. Default `terraform`. */
144
+ planner?: ChangeSetPlanner;
145
+ /** The estate or workspace the root plans into. */
146
+ scope?: string;
147
+ /** The planner's own digest of this plan, when it printed one. */
148
+ nativeDigest?: string;
149
+ }
150
+
151
+ /**
152
+ * One terraform-family root's part, from its `show -json` plan.
153
+ *
154
+ * Every `resource_changes` entry becomes an entry, no-op and read included.
155
+ * A plan stock marked `errored` is a failed member: its entries are kept,
156
+ * but nothing should apply it.
157
+ */
158
+ export function terraformChangeSetPart(input: TerraformChangeSetPartInput): ChangeSetPart {
159
+ const plan = isObject(input.plan) ? input.plan : {};
160
+ const planner = input.planner ?? "terraform";
161
+ const raw = Array.isArray(plan.resource_changes) ? plan.resource_changes : [];
162
+ const entries: ChangeSetEntry[] = raw.map((r): ChangeSetEntry => {
163
+ const rc = isObject(r) ? r : {};
164
+ const change = isObject(rc.change) ? rc.change : {};
165
+ const address = String(rc.address ?? "");
166
+ const parsed = parseTerraformAddress(address);
167
+ const action = actionFor(change.actions);
168
+ const disruption = disruptionFor(change.actions, action);
169
+ const region = regionOf(change);
170
+ const index = rc.index !== undefined && rc.index !== null ? (rc.index as string | number) : parsed.index;
171
+ const module = typeof rc.module_address === "string" ? rc.module_address : parsed.module;
172
+ return {
173
+ member: input.member,
174
+ lexicon: "terraform",
175
+ planner,
176
+ address,
177
+ type: typeof rc.type === "string" ? rc.type : parsed.type,
178
+ name: typeof rc.name === "string" ? rc.name : parsed.name,
179
+ ...(index !== undefined ? { index } : {}),
180
+ ...(module ? { module } : {}),
181
+ ...(typeof rc.deposed === "string" ? { deposed: rc.deposed } : {}),
182
+ action,
183
+ ...(isObject(change.importing) ? { importing: true as const } : {}),
184
+ ...(disruption ? { disruption } : {}),
185
+ ...(region ? { region } : {}),
186
+ ...(input.scope ? { scope: input.scope } : {}),
187
+ attributes: attributesFor(change, action),
188
+ };
189
+ });
190
+ const sideEffects = (Array.isArray(plan.action_invocations) ? plan.action_invocations : []).map(sideEffectFor);
191
+ const errored = plan.errored === true;
192
+ return {
193
+ member: {
194
+ member: input.member,
195
+ lexicon: "terraform",
196
+ planner,
197
+ ...(input.scope ? { scope: input.scope } : {}),
198
+ status: errored ? "failed" : "planned",
199
+ ...(errored ? { error: `${planner} marked the plan errored` } : {}),
200
+ planDigest: terraformPlanDigest(plan),
201
+ ...(input.nativeDigest ? { nativeDigest: input.nativeDigest } : {}),
202
+ holes: [],
203
+ },
204
+ entries,
205
+ ...(sideEffects.length > 0 ? { sideEffects } : {}),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * One `action_invocations` entry of a plan (Terraform 1.14): the action's
211
+ * address and type, and, for a lifecycle trigger, the resource and event.
212
+ */
213
+ function sideEffectFor(raw: unknown): Omit<ChangeSetSideEffect, "member"> {
214
+ const inv = isObject(raw) ? raw : {};
215
+ const type = typeof inv.type === "string" ? inv.type : "unknown";
216
+ const lifecycle = isObject(inv.lifecycle_action_trigger) ? inv.lifecycle_action_trigger : {};
217
+ return {
218
+ address: typeof inv.address === "string" ? inv.address : type,
219
+ type,
220
+ ...(typeof lifecycle.triggering_resource_address === "string" ? { trigger: lifecycle.triggering_resource_address } : {}),
221
+ ...(typeof lifecycle.action_trigger_event === "string" ? { event: lifecycle.action_trigger_event } : {}),
222
+ };
223
+ }
224
+
225
+ /** One root of a choudoufu set plan document: the fields the adapter reads. */
226
+ interface SetPlanRoot {
227
+ root: string;
228
+ estate?: string;
229
+ status?: string;
230
+ error?: string;
231
+ stage?: string;
232
+ plan?: unknown;
233
+ digest?: string;
234
+ }
235
+
236
+ export interface ChoudoufuSetPlanInput {
237
+ /** `choudoufu live-plan-set -json`, parsed. */
238
+ document: unknown;
239
+ /** The member a root belongs to. Default: the root's directory as the set names it. */
240
+ memberFor?: (root: string, estate: string | undefined) => string;
241
+ }
242
+
243
+ /**
244
+ * One part per root of a choudoufu set plan (choudoufu#1752). A planned
245
+ * root is {@link terraformChangeSetPart} over its embedded plan, with the
246
+ * estate as scope and choudoufu's own root digest as `nativeDigest`. A
247
+ * failed root is a failed member with its error and no plan digest.
248
+ */
249
+ export function choudoufuSetPlanParts(input: ChoudoufuSetPlanInput): ChangeSetPart[] {
250
+ const doc = isObject(input.document) ? input.document : {};
251
+ if (!Array.isArray(doc.roots)) throw new Error('not a choudoufu set plan document: no top-level "roots"');
252
+ return (doc.roots as SetPlanRoot[]).map((r) => {
253
+ const member = input.memberFor ? input.memberFor(r.root, r.estate || undefined) : r.root;
254
+ const scope = r.estate || undefined;
255
+ if (r.status === "planned" && isObject(r.plan)) {
256
+ return terraformChangeSetPart({ member, plan: r.plan, planner: "choudoufu", ...(scope ? { scope } : {}), ...(r.digest ? { nativeDigest: r.digest } : {}) });
257
+ }
258
+ return {
259
+ member: {
260
+ member,
261
+ lexicon: "terraform",
262
+ planner: "choudoufu" as const,
263
+ ...(scope ? { scope } : {}),
264
+ status: "failed" as const,
265
+ error: r.error || `the root did not plan (${r.status ?? "no status"}${r.stage ? `, at ${r.stage}` : ""})`,
266
+ planDigest: null,
267
+ ...(r.digest ? { nativeDigest: r.digest } : {}),
268
+ holes: [],
269
+ },
270
+ entries: [],
271
+ };
272
+ });
273
+ }