@intentius/chant-lexicon-terraform 0.124.54 → 0.124.55

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 (351) hide show
  1. package/README.md +7 -4
  2. package/dist/ci-setup.d.ts +11 -0
  3. package/dist/ci-setup.d.ts.map +1 -0
  4. package/dist/ci-setup.js +41 -0
  5. package/dist/components/capability-plugin.d.ts +40 -6
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/capability-plugin.js +167 -30
  8. package/dist/components/compose-group.d.ts +1 -1
  9. package/dist/components/compose-group.d.ts.map +1 -1
  10. package/dist/components/compose-group.js +1 -1
  11. package/dist/components/release-inputs.d.ts +29 -0
  12. package/dist/components/release-inputs.d.ts.map +1 -0
  13. package/dist/components/release-inputs.js +57 -0
  14. package/dist/components/trace-facts.d.ts +16 -0
  15. package/dist/components/trace-facts.d.ts.map +1 -0
  16. package/dist/components/trace-facts.js +19 -0
  17. package/dist/cone.d.ts +53 -0
  18. package/dist/cone.d.ts.map +1 -0
  19. package/dist/cone.js +119 -0
  20. package/dist/cost-hold.d.ts +27 -0
  21. package/dist/cost-hold.d.ts.map +1 -0
  22. package/dist/cost-hold.js +21 -0
  23. package/dist/drift-rows.d.ts +61 -0
  24. package/dist/drift-rows.d.ts.map +1 -0
  25. package/dist/drift-rows.js +108 -0
  26. package/dist/exit-codes.d.ts +55 -0
  27. package/dist/exit-codes.d.ts.map +1 -0
  28. package/dist/exit-codes.js +48 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/integrity.json +7 -4
  33. package/dist/lint/audit-catalog.d.ts.map +1 -1
  34. package/dist/lint/audit-catalog.js +27 -3
  35. package/dist/lint/audit-lineage.d.ts +14 -2
  36. package/dist/lint/audit-lineage.d.ts.map +1 -1
  37. package/dist/lint/post-synth/index.d.ts.map +1 -1
  38. package/dist/lint/post-synth/index.js +4 -0
  39. package/dist/lint/post-synth/record-store.d.ts +54 -0
  40. package/dist/lint/post-synth/record-store.d.ts.map +1 -0
  41. package/dist/lint/post-synth/record-store.js +104 -0
  42. package/dist/lint/post-synth/tf001.d.ts +2 -2
  43. package/dist/lint/post-synth/tf001.js +2 -2
  44. package/dist/lint/post-synth/tf024.d.ts +35 -11
  45. package/dist/lint/post-synth/tf024.d.ts.map +1 -1
  46. package/dist/lint/post-synth/tf024.js +92 -20
  47. package/dist/lint/post-synth/tf046.d.ts +64 -0
  48. package/dist/lint/post-synth/tf046.d.ts.map +1 -0
  49. package/dist/lint/post-synth/tf046.js +175 -0
  50. package/dist/lint/post-synth/tf047.d.ts +36 -0
  51. package/dist/lint/post-synth/tf047.d.ts.map +1 -0
  52. package/dist/lint/post-synth/tf047.js +94 -0
  53. package/dist/lock-rows.d.ts +36 -0
  54. package/dist/lock-rows.d.ts.map +1 -0
  55. package/dist/lock-rows.js +60 -0
  56. package/dist/lock-timeout.d.ts +22 -0
  57. package/dist/lock-timeout.d.ts.map +1 -0
  58. package/dist/lock-timeout.js +32 -0
  59. package/dist/manifest.json +1 -1
  60. package/dist/module-catalog/index.d.ts +69 -0
  61. package/dist/module-catalog/index.d.ts.map +1 -0
  62. package/dist/module-catalog/index.js +110 -0
  63. package/dist/module-catalog/refresh-cli.d.ts +4 -0
  64. package/dist/module-catalog/refresh-cli.d.ts.map +1 -0
  65. package/dist/module-catalog/refresh-cli.js +64 -0
  66. package/dist/module-catalog/static-tier.d.ts +6 -0
  67. package/dist/module-catalog/static-tier.d.ts.map +1 -0
  68. package/dist/module-catalog/static-tier.js +1650 -0
  69. package/dist/module-catalog/types.d.ts +65 -0
  70. package/dist/module-catalog/types.d.ts.map +1 -0
  71. package/dist/module-catalog/types.js +33 -0
  72. package/dist/okf/index.md +3 -1
  73. package/dist/okf/rules/TF024.md +2 -2
  74. package/dist/okf/rules/TF046.md +11 -0
  75. package/dist/okf/rules/TF047.md +11 -0
  76. package/dist/op/activities/terraform.d.ts +29 -0
  77. package/dist/op/activities/terraform.d.ts.map +1 -1
  78. package/dist/op/activities/terraform.js +19 -4
  79. package/dist/pin/consumers.d.ts +35 -0
  80. package/dist/pin/consumers.d.ts.map +1 -0
  81. package/dist/pin/consumers.js +70 -0
  82. package/dist/plugin.d.ts.map +1 -1
  83. package/dist/plugin.js +5 -0
  84. package/dist/registry/address.d.ts +63 -0
  85. package/dist/registry/address.d.ts.map +1 -0
  86. package/dist/registry/address.js +65 -0
  87. package/dist/registry/catalog-read.d.ts +14 -0
  88. package/dist/registry/catalog-read.d.ts.map +1 -0
  89. package/dist/registry/catalog-read.js +58 -0
  90. package/dist/registry/deprecated.d.ts +37 -0
  91. package/dist/registry/deprecated.d.ts.map +1 -0
  92. package/dist/registry/deprecated.js +43 -0
  93. package/dist/registry/docs.d.ts +96 -0
  94. package/dist/registry/docs.d.ts.map +1 -0
  95. package/dist/registry/docs.js +210 -0
  96. package/dist/registry/edge/edge.d.ts +46 -0
  97. package/dist/registry/edge/edge.d.ts.map +1 -0
  98. package/dist/registry/edge/edge.js +194 -0
  99. package/dist/registry/edge/index.d.ts +11 -0
  100. package/dist/registry/edge/index.d.ts.map +1 -0
  101. package/dist/registry/edge/index.js +10 -0
  102. package/dist/registry/edge/lambda-edge.d.ts +76 -0
  103. package/dist/registry/edge/lambda-edge.d.ts.map +1 -0
  104. package/dist/registry/edge/lambda-edge.js +95 -0
  105. package/dist/registry/edge/sigv4-web.d.ts +33 -0
  106. package/dist/registry/edge/sigv4-web.d.ts.map +1 -0
  107. package/dist/registry/edge/sigv4-web.js +56 -0
  108. package/dist/registry/edge/token-source.d.ts +73 -0
  109. package/dist/registry/edge/token-source.d.ts.map +1 -0
  110. package/dist/registry/edge/token-source.js +115 -0
  111. package/dist/registry/edge/worker.d.ts +45 -0
  112. package/dist/registry/edge/worker.d.ts.map +1 -0
  113. package/dist/registry/edge/worker.js +26 -0
  114. package/dist/registry/handler.d.ts +25 -0
  115. package/dist/registry/handler.d.ts.map +1 -0
  116. package/dist/registry/handler.js +75 -0
  117. package/dist/registry/headers-file.d.ts +21 -0
  118. package/dist/registry/headers-file.d.ts.map +1 -0
  119. package/dist/registry/headers-file.js +57 -0
  120. package/dist/registry/index.d.ts +31 -0
  121. package/dist/registry/index.d.ts.map +1 -0
  122. package/dist/registry/index.js +30 -0
  123. package/dist/registry/mirror-paths.d.ts +9 -0
  124. package/dist/registry/mirror-paths.d.ts.map +1 -0
  125. package/dist/registry/mirror-paths.js +8 -0
  126. package/dist/registry/mirror.d.ts +130 -0
  127. package/dist/registry/mirror.d.ts.map +1 -0
  128. package/dist/registry/mirror.js +319 -0
  129. package/dist/registry/proxies.d.ts +73 -0
  130. package/dist/registry/proxies.d.ts.map +1 -0
  131. package/dist/registry/proxies.js +111 -0
  132. package/dist/registry/reader.d.ts +57 -0
  133. package/dist/registry/reader.d.ts.map +1 -0
  134. package/dist/registry/reader.js +198 -0
  135. package/dist/registry/semver.d.ts +16 -0
  136. package/dist/registry/semver.d.ts.map +1 -0
  137. package/dist/registry/semver.js +24 -0
  138. package/dist/registry/serve.d.ts +24 -0
  139. package/dist/registry/serve.d.ts.map +1 -0
  140. package/dist/registry/serve.js +64 -0
  141. package/dist/registry/source-forms.d.ts +79 -0
  142. package/dist/registry/source-forms.d.ts.map +1 -0
  143. package/dist/registry/source-forms.js +202 -0
  144. package/dist/registry/targets/artifactory.d.ts +28 -0
  145. package/dist/registry/targets/artifactory.d.ts.map +1 -0
  146. package/dist/registry/targets/artifactory.js +206 -0
  147. package/dist/registry/targets/fake-artifactory.d.ts +30 -0
  148. package/dist/registry/targets/fake-artifactory.d.ts.map +1 -0
  149. package/dist/registry/targets/fake-artifactory.js +142 -0
  150. package/dist/registry/targets/fake-gitlab.d.ts +39 -0
  151. package/dist/registry/targets/fake-gitlab.d.ts.map +1 -0
  152. package/dist/registry/targets/fake-gitlab.js +136 -0
  153. package/dist/registry/targets/fake-hcp.d.ts +50 -0
  154. package/dist/registry/targets/fake-hcp.d.ts.map +1 -0
  155. package/dist/registry/targets/fake-hcp.js +202 -0
  156. package/dist/registry/targets/fake-http.d.ts +31 -0
  157. package/dist/registry/targets/fake-http.d.ts.map +1 -0
  158. package/dist/registry/targets/fake-http.js +87 -0
  159. package/dist/registry/targets/fake-nexus.d.ts +27 -0
  160. package/dist/registry/targets/fake-nexus.d.ts.map +1 -0
  161. package/dist/registry/targets/fake-nexus.js +137 -0
  162. package/dist/registry/targets/gitlab.d.ts +53 -0
  163. package/dist/registry/targets/gitlab.d.ts.map +1 -0
  164. package/dist/registry/targets/gitlab.js +187 -0
  165. package/dist/registry/targets/hcp.d.ts +29 -0
  166. package/dist/registry/targets/hcp.d.ts.map +1 -0
  167. package/dist/registry/targets/hcp.js +310 -0
  168. package/dist/registry/targets/hosted.d.ts +62 -0
  169. package/dist/registry/targets/hosted.d.ts.map +1 -0
  170. package/dist/registry/targets/hosted.js +74 -0
  171. package/dist/registry/targets/index.d.ts +18 -0
  172. package/dist/registry/targets/index.d.ts.map +1 -0
  173. package/dist/registry/targets/index.js +17 -0
  174. package/dist/registry/targets/module-target.d.ts +68 -0
  175. package/dist/registry/targets/module-target.d.ts.map +1 -0
  176. package/dist/registry/targets/module-target.js +63 -0
  177. package/dist/registry/targets/nexus.d.ts +43 -0
  178. package/dist/registry/targets/nexus.d.ts.map +1 -0
  179. package/dist/registry/targets/nexus.js +250 -0
  180. package/dist/registry/targets/zip.d.ts +55 -0
  181. package/dist/registry/targets/zip.d.ts.map +1 -0
  182. package/dist/registry/targets/zip.js +278 -0
  183. package/dist/registry/writer.d.ts +101 -0
  184. package/dist/registry/writer.d.ts.map +1 -0
  185. package/dist/registry/writer.js +109 -0
  186. package/dist/rules/record-store.ts +122 -0
  187. package/dist/rules/tf001.ts +2 -2
  188. package/dist/rules/tf024.ts +105 -22
  189. package/dist/rules/tf046.ts +204 -0
  190. package/dist/rules/tf047.ts +114 -0
  191. package/dist/state/azure-blob.d.ts +8 -67
  192. package/dist/state/azure-blob.d.ts.map +1 -1
  193. package/dist/state/azure-blob.js +6 -199
  194. package/dist/state/gcs.d.ts +7 -61
  195. package/dist/state/gcs.d.ts.map +1 -1
  196. package/dist/state/gcs.js +5 -222
  197. package/dist/state/object-store.d.ts +4 -33
  198. package/dist/state/object-store.d.ts.map +1 -1
  199. package/dist/state/object-store.js +4 -17
  200. package/dist/state/s3.d.ts +11 -58
  201. package/dist/state/s3.d.ts.map +1 -1
  202. package/dist/state/s3.js +10 -161
  203. package/dist/state/store.js +2 -2
  204. package/dist/state-ops.d.ts +255 -0
  205. package/dist/state-ops.d.ts.map +1 -0
  206. package/dist/state-ops.js +305 -0
  207. package/dist/state-reads.d.ts +57 -0
  208. package/dist/state-reads.d.ts.map +1 -0
  209. package/dist/state-reads.js +104 -0
  210. package/dist/used-sources.d.ts +54 -0
  211. package/dist/used-sources.d.ts.map +1 -0
  212. package/dist/used-sources.js +157 -0
  213. package/package.json +19 -3
  214. package/src/__fixtures__/drift-record/README.md +5 -0
  215. package/src/__fixtures__/drift-rows/live-root-plan.json +70 -0
  216. package/src/__fixtures__/drift-rows/refresh-plan.json +61 -0
  217. package/src/__fixtures__/used-sources/root/extra.tofu +1 -0
  218. package/src/__fixtures__/used-sources/root/main.tf +31 -0
  219. package/src/__fixtures__/used-sources/root/modules/net/main.tf +14 -0
  220. package/src/__fixtures__/used-sources/root/modules/net/versions.tf.json +7 -0
  221. package/src/__fixtures__/used-sources/unit/terragrunt.hcl +3 -0
  222. package/src/ci-setup.ts +42 -0
  223. package/src/components/capability-plugin.test.ts +26 -14
  224. package/src/components/capability-plugin.ts +178 -31
  225. package/src/components/compose-group.ts +1 -1
  226. package/src/components/pr-loop-compose.tofu.e2e.test.ts +1 -1
  227. package/src/components/pr-loop.tofu.test.ts +1 -1
  228. package/src/components/release-inputs.ts +69 -0
  229. package/src/components/trace-facts.test.ts +21 -0
  230. package/src/components/trace-facts.ts +27 -0
  231. package/src/cone.ts +138 -0
  232. package/src/cost-hold.ts +29 -0
  233. package/src/describe-resources.engine-facts.test.ts +2 -3
  234. package/src/drift-rows.test.ts +135 -0
  235. package/src/drift-rows.ts +137 -0
  236. package/src/exit-codes.test.ts +23 -0
  237. package/src/exit-codes.ts +59 -0
  238. package/src/index.ts +3 -0
  239. package/src/lint/audit-catalog.ts +29 -3
  240. package/src/lint/audit-lineage.ts +14 -2
  241. package/src/lint/post-synth/fixtures/TF024/live-with-record-store/main.tf +18 -0
  242. package/src/lint/post-synth/fixtures/TF024/sidecar-with-backend/estate.chdf.hcl +1 -0
  243. package/src/lint/post-synth/fixtures/TF024/sidecar-with-backend/main.tf +10 -0
  244. package/src/lint/post-synth/fixtures/TF046/azurerm-no-resource-group/main.tf +15 -0
  245. package/src/lint/post-synth/fixtures/TF046/backend-config-only/main.tf +14 -0
  246. package/src/lint/post-synth/fixtures/TF046/derived-s3/backend.tf +7 -0
  247. package/src/lint/post-synth/fixtures/TF046/derived-s3/main.tf +9 -0
  248. package/src/lint/post-synth/fixtures/TF046/explicit-local/main.tf +16 -0
  249. package/src/lint/post-synth/fixtures/TF046/explicit-s3-beside-local-backend/main.tf +15 -0
  250. package/src/lint/post-synth/fixtures/TF046/implied-local/main.tf +9 -0
  251. package/src/lint/post-synth/fixtures/TF046/sidecar-implied/estate.chdf.hcl +1 -0
  252. package/src/lint/post-synth/fixtures/TF046/sidecar-implied/main.tf +5 -0
  253. package/src/lint/post-synth/fixtures/TF047/clear/main.tf +12 -0
  254. package/src/lint/post-synth/fixtures/TF047/refused/main.tf +12 -0
  255. package/src/lint/post-synth/fixtures/TF047/unknown-module/main.tf +12 -0
  256. package/src/lint/post-synth/fixtures/TF047/unknown-version/main.tf +12 -0
  257. package/src/lint/post-synth/index.ts +4 -0
  258. package/src/lint/post-synth/record-store.ts +122 -0
  259. package/src/lint/post-synth/tf001.ts +2 -2
  260. package/src/lint/post-synth/tf024.test.ts +26 -5
  261. package/src/lint/post-synth/tf024.ts +105 -22
  262. package/src/lint/post-synth/tf046.test.ts +137 -0
  263. package/src/lint/post-synth/tf046.ts +204 -0
  264. package/src/lint/post-synth/tf047.test.ts +56 -0
  265. package/src/lint/post-synth/tf047.ts +114 -0
  266. package/src/lock-rows.test.ts +70 -0
  267. package/src/lock-rows.ts +82 -0
  268. package/src/lock-timeout.ts +34 -0
  269. package/src/module-catalog/index.test.ts +93 -0
  270. package/src/module-catalog/index.ts +159 -0
  271. package/src/module-catalog/refresh-cli.ts +68 -0
  272. package/src/module-catalog/static-tier.ts +1659 -0
  273. package/src/module-catalog/types.ts +88 -0
  274. package/src/op/activities/terraform.ts +46 -5
  275. package/src/op-wave-step.test.ts +109 -0
  276. package/src/pin/__fixtures__/consumers/live/dev/main.tf +4 -0
  277. package/src/pin/__fixtures__/consumers/live/prod/main.tf +21 -0
  278. package/src/pin/__fixtures__/consumers/units/vpc/terragrunt.hcl +7 -0
  279. package/src/pin/consumers.test.ts +57 -0
  280. package/src/pin/consumers.ts +95 -0
  281. package/src/plugin.ts +6 -0
  282. package/src/registry/__fixtures__/docs/documented/.terraform-docs.yml +3 -0
  283. package/src/registry/__fixtures__/docs/documented/README.md +1 -0
  284. package/src/registry/__fixtures__/docs/documented/main.tf +8 -0
  285. package/src/registry/__fixtures__/docs/json/main.tf.json +14 -0
  286. package/src/registry/__fixtures__/docs/vpc/README.md +3 -0
  287. package/src/registry/__fixtures__/docs/vpc/examples/complete/main.tf +5 -0
  288. package/src/registry/__fixtures__/docs/vpc/examples/simple/README.md +1 -0
  289. package/src/registry/__fixtures__/docs/vpc/examples/simple/main.tf +4 -0
  290. package/src/registry/__fixtures__/docs/vpc/main.tf +14 -0
  291. package/src/registry/__fixtures__/docs/vpc/outputs.tf +9 -0
  292. package/src/registry/__fixtures__/docs/vpc/variables.tf +16 -0
  293. package/src/registry/__fixtures__/proxied-root/main.tf +12 -0
  294. package/src/registry/address.ts +100 -0
  295. package/src/registry/catalog-read.ts +56 -0
  296. package/src/registry/deprecated.test.ts +139 -0
  297. package/src/registry/deprecated.ts +76 -0
  298. package/src/registry/docs.test.ts +140 -0
  299. package/src/registry/docs.ts +299 -0
  300. package/src/registry/edge/bundle.test.ts +50 -0
  301. package/src/registry/edge/edge.test.ts +176 -0
  302. package/src/registry/edge/edge.ts +211 -0
  303. package/src/registry/edge/index.ts +10 -0
  304. package/src/registry/edge/lambda-edge.ts +127 -0
  305. package/src/registry/edge/sigv4-web.ts +79 -0
  306. package/src/registry/edge/token-source.test.ts +258 -0
  307. package/src/registry/edge/token-source.ts +169 -0
  308. package/src/registry/edge/worker.ts +61 -0
  309. package/src/registry/handler.ts +89 -0
  310. package/src/registry/headers-file.ts +61 -0
  311. package/src/registry/index.ts +31 -0
  312. package/src/registry/mirror-paths.ts +10 -0
  313. package/src/registry/mirror.test.ts +198 -0
  314. package/src/registry/mirror.ts +395 -0
  315. package/src/registry/proxies.test.ts +153 -0
  316. package/src/registry/proxies.ts +132 -0
  317. package/src/registry/reader.ts +225 -0
  318. package/src/registry/registry.test.ts +262 -0
  319. package/src/registry/semver.ts +34 -0
  320. package/src/registry/serve.ts +82 -0
  321. package/src/registry/source-forms.test.ts +87 -0
  322. package/src/registry/source-forms.ts +216 -0
  323. package/src/registry/targets/artifactory.test.ts +248 -0
  324. package/src/registry/targets/artifactory.ts +257 -0
  325. package/src/registry/targets/fake-artifactory.ts +158 -0
  326. package/src/registry/targets/fake-gitlab.ts +161 -0
  327. package/src/registry/targets/fake-hcp.ts +233 -0
  328. package/src/registry/targets/fake-http.ts +97 -0
  329. package/src/registry/targets/fake-nexus.ts +152 -0
  330. package/src/registry/targets/gitlab.test.ts +208 -0
  331. package/src/registry/targets/gitlab.ts +223 -0
  332. package/src/registry/targets/hcp.test.ts +230 -0
  333. package/src/registry/targets/hcp.ts +360 -0
  334. package/src/registry/targets/hosted.ts +117 -0
  335. package/src/registry/targets/index.ts +17 -0
  336. package/src/registry/targets/module-target.ts +131 -0
  337. package/src/registry/targets/nexus.test.ts +220 -0
  338. package/src/registry/targets/nexus.ts +314 -0
  339. package/src/registry/targets/zip.test.ts +65 -0
  340. package/src/registry/targets/zip.ts +287 -0
  341. package/src/registry/writer.ts +155 -0
  342. package/src/state/azure-blob.ts +6 -221
  343. package/src/state/gcs.ts +5 -252
  344. package/src/state/object-store.ts +4 -32
  345. package/src/state/s3.ts +10 -182
  346. package/src/state/store.ts +2 -2
  347. package/src/state-ops.test.ts +182 -0
  348. package/src/state-ops.ts +462 -0
  349. package/src/state-reads.ts +116 -0
  350. package/src/used-sources.test.ts +75 -0
  351. package/src/used-sources.ts +204 -0
@@ -22,7 +22,7 @@
22
22
  * system and refuses with exit 3 when its fresh plan differs (choudoufu
23
23
  * #878). Either refusal fails the step. choudoufu's refusal of a plan the
24
24
  * wave handed `run` throws {@link TerraformPlanMovedError}, so the command
25
- * that applied it exits 3 (#3997). terraform's and tofu's stale-plan error
25
+ * that applied it exits 4, the table's moved code (#3997, #4553). terraform's and tofu's stale-plan error
26
26
  * is not classified and exits 1.
27
27
  *
28
28
  * Run without a wave plan (`chant run --components`, or a fan-out with no
@@ -100,9 +100,31 @@
100
100
  * read the way `plan` reads a root's (`terraformPlannedOutputs`). The member
101
101
  * is not a `terraform.roots` entry of the reading project; its directory
102
102
  * comes from the workspace declaration.
103
+ *
104
+ * ## Op waves (#4545)
105
+ *
106
+ * The step is what an op wave's `step` names to plan and apply each root
107
+ * through chant (`chant run wave`), so terragucci's waves and plywood's run
108
+ * the engine in chant. Three inputs come from terragucci's wave callbacks:
109
+ *
110
+ * - `lockTimeout` (default `5m`, `false` for none): `plan` and `apply` wait
111
+ * that long for another run's state lock, unless `TF_CLI_ARGS` sets
112
+ * `-lock-timeout` already (`../lock-timeout.ts`).
113
+ * - `cone`: a revision. A live choudoufu root plans the cone of the change
114
+ * since it (`plan -cone`, `../cone.ts`), and the cone's targets join the
115
+ * plan digest, so an approval of one cone counts for no other. Such a root
116
+ * plans with `plan`, never as a one-root set.
117
+ * - `reads`: vars read from another root's outputs, Atmos's
118
+ * `!terraform.state` (`../state-reads.ts`). An upstream with no such output
119
+ * yet makes the member a hole naming it, so the root is never planned on a
120
+ * stand-in.
121
+ *
122
+ * Every plan also writes its `show -json` beside the plan file and names it
123
+ * as `artifact.planJson`, the document a wave's cost hold prices
124
+ * (`../cost-hold.ts`).
103
125
  */
104
126
 
105
- import { rmSync } from "node:fs";
127
+ import { rmSync, writeFileSync } from "node:fs";
106
128
  import { dirname, join, resolve } from "node:path";
107
129
  import type { Capability, DeployContext, CapabilityPlan, PlannedOutputs } from "@intentius/chant/components/capability";
108
130
  import { loadChantConfigUpward } from "@intentius/chant/config";
@@ -127,13 +149,22 @@ import {
127
149
  terraformOutputs,
128
150
  terraformPlan,
129
151
  } from "../op/activities/terraform";
152
+ import { terraformPlanMoved } from "../exit-codes";
130
153
  import { terraformPlanShape } from "../plan-shape";
131
- import { terraformChangeSetPart } from "../change-set";
154
+ import { plannerForBinary, terraformChangeSetPart } from "../change-set";
155
+ import { terraformDefaultLockGrain, terraformLockGrainRefusal, terraformLockRows, terraformLockRowsFromPart } from "../lock-rows";
156
+ import type { LockRow } from "@intentius/chant/lifecycle/lock-rows";
132
157
  import type { TerraformConfig } from "../config";
133
158
  import { rootTexts } from "../compose/hcl";
134
159
  import { declaredVariables } from "../compose/root";
135
160
  import { terraformComposeGroup } from "./compose-group";
136
161
  import { markPlaceholderDriven, placeholderVars, waitingHole } from "./placeholder-preview";
162
+ import { terraformReleaseInputs } from "./release-inputs";
163
+ import { terraformTraceFacts } from "./trace-facts";
164
+ import { DEFAULT_LOCK_TIMEOUT, isLockTimeout } from "../lock-timeout";
165
+ import { conePlanDigest, type PlanCone } from "../cone";
166
+ import { fillStateReads, stateReadsProblems, waitingReadsReason, type TerraformStateRead } from "../state-reads";
167
+ import { terraformStateOpCapabilities } from "../state-ops";
137
168
 
138
169
  /** The `terraform-apply` step's input. */
139
170
  export interface TerraformApplyStepInput {
@@ -143,6 +174,14 @@ export interface TerraformApplyStepInput {
143
174
  vars?: Record<string, unknown>;
144
175
  /** Where the `chant.config.*` search starts. Default: the current directory. */
145
176
  cwd?: string;
177
+ /** The root's files from here instead of its entry's `dir` (#4542): a promote under a release sets it to the recorded copy. */
178
+ dir?: string;
179
+ /** How long `plan` and `apply` wait for a state lock (#4545). Default `"5m"`; `false` passes no flag. */
180
+ lockTimeout?: string | false;
181
+ /** choudoufu live roots (#4545): plan the cone of the change since this revision. */
182
+ cone?: string;
183
+ /** Vars read from other roots' outputs (#4545), Atmos's `!terraform.state`. The step's `vars` win over them. */
184
+ reads?: TerraformStateRead[];
146
185
  }
147
186
 
148
187
  /** What `terraform-apply` returns. `outputs` is what a dependent's `stackOutput()` reads. */
@@ -159,9 +198,53 @@ export interface TerraformApplyStepOutput {
159
198
  interface PlannedRoot {
160
199
  planFile: string;
161
200
  planDigest: string;
201
+ /** The plan's `show -json`, beside the plan file (#4545), for a cost hold to price. */
202
+ planJson?: string;
203
+ /** The cone the plan covered, when asked for one (#4545). */
204
+ cone?: PlanCone;
205
+ /** A choudoufu root's rows on the resource lock grain (#4552), from the plan's `show -json`. */
206
+ lockRows?: LockRow[];
207
+ }
208
+
209
+ /** The lock timeout a step's plan and apply pass (#4545). */
210
+ function lockTimeoutOf(input: TerraformApplyStepInput): { lockTimeout?: string } {
211
+ if (input.lockTimeout === false) return {};
212
+ const value = input.lockTimeout ?? DEFAULT_LOCK_TIMEOUT;
213
+ if (!isLockTimeout(value)) throw new Error(`terraform-apply: root "${input.root}": lockTimeout "${String(value)}" is not a duration such as "5m"`);
214
+ return { lockTimeout: value };
215
+ }
216
+
217
+ /**
218
+ * The step's input with its `reads` filled (#4545): each upstream's outputs
219
+ * read once, the values under the step's own `vars`. `waiting` when an
220
+ * upstream has no such output yet. Throws when a state cannot be read.
221
+ */
222
+ async function withReads(input: TerraformApplyStepInput): Promise<{ input: TerraformApplyStepInput } | { waiting: string; upstreams: string[] }> {
223
+ const reads = input.reads ?? [];
224
+ if (reads.length === 0) return { input };
225
+ const problems = stateReadsProblems(reads);
226
+ if (problems.length > 0) throw new Error(`terraform-apply: root "${input.root}": ${problems.join("; ")}`);
227
+ const filled = await fillStateReads(reads, async (root) => {
228
+ const at = { root, ...(input.cwd ? { cwd: input.cwd } : {}) };
229
+ await terraformInit(at);
230
+ return terraformOutputs(at);
231
+ });
232
+ if (filled.errors.length > 0) throw new Error(`terraform-apply: root "${input.root}": ${filled.errors.join("; ")}`);
233
+ if (filled.waiting.length > 0) return { waiting: waitingReadsReason(filled.waiting), upstreams: [...new Set(filled.waiting.map((w) => w.read.root))].sort() };
234
+ return { input: { ...input, vars: { ...filled.vars, ...(input.vars ?? {}) } } };
235
+ }
236
+
237
+ /** A root whose reads wait on an upstream (#4545): a hole naming it, and a digest over the root and those upstreams. */
238
+ function waitingOnReads(ctx: DeployContext, input: TerraformApplyStepInput, waiting: { waiting: string; upstreams: string[] }): CapabilityPlan {
239
+ const planDigest = computePlanDigest("terraform-apply-reads-waiting", { env: ctx.env, root: input.root, upstreams: waiting.upstreams });
240
+ const changeSet: ChangeSetPart = {
241
+ member: { member: input.root, lexicon: "terraform", planner: "chant", status: "planned", planDigest, holes: [{ address: input.root, type: "terraform_root", reason: waiting.waiting }] },
242
+ entries: [],
243
+ };
244
+ return { member: input.root, planDigest, changeSet };
162
245
  }
163
246
 
164
- const rootArgs = (input: TerraformApplyStepInput) => ({ root: input.root, ...(input.cwd ? { cwd: input.cwd } : {}) });
247
+ const rootArgs = (input: TerraformApplyStepInput) => ({ root: input.root, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.dir ? { dir: input.dir } : {}) });
165
248
 
166
249
  function requireRoot(input: TerraformApplyStepInput): void {
167
250
  if (typeof input.root !== "string" || input.root === "") {
@@ -179,12 +262,27 @@ async function planRoot(input: TerraformApplyStepInput, targets?: string[]): Pro
179
262
  ...rootArgs(input),
180
263
  ...(input.vars ? { vars: input.vars } : {}),
181
264
  ...(targets && targets.length > 0 ? { targets } : {}),
265
+ ...lockTimeoutOf(input),
266
+ ...(input.cone && !(targets && targets.length > 0) ? { cone: input.cone } : {}),
182
267
  text: false,
183
268
  });
269
+ // #4545: the cone's targets join the digest, so an approval of this cone counts for no other.
270
+ const planDigest = conePlanDigest(planned.planDigest, planned.cone);
271
+ const changeSet = planned.changeSet && planDigest !== planned.planDigest ? { ...planned.changeSet, member: { ...planned.changeSet.member, planDigest } } : planned.changeSet;
272
+ // #4545: the plan document beside the plan file, which a wave's cost hold prices.
273
+ const planJson = join(planned.dir, `${planned.planFile}.json`);
274
+ let wrote = true;
275
+ try {
276
+ writeFileSync(planJson, JSON.stringify(planned.json));
277
+ } catch {
278
+ wrote = false;
279
+ }
184
280
  return {
185
281
  planFile: planned.planFile,
186
- planDigest: planned.planDigest,
187
- ...(planned.changeSet ? { changeSet: planned.changeSet } : {}),
282
+ planDigest,
283
+ ...(wrote ? { planJson } : {}),
284
+ ...(planned.cone ? { cone: planned.cone } : {}),
285
+ ...(changeSet ? { changeSet } : {}),
188
286
  json: planned.json,
189
287
  };
190
288
  }
@@ -290,8 +388,9 @@ async function rootDir(input: TerraformApplyStepInput): Promise<{ dir: string; b
290
388
  const start = resolve(input.cwd ?? process.cwd());
291
389
  const { config, configPath } = await loadChantConfigUpward(start);
292
390
  const terraform = (config as { terraform?: TerraformConfig }).terraform;
293
- const dir = terraform?.roots?.[input.root]?.dir;
294
- if (dir === undefined) return undefined;
391
+ const declared = terraform?.roots?.[input.root]?.dir;
392
+ if (declared === undefined) return undefined;
393
+ const dir = input.dir ?? declared;
295
394
  return { dir: resolve(configPath ? dirname(configPath) : start, dir), ...(terraform?.binary ? { binary: terraform.binary } : {}) };
296
395
  }
297
396
 
@@ -364,11 +463,12 @@ async function previewRoot(ctx: DeployContext, input: TerraformApplyStepInput, u
364
463
  }
365
464
 
366
465
  /**
367
- * choudoufu refused the plan `run` was handed by its wave (its exit 3):
368
- * the live system moved since the plan, or the plan names another estate.
369
- * Nothing was applied. `planMoved` is core's mark for a moved plan
370
- * (`isPlanMovedError`), so the command that applied it exits 3, as for the
371
- * other capabilities that refuse a moved plan (#3997).
466
+ * choudoufu refused the plan `run` was handed by its wave (its exit 3)
467
+ * because the live system moved since the plan. Nothing was applied.
468
+ * `planMoved` is core's mark for a moved plan (`isPlanMovedError`), so the
469
+ * command that applied it exits `CHANT_EXIT.moved`, 4, as for the other
470
+ * capabilities that refuse a moved plan (#3997, #4553). A plan made against
471
+ * another estate is a plain failure (`terraformExitOf` in ../exit-codes).
372
472
  */
373
473
  export class TerraformPlanMovedError extends Error {
374
474
  readonly planMoved = true;
@@ -378,9 +478,6 @@ export class TerraformPlanMovedError extends Error {
378
478
  }
379
479
  }
380
480
 
381
- /** `live-wave-apply`'s exit status for a set whose plan moved (`CHOUDOUFU_WAVE_EXIT.setMoved`). */
382
- const CHOUDOUFU_SET_MOVED_EXIT = 3;
383
-
384
481
  function isPlannedRoot(value: unknown): value is PlannedRoot {
385
482
  const v = value as Partial<PlannedRoot> | undefined;
386
483
  return typeof v?.planFile === "string" && typeof v.planDigest === "string";
@@ -390,6 +487,8 @@ function isPlannedRoot(value: unknown): value is PlannedRoot {
390
487
  interface ChoudoufuWavePlannedRoot {
391
488
  planDigest: string;
392
489
  choudoufu: { planSetFile: string; setDigest: string };
490
+ /** The root's rows on the resource lock grain (#4552), from its embedded plan. */
491
+ lockRows?: LockRow[];
393
492
  }
394
493
 
395
494
  function isChoudoufuWavePlan(value: unknown): value is ChoudoufuWavePlannedRoot {
@@ -416,13 +515,28 @@ async function runChoudoufuWave(
416
515
  if (applied.carry) ctx.carry?.(input.root, applied.carry);
417
516
  if (!applied.landed) {
418
517
  const message = `terraform-apply: root "${input.root}": ${applied.message}`;
419
- if (applied.exitCode === CHOUDOUFU_SET_MOVED_EXIT) throw new TerraformPlanMovedError(input.root, message);
518
+ if (terraformPlanMoved({ command: "live-wave-apply", code: applied.exitCode })) throw new TerraformPlanMovedError(input.root, message);
420
519
  throw new Error(message);
421
520
  }
422
521
  const outputs = await terraformOutputs(rootArgs(input));
423
522
  return { root: input.root, planDigest: planned.planDigest, fromWavePlan: true, outputs };
424
523
  }
425
524
 
525
+ /** The input a `run` that plans for itself plans with (#4545): its reads filled, or an error naming the upstream to apply first. */
526
+ async function readsForRun(input: TerraformApplyStepInput): Promise<TerraformApplyStepInput> {
527
+ const read = await withReads(input);
528
+ if ("waiting" in read) throw new Error(`terraform-apply: root "${input.root}": ${read.waiting}`);
529
+ return read.input;
530
+ }
531
+
532
+ /** The engine a root plans with (#4552): the configured binary's, else the plan's planner. */
533
+ async function plannerOf(input: TerraformApplyStepInput, plan?: CapabilityPlan): Promise<"terraform" | "tofu" | "choudoufu" | undefined> {
534
+ const binary = (await rootDir(input).catch(() => undefined))?.binary;
535
+ if (binary !== undefined) return plannerForBinary(binary) as "terraform" | "tofu" | "choudoufu";
536
+ const planner = plan?.changeSet?.member.planner;
537
+ return planner === "terraform" || planner === "tofu" || planner === "choudoufu" ? planner : "terraform";
538
+ }
539
+
426
540
  export const terraformApplyCapability: Capability<TerraformApplyStepInput, TerraformApplyStepOutput> = {
427
541
  kind: "terraform-apply",
428
542
  // A root's apply has no undo short of applying the previous configuration,
@@ -430,26 +544,33 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
430
544
  rollbackPolicy: "needs-opt-out",
431
545
  // #4300: `--target` passes through as `-target`, and the saved plan holds those addresses alone.
432
546
  targets: "accept",
433
- async plan(ctx: DeployContext, input: TerraformApplyStepInput): Promise<CapabilityPlan> {
434
- requireRoot(input);
547
+ async plan(ctx: DeployContext, given: TerraformApplyStepInput): Promise<CapabilityPlan> {
548
+ requireRoot(given);
549
+ // #4545: a root reading an upstream's output that is not there yet is a hole naming the upstream.
550
+ const read = await withReads(given);
551
+ if ("waiting" in read) return waitingOnReads(ctx, given, read);
552
+ const { input } = read;
435
553
  const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
436
554
  const targets = ctx.targets && ctx.targets.length > 0 ? ctx.targets : undefined;
437
555
  if (unresolved.length > 0) return previewRoot(ctx, input, unresolved, targets);
438
- // A targeted root plans with `plan -target`, never as a choudoufu one-root set.
439
- const wave = targets ? { wave: false } : await choudoufuWaveRoot({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
556
+ // A targeted root plans with `plan -target`, and a cone plan with `plan -cone` (#4545), never as a choudoufu one-root set.
557
+ const wave = targets || input.cone ? { wave: false } : await choudoufuWaveRoot({ ...rootArgs(input), ...(input.vars ? { vars: input.vars } : {}) });
440
558
  if (wave.wave) {
441
559
  const set = await choudoufuPlanSet(rootArgs(input));
442
560
  const artifact: ChoudoufuWavePlannedRoot = {
443
561
  planDigest: set.planDigest,
444
562
  choudoufu: { planSetFile: set.planSetFile, setDigest: set.setDigest },
563
+ lockRows: terraformLockRows(input.root, set.changeSet.member.scope, set.json),
445
564
  };
446
565
  return { member: input.root, planDigest: set.planDigest, artifact, changeSet: set.changeSet, outputs: terraformPlannedOutputs(set.json), shape: terraformPlanShape(set.json, input.root) };
447
566
  }
448
567
  const { changeSet, json, ...planned } = await planRoot(input, targets);
568
+ // #4552: a choudoufu root's rows, keyed from the plan document while it is at hand.
569
+ const lockRows = changeSet?.member.planner === "choudoufu" ? terraformLockRows(input.root, changeSet.member.scope, json) : undefined;
449
570
  return {
450
571
  member: input.root,
451
572
  planDigest: planned.planDigest,
452
- artifact: planned,
573
+ artifact: { ...planned, ...(lockRows ? { lockRows } : {}) },
453
574
  ...(changeSet ? { changeSet } : {}),
454
575
  outputs: terraformPlannedOutputs(json),
455
576
  shape: terraformPlanShape(json, input.root),
@@ -466,7 +587,29 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
466
587
  // #4447 (ws-107): a group core forms of components that read each other plans and applies as one composed run
467
588
  // when `terraform.compose.enabled` is set (./compose-group.ts). Delegates, so the methods are read when called.
468
589
  groupReads: (ctx, members) => terraformComposeGroup.groupReads(ctx, members),
469
- groupFallback: (ctx, members) => terraformComposeGroup.groupFallback(ctx, members),
590
+ // #4542: a member pointed at a release's recorded copy plans alone, since a composition reads the declared roots.
591
+ groupFallback: async (ctx, members) =>
592
+ members.some((m) => typeof m.step.dir === "string")
593
+ ? { kind: "release-copy", reason: "a member deploys a release's recorded copy of its root, which a composition does not read" }
594
+ : terraformComposeGroup.groupFallback(ctx, members),
595
+ // #4542: the root's files, which a release cut records and a promote under it plans.
596
+ releaseInputs: (input, ctx) => terraformReleaseInputs(input, ctx),
597
+ // #4552: resource under choudoufu, the root under Terraform, tofu and Terragrunt, which refuse resource by name.
598
+ async defaultLockGrain(input, plan) {
599
+ return terraformDefaultLockGrain(await plannerOf(input, plan));
600
+ },
601
+ async lockGrainRefusal(input, grain, plan) {
602
+ if (grain !== "resource") return undefined;
603
+ const binary = (await rootDir(input).catch(() => undefined))?.binary;
604
+ return terraformLockGrainRefusal(grain, await plannerOf(input, plan), binary);
605
+ },
606
+ lockRows(input, plan) {
607
+ const kept = (plan.artifact as { lockRows?: LockRow[] } | undefined)?.lockRows;
608
+ if (Array.isArray(kept)) return kept;
609
+ return plan.changeSet ? terraformLockRowsFromPart(input.root, plan.changeSet) : [];
610
+ },
611
+ // #4555: the root and the binary, for the step's span and run record.
612
+ traceFacts: async (input, outcome) => terraformTraceFacts(input, outcome, (await rootDir(input).catch(() => undefined))?.binary),
470
613
  planGroup: (ctx, members) => terraformComposeGroup.planGroup(ctx, members),
471
614
  runGroup: (ctx, run, members) => terraformComposeGroup.runGroup(ctx, run, members),
472
615
  async run(ctx: DeployContext, input: TerraformApplyStepInput): Promise<TerraformApplyStepOutput> {
@@ -475,10 +618,10 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
475
618
  if (isShapeArtifact(fromWave)) {
476
619
  // A root previewed with stand-ins (#4463): plan it now with the real
477
620
  // values, and apply that plan only when it is within the preview's shape.
478
- const fresh = await planRoot(input, ctx.targets);
621
+ const fresh = await planRoot(await readsForRun(input), ctx.targets);
479
622
  const differences = planShapeDifferences(fromWave.shape, terraformPlanShape(fresh.json, input.root));
480
623
  if (differences.length > 0) throw new PlanShapeMovedError(input.root, differences);
481
- const applied = await terraformApply({ ...rootArgs(input), planFile: fresh.planFile });
624
+ const applied = await terraformApply({ ...rootArgs(input), planFile: fresh.planFile, ...lockTimeoutOf(input) });
482
625
  if (!applied.applied) {
483
626
  throw new Error(`terraform-apply: root "${input.root}" refused the plan it was given (${applied.refused ?? "refused"}). ${applied.refusal ?? ""}`);
484
627
  }
@@ -487,15 +630,16 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
487
630
  }
488
631
  const planned: PlannedRoot = isPlannedRoot(fromWave)
489
632
  ? fromWave
490
- : await planRoot(input, ctx.targets).then(({ planFile, planDigest }) => ({ planFile, planDigest }));
491
- const applied = await terraformApply({ ...rootArgs(input), planFile: planned.planFile });
633
+ : await planRoot(await readsForRun(input), ctx.targets).then(({ planFile, planDigest }) => ({ planFile, planDigest }));
634
+ const applied = await terraformApply({ ...rootArgs(input), planFile: planned.planFile, ...lockTimeoutOf(input) });
492
635
  if (!applied.applied) {
493
636
  const message =
494
637
  `terraform-apply: root "${input.root}" refused the plan it was given (${applied.refused ?? "refused"}). ` +
495
638
  (applied.refusal ?? "");
496
- // A plan the wave approved that choudoufu refuses moved since the
497
- // approval. One this run made itself is planned afresh by the next run.
498
- if (applied.refused !== undefined && isPlannedRoot(fromWave)) throw new TerraformPlanMovedError(input.root, message);
639
+ // A plan the wave approved that choudoufu refuses as no longer matching
640
+ // moved since the approval (#4553: one made against another estate is a
641
+ // failure). One this run made itself is planned afresh by the next run.
642
+ if (applied.refused === "approval-mismatch" && isPlannedRoot(fromWave)) throw new TerraformPlanMovedError(input.root, message);
499
643
  throw new Error(message);
500
644
  }
501
645
  const outputs = await terraformOutputs(rootArgs(input));
@@ -505,6 +649,9 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
505
649
 
506
650
  export const TERRAFORM_VERB_FAMILIES = {
507
651
  apply: ["terraform-apply"],
652
+ // #4618: the binary runs outside waves, each a fixed operation; the two that change state on their own run under a gated request.
653
+ state: ["terraform-state-unlock", "terraform-state-push"],
654
+ copy: ["terraform-copy"],
508
655
  } as const;
509
656
 
510
657
  export const terraformCapabilityPlugin: CapabilityPlugin = {
@@ -513,7 +660,7 @@ export const terraformCapabilityPlugin: CapabilityPlugin = {
513
660
  return ownPackageVersion(import.meta.url);
514
661
  },
515
662
  capabilities(): Array<Capability<never, unknown>> {
516
- return [terraformApplyCapability as Capability<never, unknown>];
663
+ return [terraformApplyCapability as Capability<never, unknown>, ...terraformStateOpCapabilities.map((c) => c as unknown as Capability<never, unknown>)];
517
664
  },
518
665
  families(): Record<string, readonly string[]> {
519
666
  return TERRAFORM_VERB_FAMILIES;
@@ -341,7 +341,7 @@ function waitingGroup(ctx: GroupContext, members: readonly GroupMember<StepInput
341
341
  return { members: plans, digest: computePlanDigest("terraform-apply-group-unresolved", { components: ctx.components, unresolved: waits }) };
342
342
  }
343
343
 
344
- /** A composed plan that no longer matches the one approved. Core's moved-plan mark, so nothing applies and the command exits 3. */
344
+ /** A composed plan that no longer matches the one approved. Core's moved-plan mark, so nothing applies and the command exits `CHANT_EXIT.moved`, 4 (#4553). */
345
345
  export class ComposedPlanMovedError extends Error {
346
346
  readonly planMoved = true;
347
347
  constructor(message: string) {
@@ -287,7 +287,7 @@ chainSuite("with terraform.compose, three roots plan and apply as one group", {
287
287
  writeFileSync(join(loop.dir, "roots/a/main.tf"), CHAIN.a!.replace("a-v1", "a-v4"));
288
288
  loop.git("commit", "-qam", "a v4");
289
289
 
290
- expect(await loop.merge(2)).toBe(3);
290
+ expect(await loop.merge(2)).toBe(4);
291
291
  const refused = loop.report("apply");
292
292
  expect(refused.status).toBe("refused");
293
293
  expect(refused.refusal).toBe("plan-changed");
@@ -298,7 +298,7 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
298
298
  write("roots/a/main.tf", ROOTS.a.replace("a-v1", "a-v4"));
299
299
  git("commit", "-qam", "a v4");
300
300
 
301
- expect(await merge(2)).toBe(3);
301
+ expect(await merge(2)).toBe(4);
302
302
  const refused = report("apply");
303
303
  expect(refused.status).toBe("refused");
304
304
  expect(refused.refusal).toBe("plan-changed");
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The files a release records for a `terraform-apply` step (chant#4542).
3
+ *
4
+ * The step names its root by key, so core can't see which files it deploys.
5
+ * `terraformReleaseInputs` answers core's `releaseInputs` with the set a
6
+ * pull request's plan treats as the root's change (`rootWatchPaths`): the
7
+ * root's directory, each local module it reaches, and its var files, plus
8
+ * its backend-config files. A release cut records them for the component,
9
+ * and a promote or rollback under the release writes them under
10
+ * `.chant/release-templates/<digest>/` and sets the step's `dir` to the copy
11
+ * of the root, so `terraform-apply` plans the release's configuration against
12
+ * the environment's state.
13
+ *
14
+ * A root whose state is a file in its own directory (the `local` backend) is
15
+ * unbound: a plan run in a copy elsewhere would read no state and propose
16
+ * creating everything. Such a root rolls back through `chant lifecycle
17
+ * rollback --to`. A live choudoufu root has no state file and is recorded.
18
+ */
19
+
20
+ import { dirname, relative, resolve } from "node:path";
21
+ import type { ReleaseInputs, ReleaseInputsContext, ReleaseInputsUnbound } from "@intentius/chant/components/capability";
22
+ import { loadChantConfigUpward } from "@intentius/chant/config";
23
+ import { rootWatchPaths } from "../changed-roots";
24
+ import type { TerraformConfig } from "../config";
25
+ import { liveEstate } from "../compose/hcl";
26
+ import { readBackend } from "../state/backend";
27
+
28
+ /** The parts of the `terraform-apply` input this reads. */
29
+ interface StepInput {
30
+ root: string;
31
+ cwd?: string;
32
+ dir?: string;
33
+ }
34
+
35
+ const toPosix = (p: string): string => p.split("\\").join("/") || ".";
36
+
37
+ /** What a release records for one `terraform-apply` step, relative to the member's directory. Null when the root is not declared. */
38
+ export async function terraformReleaseInputs(input: StepInput, ctx: ReleaseInputsContext): Promise<ReleaseInputs | ReleaseInputsUnbound | null> {
39
+ if (typeof input.root !== "string" || input.root === "") return null;
40
+ const start = resolve(ctx.memberDir, input.cwd ?? ".");
41
+ const { config, configPath } = await loadChantConfigUpward(start);
42
+ const projectRoot = configPath ? dirname(configPath) : start;
43
+ const terraform = (config as { terraform?: TerraformConfig }).terraform;
44
+ const root = terraform?.roots?.[input.root];
45
+ if (!root) return null;
46
+ const binary = terraform?.binary;
47
+ const rootDir = resolve(projectRoot, input.dir ?? root.dir);
48
+ const live = binary === "choudoufu" && (root.estate !== undefined || liveEstate(rootDir, binary).live);
49
+ if (!live) {
50
+ const { backend } = readBackend(rootDir, {
51
+ ...(root.backendConfig ? { backendConfig: root.backendConfig } : {}),
52
+ ...(root.backendConfigFiles ? { backendConfigFiles: root.backendConfigFiles } : {}),
53
+ ...(binary ? { binary } : {}),
54
+ });
55
+ if (backend.type === "local") {
56
+ return {
57
+ unbound:
58
+ `root "${input.root}" keeps its state in a local file under ${toPosix(relative(ctx.memberDir, rootDir))}, ` +
59
+ "so a plan of a recorded copy would read no state; roll it back with chant lifecycle rollback --to <ref>",
60
+ };
61
+ }
62
+ }
63
+ const fromMember = (abs: string): string => toPosix(relative(ctx.memberDir, abs));
64
+ const paths = rootWatchPaths(projectRoot, { dir: relative(projectRoot, rootDir) || ".", ...(root.varFiles ? { varFiles: root.varFiles } : {}) }, binary).map((p) =>
65
+ fromMember(resolve(projectRoot, p)),
66
+ );
67
+ for (const file of root.backendConfigFiles ?? []) paths.push(fromMember(resolve(rootDir, file)));
68
+ return { field: "dir", dir: fromMember(rootDir), paths: [...new Set(paths)].sort() };
69
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `terraform-apply`'s engine facts (INTENTIUS/chant#4555): the root and the
3
+ * binary, for the step's span and run record.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { terraformTraceFacts } from "./trace-facts";
8
+
9
+ describe("terraform-apply's trace facts (#4555)", () => {
10
+ test("name the root, the configured binary and whether the wave's plan applied", () => {
11
+ expect(terraformTraceFacts({ root: "network" }, { output: { root: "network", planDigest: "sha256:x", fromWavePlan: true, outputs: { vpc_id: "v" } } }, "/usr/local/bin/tofu")).toEqual({
12
+ attributes: { root: "network", binary: "tofu", "apply.from_wave_plan": true, "apply.outputs": 1 },
13
+ });
14
+ });
15
+
16
+ test("default to terraform, and keep the root and the cone on a failed apply", () => {
17
+ expect(terraformTraceFacts({ root: "network", cone: "HEAD~1" }, { error: new Error("refused") }, undefined)).toEqual({
18
+ attributes: { root: "network", binary: "terraform", "plan.cone": "HEAD~1" },
19
+ });
20
+ });
21
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `terraform-apply`'s engine facts for its span and op-wave run record
3
+ * (INTENTIUS/chant#4555): the root, the binary that applied it, and whether
4
+ * it applied the plan the wave decided. Core adds the plan's resource counts
5
+ * by action, and writes every key under the CI product's prefix
6
+ * (`terragucci.root`, `terragucci.binary`).
7
+ */
8
+
9
+ import type { CapabilityTraceFacts } from "@intentius/chant/otlp";
10
+ import type { TerraformApplyStepInput, TerraformApplyStepOutput } from "./capability-plugin";
11
+
12
+ /** `traceFacts` for `terraform-apply` (#4555). `binary` is the config's `terraform.binary`, `terraform` when it names none. */
13
+ export function terraformTraceFacts(
14
+ input: TerraformApplyStepInput,
15
+ outcome: { output: TerraformApplyStepOutput } | { error: unknown },
16
+ binary: string | undefined,
17
+ ): CapabilityTraceFacts {
18
+ const bin = (binary ?? "terraform").split(/[\\/]/).pop() || "terraform";
19
+ return {
20
+ attributes: {
21
+ root: input.root,
22
+ binary: bin,
23
+ ...("output" in outcome && outcome.output ? { "apply.from_wave_plan": outcome.output.fromWavePlan, "apply.outputs": Object.keys(outcome.output.outputs ?? {}).length } : {}),
24
+ ...(input.cone ? { "plan.cone": input.cone } : {}),
25
+ },
26
+ };
27
+ }
package/src/cone.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * choudoufu's cone plans (#4545, moved from terragucci's `cone.ts`): `plan
3
+ * -cone=<base>` plans only the blocks a change touches and every block that
4
+ * reads them.
5
+ *
6
+ * A wave that applies an open pull request plans a live root as the cone of
7
+ * its change since the pull request's base, as the plan note did. A wave
8
+ * that applies a merge commit and a drift run plan the whole estate, as do
9
+ * a root with no `live` block, an estate file (`estate.chdf.hcl`), whose
10
+ * form choudoufu refuses `-cone` on, and any binary but choudoufu.
11
+ *
12
+ * choudoufu's guard can plan the whole estate anyway (no base, a provider,
13
+ * terraform or live block or the lock file changed, a cone over 30% of the
14
+ * estate's blocks, and so on). It says why in a warning. A cone plan says
15
+ * its size in a warning too, and with `-out FILE` writes `FILE.cone.json`:
16
+ * the cone's base, the commit it named and the addresses it targets.
17
+ *
18
+ * The record's targets join the root's plan digest ({@link conePlanDigest}),
19
+ * so the digest an approval binds names the cone. A wave that plans again
20
+ * after its approval and finds another cone has another digest, and refuses
21
+ * as moved. choudoufu's `apply FILE` checks the same record again.
22
+ *
23
+ * Pure, apart from reading the record beside a plan file.
24
+ */
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { basename } from "node:path";
27
+ import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
28
+
29
+ /** The flags choudoufu refuses beside `-cone`, or plans in full with: a plan that carries one gets no `-cone`. */
30
+ export const CONE_EXCLUDES = ["-target", "-exclude", "-destroy", "-refresh-only", "-json", "-estate"] as const;
31
+
32
+ /** What choudoufu writes beside a saved cone plan, `FILE.cone.json`. */
33
+ export interface ConeRecord {
34
+ base: string;
35
+ base_sha: string;
36
+ targets: string[];
37
+ }
38
+
39
+ /** What one root's cone plan did: planned the cone, or planned the whole estate for the guard's reason. */
40
+ export interface PlanCone {
41
+ /** The revision the plan was asked to measure the change from. */
42
+ base: string;
43
+ /** With a cone: its blocks, and the estate's, as choudoufu counted them. Absent when it did not say. */
44
+ blocks?: number;
45
+ of?: number;
46
+ /** With a cone: the record beside the plan file. */
47
+ record?: ConeRecord;
48
+ /** The guard planned the whole estate: its kind and why. */
49
+ fallback?: { kind: string; reason: string };
50
+ }
51
+
52
+ /** Whether `binary` (a name or a path) is choudoufu. */
53
+ export const isChoudoufu = (binary: string): boolean => basename(binary) === "choudoufu";
54
+
55
+ /**
56
+ * The `-cone` argument a plan takes: `-cone=<base>` for choudoufu with a
57
+ * base, on a root that takes it (`live`), when none of `args` is a flag in
58
+ * {@link CONE_EXCLUDES}. Otherwise none, and the plan is full.
59
+ */
60
+ export function coneArgs(binary: string, base: string | undefined, args: readonly string[], cone: boolean): string[] {
61
+ if (!base || !cone || !isChoudoufu(binary)) return [];
62
+ const excluded = args.some((a) => CONE_EXCLUDES.some((f) => a === f || a.startsWith(`${f}=`)));
63
+ return excluded ? [] : [`-cone=${base}`];
64
+ }
65
+
66
+ /** The path of the cone record beside a plan file. */
67
+ export const coneRecordPath = (planFile: string): string => `${planFile}.cone.json`;
68
+
69
+ /** The cone record beside `planFile`, or none when there is none or it does not read as one. */
70
+ export function readConeRecord(planFile: string): ConeRecord | undefined {
71
+ const path = coneRecordPath(planFile);
72
+ if (!existsSync(path)) return undefined;
73
+ try {
74
+ const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<ConeRecord> | null;
75
+ if (!r || typeof r.base !== "string" || !Array.isArray(r.targets) || !r.targets.every((t) => typeof t === "string")) return undefined;
76
+ return { base: r.base, base_sha: typeof r.base_sha === "string" ? r.base_sha : "", targets: [...r.targets].sort() };
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The detail of the last warning with this summary in a plan's output, its
84
+ * lines joined. The binary prints a warning as `Warning: <summary>`, a blank
85
+ * line, and the detail up to the next blank line or the plan's summary; a box
86
+ * drawn around it with colour on is stripped.
87
+ */
88
+ function warningDetail(text: string, summary: string): string | undefined {
89
+ const lines = text.replace(/\r\n/g, "\n").split("\n").map((l) => l.replace(/^[\s│╷╵]*/, "").trimEnd());
90
+ let found: string | undefined;
91
+ for (let i = 0; i < lines.length; i++) {
92
+ if (lines[i] !== `Warning: ${summary}`) continue;
93
+ let j = i + 1;
94
+ while (j < lines.length && lines[j] === "") j++;
95
+ const detail: string[] = [];
96
+ for (; j < lines.length && lines[j] !== "" && !/^(Plan: |No changes\.|Changes to Outputs:)/.test(lines[j]!); j++) detail.push(lines[j]!);
97
+ found = detail.join(" ").replace(/\s+/g, " ").trim();
98
+ }
99
+ return found;
100
+ }
101
+
102
+ /**
103
+ * What a plan run with `-cone=<base>` did, from its output and the record
104
+ * beside `planFile`. A fallback warning means the whole estate was planned,
105
+ * even after a cone warning. With no fallback, the record is the cone. With
106
+ * neither, choudoufu said nothing this reads, and the plan counts as full.
107
+ */
108
+ export function readPlanCone(output: string, planFile: string, base: string): PlanCone {
109
+ const fell = warningDetail(output, "Cone plan fell back to a full plan");
110
+ if (fell !== undefined) {
111
+ const m = /^This plan covers the whole estate \(guard ([^)]*)\): (.*?)\.?$/.exec(fell);
112
+ return { base, fallback: m ? { kind: m[1]!, reason: m[2]! } : { kind: "unknown", reason: fell } };
113
+ }
114
+ const record = readConeRecord(planFile);
115
+ if (!record) return { base, fallback: { kind: "unknown", reason: "choudoufu wrote no cone record beside the plan" } };
116
+ const size = /: (\d+) of the estate's (\d+) blocks/.exec(warningDetail(output, "Cone plan") ?? "");
117
+ return { base, ...(size ? { blocks: Number(size[1]), of: Number(size[2]) } : {}), record };
118
+ }
119
+
120
+ /** What a cone plan did, in words: its size, or why it planned the whole estate. */
121
+ export function coneText(cone: PlanCone): string {
122
+ if (cone.fallback) return `the whole estate, since ${cone.fallback.reason} (guard ${cone.fallback.kind})`;
123
+ const n = cone.record?.targets.length ?? 0;
124
+ const size = cone.blocks !== undefined && cone.of !== undefined ? `${cone.blocks} of the estate's ${cone.of} blocks` : `${n} target${n === 1 ? "" : "s"}`;
125
+ return `the cone of the change since ${cone.base}, ${size}`;
126
+ }
127
+
128
+ /**
129
+ * A root's plan digest with its cone in it: the plan's own digest, unchanged,
130
+ * when the plan was full; with a cone, the digest over the plan's digest and
131
+ * the cone's targets, so an approval of one cone counts for no other. The
132
+ * digest's kind is terragucci's, so approvals recorded before the move still
133
+ * name the same digest.
134
+ */
135
+ export function conePlanDigest(planDigest: string, cone: PlanCone | undefined): string {
136
+ if (!cone?.record) return planDigest;
137
+ return computePlanDigest("terragucci-cone-plan", { plan: planDigest, targets: [...cone.record.targets].sort() });
138
+ }