@intentius/chant-lexicon-terraform 0.124.51 → 0.124.54

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 (338) hide show
  1. package/README.md +7 -6
  2. package/dist/__fixtures__/pr-loop/estate.component.d.ts +7 -0
  3. package/dist/__fixtures__/pr-loop/estate.component.d.ts.map +1 -0
  4. package/dist/__fixtures__/pr-loop/estate.component.js +7 -0
  5. package/dist/__fixtures__/report/generate.d.ts +26 -0
  6. package/dist/__fixtures__/report/generate.d.ts.map +1 -0
  7. package/dist/__fixtures__/report/generate.js +58 -0
  8. package/dist/applied-state.d.ts +27 -0
  9. package/dist/applied-state.d.ts.map +1 -0
  10. package/dist/applied-state.js +44 -0
  11. package/dist/backend-config.d.ts +53 -0
  12. package/dist/backend-config.d.ts.map +1 -0
  13. package/dist/backend-config.js +136 -0
  14. package/dist/behaviour/kinds.d.ts.map +1 -1
  15. package/dist/behaviour/kinds.js +4 -0
  16. package/dist/behaviour/predict.d.ts.map +1 -1
  17. package/dist/behaviour/predict.js +6 -2
  18. package/dist/behaviour/reference-catalog.d.ts +16 -5
  19. package/dist/behaviour/reference-catalog.d.ts.map +1 -1
  20. package/dist/behaviour/reference-catalog.js +16 -5
  21. package/dist/behaviour/request.d.ts.map +1 -1
  22. package/dist/behaviour/request.js +3 -2
  23. package/dist/changed-roots.d.ts +6 -4
  24. package/dist/changed-roots.d.ts.map +1 -1
  25. package/dist/changed-roots.js +14 -18
  26. package/dist/commands.d.ts +5 -0
  27. package/dist/commands.d.ts.map +1 -1
  28. package/dist/commands.js +87 -4
  29. package/dist/components/capability-plugin.d.ts +65 -3
  30. package/dist/components/capability-plugin.d.ts.map +1 -1
  31. package/dist/components/capability-plugin.js +197 -13
  32. package/dist/components/compose-group.d.ts +145 -0
  33. package/dist/components/compose-group.d.ts.map +1 -0
  34. package/dist/components/compose-group.js +407 -0
  35. package/dist/components/placeholder-preview.d.ts +78 -0
  36. package/dist/components/placeholder-preview.d.ts.map +1 -0
  37. package/dist/components/placeholder-preview.js +254 -0
  38. package/dist/compose/apply-events.d.ts +46 -0
  39. package/dist/compose/apply-events.d.ts.map +1 -0
  40. package/dist/compose/apply-events.js +77 -0
  41. package/dist/compose/estate.d.ts +194 -0
  42. package/dist/compose/estate.d.ts.map +1 -0
  43. package/dist/compose/estate.js +363 -0
  44. package/dist/compose/hcl.d.ts +99 -0
  45. package/dist/compose/hcl.d.ts.map +1 -0
  46. package/dist/compose/hcl.js +302 -0
  47. package/dist/compose/module-address.d.ts +24 -0
  48. package/dist/compose/module-address.d.ts.map +1 -0
  49. package/dist/compose/module-address.js +43 -0
  50. package/dist/compose/root.d.ts +127 -0
  51. package/dist/compose/root.d.ts.map +1 -0
  52. package/dist/compose/root.js +586 -0
  53. package/dist/compose/run.d.ts +183 -0
  54. package/dist/compose/run.d.ts.map +1 -0
  55. package/dist/compose/run.js +447 -0
  56. package/dist/compose/slice.d.ts +102 -0
  57. package/dist/compose/slice.d.ts.map +1 -0
  58. package/dist/compose/slice.js +249 -0
  59. package/dist/compose/state-address.d.ts +28 -0
  60. package/dist/compose/state-address.d.ts.map +1 -0
  61. package/dist/compose/state-address.js +154 -0
  62. package/dist/compose/state-merge.d.ts +52 -0
  63. package/dist/compose/state-merge.d.ts.map +1 -0
  64. package/dist/compose/state-merge.js +135 -0
  65. package/dist/compose.d.ts +179 -0
  66. package/dist/compose.d.ts.map +1 -0
  67. package/dist/compose.js +482 -0
  68. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  69. package/dist/composites/terraform-apply-op.js +7 -3
  70. package/dist/config.d.ts +59 -13
  71. package/dist/config.d.ts.map +1 -1
  72. package/dist/config.js +43 -9
  73. package/dist/describe-resources.d.ts +74 -12
  74. package/dist/describe-resources.d.ts.map +1 -1
  75. package/dist/describe-resources.js +374 -66
  76. package/dist/drift-read.d.ts +33 -0
  77. package/dist/drift-read.d.ts.map +1 -0
  78. package/dist/drift-read.js +243 -0
  79. package/dist/drift-record.d.ts +147 -0
  80. package/dist/drift-record.d.ts.map +1 -0
  81. package/dist/drift-record.js +383 -0
  82. package/dist/hcl/edges.d.ts.map +1 -1
  83. package/dist/hcl/edges.js +24 -1
  84. package/dist/hcl/files.d.ts +61 -0
  85. package/dist/hcl/files.d.ts.map +1 -0
  86. package/dist/hcl/files.js +221 -0
  87. package/dist/hcl/instances.d.ts +82 -0
  88. package/dist/hcl/instances.d.ts.map +1 -0
  89. package/dist/hcl/instances.js +390 -0
  90. package/dist/hcl/parse.d.ts +81 -9
  91. package/dist/hcl/parse.d.ts.map +1 -1
  92. package/dist/hcl/parse.js +114 -16
  93. package/dist/hcl/roots.d.ts.map +1 -1
  94. package/dist/hcl/roots.js +5 -0
  95. package/dist/hcl/suppressions.d.ts.map +1 -1
  96. package/dist/hcl/suppressions.js +8 -2
  97. package/dist/index.d.ts +11 -2
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +21 -2
  100. package/dist/integrity.json +3 -3
  101. package/dist/live-plan-reasons.d.ts +21 -0
  102. package/dist/live-plan-reasons.d.ts.map +1 -0
  103. package/dist/live-plan-reasons.js +47 -0
  104. package/dist/lsp/option-keys.d.ts.map +1 -1
  105. package/dist/lsp/option-keys.js +2 -0
  106. package/dist/manifest.json +1 -1
  107. package/dist/op/activities/index.d.ts +6 -2
  108. package/dist/op/activities/index.d.ts.map +1 -1
  109. package/dist/op/activities/index.js +5 -1
  110. package/dist/op/activities/live-detect.d.ts +4 -3
  111. package/dist/op/activities/live-detect.d.ts.map +1 -1
  112. package/dist/op/activities/live-detect.js +57 -32
  113. package/dist/op/activities/pin-rollout.d.ts +2 -0
  114. package/dist/op/activities/pin-rollout.d.ts.map +1 -1
  115. package/dist/op/activities/pin-rollout.js +1 -0
  116. package/dist/op/activities/terraform.d.ts +153 -5
  117. package/dist/op/activities/terraform.d.ts.map +1 -1
  118. package/dist/op/activities/terraform.js +205 -20
  119. package/dist/op/activity-contracts.d.ts +27 -0
  120. package/dist/op/activity-contracts.d.ts.map +1 -1
  121. package/dist/op/activity-contracts.js +19 -1
  122. package/dist/op/builders.d.ts +7 -1
  123. package/dist/op/builders.d.ts.map +1 -1
  124. package/dist/op/builders.js +9 -0
  125. package/dist/pin/edit.d.ts.map +1 -1
  126. package/dist/pin/edit.js +35 -0
  127. package/dist/pin/rollout.d.ts +2 -0
  128. package/dist/pin/rollout.d.ts.map +1 -1
  129. package/dist/pin/rollout.js +14 -11
  130. package/dist/plan-shape.d.ts +30 -0
  131. package/dist/plan-shape.d.ts.map +1 -0
  132. package/dist/plan-shape.js +125 -0
  133. package/dist/plugin.d.ts.map +1 -1
  134. package/dist/plugin.js +32 -18
  135. package/dist/report.d.ts +216 -0
  136. package/dist/report.d.ts.map +1 -0
  137. package/dist/report.js +666 -0
  138. package/dist/skills/chant-terraform.md +1 -1
  139. package/dist/state/azure-blob.d.ts +68 -0
  140. package/dist/state/azure-blob.d.ts.map +1 -0
  141. package/dist/state/azure-blob.js +201 -0
  142. package/dist/state/backend.d.ts +158 -0
  143. package/dist/state/backend.d.ts.map +1 -0
  144. package/dist/state/backend.js +406 -0
  145. package/dist/state/exec.d.ts +39 -0
  146. package/dist/state/exec.d.ts.map +1 -0
  147. package/dist/state/exec.js +80 -0
  148. package/dist/state/gcs.d.ts +62 -0
  149. package/dist/state/gcs.d.ts.map +1 -0
  150. package/dist/state/gcs.js +224 -0
  151. package/dist/state/index.d.ts +15 -0
  152. package/dist/state/index.d.ts.map +1 -0
  153. package/dist/state/index.js +14 -0
  154. package/dist/state/locks.d.ts +105 -0
  155. package/dist/state/locks.d.ts.map +1 -0
  156. package/dist/state/locks.js +130 -0
  157. package/dist/state/object-store.d.ts +37 -0
  158. package/dist/state/object-store.d.ts.map +1 -0
  159. package/dist/state/object-store.js +20 -0
  160. package/dist/state/root-states.d.ts +131 -0
  161. package/dist/state/root-states.d.ts.map +1 -0
  162. package/dist/state/root-states.js +241 -0
  163. package/dist/state/s3.d.ts +68 -0
  164. package/dist/state/s3.d.ts.map +1 -0
  165. package/dist/state/s3.js +170 -0
  166. package/dist/state/store.d.ts +51 -0
  167. package/dist/state/store.d.ts.map +1 -0
  168. package/dist/state/store.js +185 -0
  169. package/dist/terragrunt/affected.d.ts.map +1 -1
  170. package/dist/terragrunt/affected.js +2 -1
  171. package/package.json +7 -3
  172. package/src/__fixtures__/ACCEPTANCE.md +69 -7
  173. package/src/__fixtures__/compose/apply-failed-in-app.jsonl +12 -0
  174. package/src/__fixtures__/compose/state-app.json +66 -0
  175. package/src/__fixtures__/compose/state-edge.json +40 -0
  176. package/src/__fixtures__/compose/state-net.json +58 -0
  177. package/src/__fixtures__/compose/three-app.plan.json +184 -0
  178. package/src/__fixtures__/compose/three-composed.plan.json +509 -0
  179. package/src/__fixtures__/compose/three-edge.plan.json +144 -0
  180. package/src/__fixtures__/compose/three-net.plan.json +174 -0
  181. package/src/__fixtures__/compose/unknown-composed.plan.json +228 -0
  182. package/src/__fixtures__/compose/working-after-apply.json +145 -0
  183. package/src/__fixtures__/config-blocks/app/main.tf +61 -0
  184. package/src/__fixtures__/config-blocks/app/modules/network/main.tf +4 -0
  185. package/src/__fixtures__/config-blocks/app-json/main.tf.json +39 -0
  186. package/src/__fixtures__/config-files/shadow/main.tf +3 -0
  187. package/src/__fixtures__/config-files/shadow/main.tofu +3 -0
  188. package/src/__fixtures__/config-files/shadow/other.tf +3 -0
  189. package/src/__fixtures__/config-files/tf/main.tf +11 -0
  190. package/src/__fixtures__/config-files/tf-json/main.tf.json +18 -0
  191. package/src/__fixtures__/config-files/tofu/main.tofu +11 -0
  192. package/src/__fixtures__/config-files/tofu-json/main.tofu.json +18 -0
  193. package/src/__fixtures__/config-files/tofu-only/main.tofu +3 -0
  194. package/src/__fixtures__/drift-record/README.md +11 -0
  195. package/src/__fixtures__/drift-record/live-plan-sections.json +27 -0
  196. package/src/__fixtures__/instance-forms/app/main.tf +110 -0
  197. package/src/__fixtures__/instance-forms/app/modules/svc/main.tf +3 -0
  198. package/src/__fixtures__/instance-vars/app/main.tf +52 -0
  199. package/src/__fixtures__/instance-vars/app/modules/app/main.tf +12 -0
  200. package/src/__fixtures__/instance-vars/app/modules/edge/main.tf +3 -0
  201. package/src/__fixtures__/instance-vars/app/terraform.tfvars +1 -0
  202. package/src/__fixtures__/live-module-keyed/live-graph.json +259 -0
  203. package/src/__fixtures__/live-module-keyed/live-plan.json +87 -0
  204. package/src/__fixtures__/live-module-keyed/root/estate.chdf.hcl +1 -0
  205. package/src/__fixtures__/live-module-keyed/root/main.tf +21 -0
  206. package/src/__fixtures__/live-module-keyed/root/modules/app/main.tf +17 -0
  207. package/src/__fixtures__/live-module-keyed/root/modules/edge/main.tf +7 -0
  208. package/src/__fixtures__/live-module-keyed/root/versions.tf +30 -0
  209. package/src/__fixtures__/live-module-keyed/show-state.json +146 -0
  210. package/src/__fixtures__/pr-loop/README.md +50 -0
  211. package/src/__fixtures__/pr-loop/chant.config.json +13 -0
  212. package/src/__fixtures__/pr-loop/estate.component.ts +10 -0
  213. package/src/__fixtures__/pr-loop/roots/a/main.tf +11 -0
  214. package/src/__fixtures__/pr-loop/roots/app/main.tf +19 -0
  215. package/src/__fixtures__/pr-loop/roots/b/main.tf +7 -0
  216. package/src/__fixtures__/pr-loop/roots/dns/main.tf +3 -0
  217. package/src/__fixtures__/pr-loop/roots/net/main.tf +7 -0
  218. package/src/__fixtures__/report/applied.show.json +51 -0
  219. package/src/__fixtures__/report/generate.ts +63 -0
  220. package/src/__fixtures__/report/plan.show.json +200 -0
  221. package/src/__fixtures__/report/tf-apply/change-set.json +170 -0
  222. package/src/__fixtures__/report/tf-apply/report.json +338 -0
  223. package/src/__fixtures__/report/tf-plan/change-set.json +170 -0
  224. package/src/__fixtures__/report/tf-plan/report.json +320 -0
  225. package/src/__fixtures__/three-cloud-azure.graph.json +290 -0
  226. package/src/applied-state.ts +52 -0
  227. package/src/backend-config.test.ts +105 -0
  228. package/src/backend-config.ts +133 -0
  229. package/src/behaviour/kinds.ts +4 -0
  230. package/src/behaviour/predict.ts +5 -2
  231. package/src/behaviour/reference-catalog.plugin.test.ts +65 -0
  232. package/src/behaviour/reference-catalog.ts +16 -5
  233. package/src/behaviour/request.ts +3 -2
  234. package/src/change-set-instances.test.ts +98 -0
  235. package/src/changed-roots.ts +14 -16
  236. package/src/commands.ts +84 -4
  237. package/src/components/capability-plugin.test.ts +172 -5
  238. package/src/components/capability-plugin.ts +212 -11
  239. package/src/components/change-set-graph.e2e.test.ts +11 -5
  240. package/src/components/compose-group.test.ts +486 -0
  241. package/src/components/compose-group.ts +489 -0
  242. package/src/components/demo-composed-roots.e2e.test.ts +440 -0
  243. package/src/components/member-outputs.test.ts +76 -0
  244. package/src/components/placeholder-preview.test.ts +147 -0
  245. package/src/components/placeholder-preview.ts +257 -0
  246. package/src/components/pr-loop-compose.tofu.e2e.test.ts +363 -0
  247. package/src/components/pr-loop.tofu.test.ts +112 -56
  248. package/src/compose/apply-events.test.ts +62 -0
  249. package/src/compose/apply-events.ts +100 -0
  250. package/src/compose/estate.choudoufu.e2e.test.ts +112 -0
  251. package/src/compose/estate.test.ts +421 -0
  252. package/src/compose/estate.ts +443 -0
  253. package/src/compose/hcl.ts +311 -0
  254. package/src/compose/module-address.ts +49 -0
  255. package/src/compose/root.test.ts +416 -0
  256. package/src/compose/root.ts +592 -0
  257. package/src/compose/run.test.ts +440 -0
  258. package/src/compose/run.tofu.test.ts +176 -0
  259. package/src/compose/run.ts +527 -0
  260. package/src/compose/slice.test.ts +184 -0
  261. package/src/compose/slice.ts +290 -0
  262. package/src/compose/state-address.ts +157 -0
  263. package/src/compose/state-merge.test.ts +131 -0
  264. package/src/compose/state-merge.ts +148 -0
  265. package/src/compose.test.ts +248 -0
  266. package/src/compose.ts +627 -0
  267. package/src/composites/terraform-apply-op.test.ts +9 -0
  268. package/src/composites/terraform-apply-op.ts +7 -3
  269. package/src/composites/terraform-pin-rollout-op.test.ts +2 -2
  270. package/src/config-file-listers.test.ts +265 -0
  271. package/src/config.ts +44 -9
  272. package/src/describe-resources.engine-facts.test.ts +412 -0
  273. package/src/describe-resources.live-module-keyed.test.ts +290 -0
  274. package/src/describe-resources.ts +416 -70
  275. package/src/drift-read.ts +284 -0
  276. package/src/drift-record.test.ts +223 -0
  277. package/src/drift-record.ts +455 -0
  278. package/src/hcl/config-blocks.test.ts +174 -0
  279. package/src/hcl/edges.ts +21 -1
  280. package/src/hcl/files.ts +234 -0
  281. package/src/hcl/instance-forms.test.ts +94 -0
  282. package/src/hcl/instances.test.ts +137 -0
  283. package/src/hcl/instances.ts +422 -0
  284. package/src/hcl/parse.test.ts +139 -1
  285. package/src/hcl/parse.ts +149 -16
  286. package/src/hcl/roots.ts +6 -0
  287. package/src/hcl/suppressions.ts +8 -2
  288. package/src/index.ts +76 -0
  289. package/src/live-plan-reasons.ts +54 -0
  290. package/src/lsp/completions.test.ts +1 -1
  291. package/src/lsp/option-keys.ts +2 -0
  292. package/src/op/activities/choudoufu-plan-set-op.test.ts +111 -0
  293. package/src/op/activities/index.ts +9 -0
  294. package/src/op/activities/live-detect.ts +55 -26
  295. package/src/op/activities/pin-rollout.ts +3 -0
  296. package/src/op/activities/registry.test.ts +2 -1
  297. package/src/op/activities/terraform.test.ts +55 -0
  298. package/src/op/activities/terraform.ts +266 -22
  299. package/src/op/activity-contracts.test.ts +1 -0
  300. package/src/op/activity-contracts.ts +24 -1
  301. package/src/op/builders.ts +14 -0
  302. package/src/pin/bundle.test.ts +2 -0
  303. package/src/pin/edit.ts +30 -0
  304. package/src/pin/rollout.ts +16 -11
  305. package/src/plan-shape.test.ts +148 -0
  306. package/src/plan-shape.ts +128 -0
  307. package/src/plugin.ts +33 -19
  308. package/src/report.test.ts +423 -0
  309. package/src/report.ts +752 -0
  310. package/src/signal-only-root.test.ts +72 -0
  311. package/src/skills/chant-terraform.md +1 -1
  312. package/src/state/azure-blob.ts +223 -0
  313. package/src/state/backend-verdict.test.ts +187 -0
  314. package/src/state/backend.test.ts +127 -0
  315. package/src/state/backend.ts +488 -0
  316. package/src/state/exec.ts +111 -0
  317. package/src/state/gcs.ts +254 -0
  318. package/src/state/index.ts +15 -0
  319. package/src/state/locks.test.ts +84 -0
  320. package/src/state/locks.ts +201 -0
  321. package/src/state/object-store.ts +35 -0
  322. package/src/state/root-states-floci.e2e.test.ts +66 -0
  323. package/src/state/root-states.test.ts +462 -0
  324. package/src/state/root-states.ts +309 -0
  325. package/src/state/s3.ts +193 -0
  326. package/src/state/store.ts +225 -0
  327. package/src/terragrunt/affected.ts +2 -1
  328. package/src/workspace-kinds.test.ts +4 -17
  329. package/workspace-kinds.json +1 -24
  330. package/dist/workspace-member.d.ts +0 -30
  331. package/dist/workspace-member.d.ts.map +0 -1
  332. package/dist/workspace-member.js +0 -91
  333. package/schemas/pulumi.schema.json +0 -34
  334. package/src/testdata/pulumi-member/Pulumi.yaml +0 -2
  335. package/src/testdata/pulumi-member/stack-export.json +0 -53
  336. package/src/testdata/pulumi-unexported/Pulumi.yaml +0 -2
  337. package/src/workspace-member.test.ts +0 -89
  338. package/src/workspace-member.ts +0 -126
@@ -0,0 +1,440 @@
1
+ /**
2
+ * The composed run (#4444) with a fake binary: three roots with local state,
3
+ * B reading A and C reading B through `terraform_remote_state`. The fake
4
+ * answers `init`, `state pull`, `state push`, `plan`, `show` and `apply` the
5
+ * way tofu does for these roots, so the run's order, its locks, its writes
6
+ * and its release are checked without a binary. `run.tofu.test.ts` runs the
7
+ * same roots on tofu.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { basename, join, relative } from "node:path";
13
+ import { afterAll, describe, expect, test } from "vitest";
14
+ import type { DriverComponent } from "@intentius/chant/components/driver";
15
+ import { compositionFor, type Composition } from "../compose";
16
+ import type { TerraformConfig } from "../config";
17
+ import type { BinaryExec, StateFile } from "../state/exec";
18
+ import { lockInfo } from "../state/locks";
19
+ import { UNWRITTEN_SUFFIX } from "../state/root-states";
20
+ import { COMPOSE_DIR, COMPOSE_FILE } from "./root";
21
+ import { ComposedRun, type ComposeHookMoment, type ComposedRunOptions } from "./run";
22
+
23
+ const made: string[] = [];
24
+ afterAll(() => {
25
+ for (const d of made) rmSync(d, { recursive: true, force: true });
26
+ });
27
+ const tmp = (prefix: string): string => {
28
+ const d = mkdtempSync(join(tmpdir(), prefix));
29
+ made.push(d);
30
+ return d;
31
+ };
32
+
33
+ const A = 'resource "terraform_data" "x" {\n input = "a"\n}\n\noutput "id" {\n value = terraform_data.x.id\n}\n';
34
+ const reader = (up: string): string =>
35
+ `data "terraform_remote_state" "${up}" {\n backend = "local"\n config = {\n path = "../${up}/terraform.tfstate"\n }\n}\n\nresource "terraform_data" "x" {\n input = data.terraform_remote_state.${up}.outputs.id\n}\n\noutput "id" {\n value = terraform_data.x.id\n}\n`;
36
+
37
+ const tf = (name: string, dependsOn: string[] = []): DriverComponent => ({ name, dependsOn, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
38
+
39
+ interface Project {
40
+ dir: string;
41
+ c: Composition;
42
+ terraform: TerraformConfig;
43
+ stateFile: (m: string) => string;
44
+ }
45
+
46
+ function project(): Project {
47
+ const dir = tmp("chant-compose-run-");
48
+ for (const [m, text] of Object.entries({ a: A, b: reader("a"), c: reader("b") })) {
49
+ mkdirSync(join(dir, "roots", m), { recursive: true });
50
+ writeFileSync(join(dir, "roots", m, "main.tf"), text);
51
+ }
52
+ const terraform: TerraformConfig = { binary: "tofu", roots: { a: { dir: "roots/a" }, b: { dir: "roots/b" }, c: { dir: "roots/c" } }, compose: { enabled: true } };
53
+ const c = compositionFor({ projectRoot: dir, terraform, components: [tf("a"), tf("b", ["a"]), tf("c", ["b"])], env: {} });
54
+ return { dir, c, terraform, stateFile: (m) => join(dir, "roots", m, "terraform.tfstate") };
55
+ }
56
+
57
+ /** The root `.tf` files of a project, as text, by path: what a run must put back. */
58
+ function rootFiles(dir: string): Record<string, string> {
59
+ const out: Record<string, string> = {};
60
+ const walk = (at: string): void => {
61
+ for (const n of readdirSync(at).sort()) {
62
+ const p = join(at, n);
63
+ if (statSync(p).isDirectory()) walk(p);
64
+ else if (/\.tf(\.json)?$|\.tfvars$/.test(n)) out[relative(dir, p)] = readFileSync(p, "utf-8");
65
+ }
66
+ };
67
+ walk(join(dir, "roots"));
68
+ return out;
69
+ }
70
+
71
+ const lockFiles = (dir: string): string[] => ["a", "b", "c"].filter((m) => existsSync(join(dir, "roots", m, "terraform.tfstate.tflock")));
72
+
73
+ const change = (m: string, actions: string[] = ["create"]) => ({
74
+ address: `module.${m}.terraform_data.x`,
75
+ module_address: `module.${m}`,
76
+ mode: "managed",
77
+ type: "terraform_data",
78
+ name: "x",
79
+ provider_name: "terraform.io/builtin/terraform",
80
+ change: { actions, before: actions[0] === "create" ? null : { id: `${m}-0`, input: m }, after: actions[0] === "create" ? {} : { id: `${m}-0`, input: m }, after_unknown: actions[0] === "create" ? { id: true, input: m !== "a", output: true } : {} },
81
+ });
82
+
83
+ /** The composed plan's `show -json`: each member creates its one resource, unless `noop` names it. */
84
+ function composedPlan(noop: string[] = []): unknown {
85
+ const ms = ["a", "b", "c"];
86
+ return {
87
+ format_version: "1.2",
88
+ resource_changes: ms.map((m) => change(m, noop.includes(m) ? ["no-op"] : ["create"])),
89
+ output_changes: Object.fromEntries(ms.map((m) => [`${m}__id`, noop.includes(m) ? { actions: ["no-op"], before: `${m}-0`, after: `${m}-0`, after_unknown: false } : { actions: ["create"], before: null, after_unknown: true }])),
90
+ planned_values: { outputs: Object.fromEntries(ms.filter((m) => noop.includes(m)).map((m) => [`${m}__id`, { value: `${m}-0`, sensitive: false }])), root_module: {} },
91
+ };
92
+ }
93
+
94
+ const resource = (m: string, id: string, input: string, status?: string): StateFile["resources"][number] => ({
95
+ module: `module.${m}`,
96
+ mode: "managed",
97
+ type: "terraform_data",
98
+ name: "x",
99
+ provider: 'provider["terraform.io/builtin/terraform"]',
100
+ instances: [{ schema_version: 0, ...(status ? { status } : {}), attributes: { id, input, output: input } }],
101
+ });
102
+
103
+ /** A member's own state, as `state pull` prints it: its resource in no module. */
104
+ const memberState = (m: string, id: string, input: string): StateFile => ({
105
+ version: 4,
106
+ terraform_version: "1.10.0",
107
+ serial: 4,
108
+ lineage: `lineage-${m}`,
109
+ outputs: { id: { value: id, type: "string" } },
110
+ resources: [{ mode: "managed", type: "terraform_data", name: "x", provider: 'provider["terraform.io/builtin/terraform"]', instances: [{ schema_version: 0, attributes: { id, input, output: input } }] }],
111
+ });
112
+
113
+ interface FakeOptions {
114
+ /** The plan `show -json` prints. */
115
+ plan?: unknown;
116
+ /** The member whose resource fails to apply. */
117
+ failing?: string;
118
+ /** Members whose `state push` fails. */
119
+ pushFails?: Set<string>;
120
+ /** The plan exits 1. */
121
+ planFails?: boolean;
122
+ }
123
+
124
+ /** A fake tofu for these roots: every call is recorded as `<dir>: <args>`. */
125
+ function fakeTofu(p: Project, o: FakeOptions = {}) {
126
+ const calls: string[] = [];
127
+ const pushed: string[] = [];
128
+ const composeDir = join(p.dir, COMPOSE_DIR);
129
+ const exec: BinaryExec = async (_bin, args, dir) => {
130
+ const who = dir === composeDir ? "compose" : basename(dir);
131
+ // Flags and file paths left out: `b: state push`, `compose: show -json`.
132
+ calls.push(`${who}: ${args.filter((a) => (!a.startsWith("-") || a === "-json") && !a.includes("/")).join(" ")}`);
133
+ const ok = (stdout = "") => ({ code: 0, stdout, out: stdout });
134
+ const [cmd, sub] = args;
135
+ if (cmd === "init") return ok();
136
+ if (cmd === "state" && sub === "pull") return ok(existsSync(join(dir, "terraform.tfstate")) ? readFileSync(join(dir, "terraform.tfstate"), "utf-8") : "");
137
+ if (cmd === "state" && sub === "push") {
138
+ if (o.pushFails?.has(who)) return { code: 1, stdout: "", out: "Error: AccessDenied writing the state" };
139
+ writeFileSync(join(dir, "terraform.tfstate"), readFileSync(args.at(-1)!, "utf-8"));
140
+ pushed.push(who);
141
+ return ok();
142
+ }
143
+ if (cmd === "plan") {
144
+ if (o.planFails) return { code: 1, stdout: "", out: "Error: Unsupported argument" };
145
+ writeFileSync(args.find((a) => a.startsWith("-out="))!.slice(5), "plan");
146
+ return ok("Plan: 3 to add, 0 to change, 0 to destroy.\n");
147
+ }
148
+ if (cmd === "show" && args.includes("-json") && args.at(-1)!.endsWith(".tfplan")) return ok(JSON.stringify(o.plan ?? composedPlan()));
149
+ if (cmd === "show" && args.at(-1)!.endsWith(".tfplan")) return ok("");
150
+ if (cmd === "apply") {
151
+ // What tofu writes: the working state with each member that applied, and one event line per resource.
152
+ const working = JSON.parse(readFileSync(join(composeDir, "terraform.tfstate"), "utf-8")) as StateFile;
153
+ const lines: string[] = [];
154
+ const ids: Record<string, string> = {};
155
+ let stopped = false;
156
+ for (const m of ["a", "b", "c"]) {
157
+ if (stopped) break;
158
+ const addr = `module.${m}.terraform_data.x`;
159
+ if (working.resources.some((r) => r.module === `module.${m}`)) continue;
160
+ lines.push(JSON.stringify({ type: "apply_start", hook: { resource: { addr } } }));
161
+ const input = m === "a" ? "a" : ids[m === "b" ? "a" : "b"]!;
162
+ if (m === o.failing) {
163
+ working.resources.push(resource(m, `${m}-1`, input, "tainted"));
164
+ lines.push(JSON.stringify({ type: "apply_errored", hook: { resource: { addr }, error: "local-exec provisioner error" } }));
165
+ stopped = true;
166
+ continue;
167
+ }
168
+ ids[m] = `${m}-1`;
169
+ working.resources.push(resource(m, ids[m]!, input));
170
+ working.outputs = { ...working.outputs, [`${m}__id`]: { value: ids[m], type: "string" } };
171
+ lines.push(JSON.stringify({ type: "apply_complete", hook: { resource: { addr } } }));
172
+ }
173
+ working.serial += 1;
174
+ writeFileSync(join(composeDir, "terraform.tfstate"), JSON.stringify(working));
175
+ return { code: stopped ? 1 : 0, stdout: lines.join("\n") + "\n", out: lines.join("\n") };
176
+ }
177
+ if (cmd === "show" && args.includes("-json")) {
178
+ const working = JSON.parse(readFileSync(join(composeDir, "terraform.tfstate"), "utf-8")) as StateFile;
179
+ const outputs = Object.fromEntries(Object.entries(working.outputs ?? {}).map(([k, v]) => [k, { value: (v as { value: unknown }).value, sensitive: false }]));
180
+ const child_modules = ["a", "b", "c"].map((m) => ({ address: `module.${m}`, resources: working.resources.filter((r) => r.module === `module.${m}`).map((r) => ({ address: `module.${m}.terraform_data.x`, type: "terraform_data", name: "x" })) }));
181
+ return ok(JSON.stringify({ format_version: "1.0", values: { outputs, root_module: { child_modules } } }));
182
+ }
183
+ return { code: 1, stdout: "", out: `unexpected ${args.join(" ")}` };
184
+ };
185
+ return { exec, calls, pushed };
186
+ }
187
+
188
+ function run(p: Project, exec: BinaryExec, extra: Partial<ComposedRunOptions> = {}): ComposedRun {
189
+ return new ComposedRun({ projectRoot: p.dir, composition: p.c, terraform: p.terraform, binary: { path: "tofu" }, env: {}, work: tmp("chant-compose-work-"), lock: true, runId: "run-1", exec, retryDelays: [0, 0], ...extra });
190
+ }
191
+
192
+ describe("ComposedRun's init takes the root's backend-config files (chant#4459)", () => {
193
+ test("each member's init gets -backend-config=<file> for each file, then its pairs, run in the member's directory", async () => {
194
+ const p = project();
195
+ writeFileSync(join(p.dir, "roots", "a", "backend.hcl"), 'path = "terraform.tfstate"\n');
196
+ const terraform: TerraformConfig = { ...p.terraform, roots: { ...p.terraform.roots, a: { dir: "roots/a", backendConfigFiles: ["backend.hcl"], backendConfig: { workspace_dir: "ws" } } } };
197
+ const fake = fakeTofu(p);
198
+ const inits: Array<[string, string[]]> = [];
199
+ const exec: BinaryExec = async (bin, args, dir, env) => {
200
+ if (args[0] === "init") inits.push([relative(p.dir, dir), args]);
201
+ return fake.exec(bin, args, dir, env);
202
+ };
203
+ const r = run(p, exec, { terraform });
204
+ try {
205
+ await r.plan();
206
+ } finally {
207
+ await r.release();
208
+ }
209
+ expect(inits.find(([d]) => d === join("roots", "a"))![1]).toEqual(["init", "-input=false", "-no-color", "-backend-config=backend.hcl", "-backend-config=workspace_dir=ws"]);
210
+ expect(inits.find(([d]) => d === join("roots", "b"))![1]).toEqual(["init", "-input=false", "-no-color"]);
211
+ });
212
+ });
213
+
214
+ describe("ComposedRun", () => {
215
+ test("one plan names B's and C's reads unknown, one digest covers all three, and one apply writes each state back to its own root, upstreams first", async () => {
216
+ const p = project();
217
+ expect(p.c.order).toEqual(["a", "b", "c"]);
218
+ const before = rootFiles(p.dir);
219
+ const fake = fakeTofu(p);
220
+ const r = run(p, fake.exec);
221
+ try {
222
+ const plan = await r.plan();
223
+ expect(plan.error).toBeUndefined();
224
+ expect(Object.keys(plan.members)).toEqual(["a", "b", "c"]);
225
+ expect(plan.members.b!.reads).toEqual([{ upstream: "a", via: "remote-state", name: "a", outputs: ["id"], unknown: ["id"] }]);
226
+ expect(plan.members.b!.shape.unknownInputs).toEqual(["a.id"]);
227
+ expect(plan.unknownReads).toEqual(["b", "c"]);
228
+ expect(plan.digest).toMatch(/\S/);
229
+ expect(plan.generated.map(([f]) => f)).toEqual([`${COMPOSE_DIR}/main.tf`, `roots/a/${COMPOSE_FILE}`, `roots/b/${COMPOSE_FILE}`, `roots/c/${COMPOSE_FILE}`]);
230
+ // Between the plan and the apply the roots are as they were, and every lock is held.
231
+ expect(rootFiles(p.dir)).toEqual(before);
232
+ expect(r.held()).toEqual(["a", "b", "c"]);
233
+
234
+ const applied = await r.apply();
235
+ expect(applied.ok).toBe(true);
236
+ expect(Object.fromEntries(Object.entries(applied.members).map(([m, o]) => [m, [o.status, o.state]]))).toEqual({ a: ["applied", "written"], b: ["applied", "written"], c: ["applied", "written"] });
237
+ expect(applied.settled).toEqual(["a", "b", "c"]);
238
+ expect(fake.pushed).toEqual(["a", "b", "c"]);
239
+ const a = JSON.parse(readFileSync(p.stateFile("a"), "utf-8")) as StateFile;
240
+ const b = JSON.parse(readFileSync(p.stateFile("b"), "utf-8")) as StateFile;
241
+ expect(a.resources[0]).not.toHaveProperty("module");
242
+ expect((b.resources[0]!.instances![0] as { attributes: { input: string } }).attributes.input).toBe("a-1");
243
+ expect(b.outputs).toEqual({ id: { value: "b-1", type: "string" } });
244
+ expect(applied.members.b!.outputs).toEqual({ id: "b-1" });
245
+ expect(applied.shown.b).toMatchObject({ values: { outputs: { id: { value: "b-1" } } } });
246
+ // Each lock is released once its state is written.
247
+ expect(r.held()).toEqual([]);
248
+ expect(applied.members.c!.outputs).toEqual({ id: "c-1" });
249
+ expect([...applied.members.b!.resources!.keys()]).toEqual(["terraform_data.x"]);
250
+ } finally {
251
+ await r.release();
252
+ }
253
+ expect(r.heldOnPurpose()).toEqual([]);
254
+ expect(existsSync(join(p.dir, COMPOSE_DIR))).toBe(false);
255
+ expect(rootFiles(p.dir)).toEqual(before);
256
+ });
257
+
258
+ test("B fails: A is applied and written, B is failed with what applied of it written, C is not-started and not written", async () => {
259
+ const p = project();
260
+ const fake = fakeTofu(p, { failing: "b" });
261
+ const r = run(p, fake.exec);
262
+ try {
263
+ await r.plan();
264
+ const applied = await r.apply();
265
+ expect(applied.ok).toBe(false);
266
+ expect(applied.members.a).toMatchObject({ status: "applied", state: "written", done: 1, planned: 1 });
267
+ expect(applied.members.b).toMatchObject({ status: "failed", state: "written", done: 0, planned: 1, error: expect.stringContaining("terraform_data.x: local-exec provisioner error") });
268
+ expect(applied.members.c).toMatchObject({ status: "not-started", state: "unchanged", done: 0 });
269
+ expect(applied.members.b!.outputs).toBeUndefined();
270
+ expect(fake.pushed).toEqual(["a", "b"]);
271
+ const b = JSON.parse(readFileSync(p.stateFile("b"), "utf-8")) as StateFile;
272
+ expect((b.resources[0]!.instances![0] as { status: string }).status).toBe("tainted");
273
+ expect(existsSync(p.stateFile("c"))).toBe(false);
274
+ expect(applied.settled).toEqual(["a", "b", "c"]);
275
+ } finally {
276
+ await r.release();
277
+ }
278
+ expect(lockFiles(p.dir)).toEqual([]);
279
+ });
280
+
281
+ test("a lock held by another run stops plan() before any state is pulled, names the holder, and releases what it took", async () => {
282
+ const p = project();
283
+ writeFileSync(`${p.stateFile("b")}.tflock`, lockInfo("x", "2026-10-25T12:00:00.000Z", { operation: "OperationTypeApply", info: "" }, "someone@ci-runner"));
284
+ const before = rootFiles(p.dir);
285
+ const fake = fakeTofu(p);
286
+ const r = run(p, fake.exec);
287
+ const plan = await r.plan();
288
+ expect(plan.stage).toBe("lock");
289
+ expect(plan.member).toBe("b");
290
+ expect(plan.error).toContain("someone@ci-runner");
291
+ expect(plan.members).toEqual({});
292
+ expect(fake.calls.some((c) => c.includes("state pull"))).toBe(false);
293
+ expect(fake.calls.some((c) => c.startsWith("compose:"))).toBe(false);
294
+ // The other run's lock is left; this run's are gone.
295
+ expect(lockFiles(p.dir)).toEqual(["b"]);
296
+ expect(r.held()).toEqual([]);
297
+ expect(rootFiles(p.dir)).toEqual(before);
298
+ expect(existsSync(join(p.dir, COMPOSE_DIR))).toBe(false);
299
+ });
300
+
301
+ test("a write that fails every retry keeps the state unwritten and holds that lock; the next plan() writes it first", async () => {
302
+ const p = project();
303
+ const first = fakeTofu(p, { pushFails: new Set(["b"]) });
304
+ const r1 = run(p, first.exec, { runId: "run-1" });
305
+ let kept: string | undefined;
306
+ try {
307
+ await r1.plan();
308
+ const applied = await r1.apply();
309
+ expect(applied.ok).toBe(false);
310
+ expect(applied.members.b).toMatchObject({ status: "applied", state: "not-written", lockHeld: true, kept: `${p.stateFile("b")}${UNWRITTEN_SUFFIX}` });
311
+ expect(applied.members.b!.error).toContain("state push failed");
312
+ expect(applied.settled).toEqual(["a", "c"]);
313
+ // Three tries: the first and two retries.
314
+ expect(first.calls.filter((c) => c === "b: state push").length).toBe(3);
315
+ kept = readFileSync(`${p.stateFile("b")}${UNWRITTEN_SUFFIX}`, "utf-8");
316
+ } finally {
317
+ await r1.release();
318
+ }
319
+ expect(r1.heldOnPurpose()).toEqual(["b"]);
320
+ expect(lockFiles(p.dir)).toEqual(["b"]);
321
+ expect(existsSync(p.stateFile("b"))).toBe(false);
322
+
323
+ const second = fakeTofu(p);
324
+ const r2 = run(p, second.exec, { runId: "run-2" });
325
+ try {
326
+ expect((await r2.plan()).error).toBeUndefined();
327
+ const push = second.calls.indexOf("b: state push");
328
+ expect(push).toBeGreaterThan(-1);
329
+ expect(push).toBeLessThan(second.calls.findIndex((c) => c.includes("state pull")));
330
+ expect(JSON.parse(readFileSync(p.stateFile("b"), "utf-8"))).toEqual(JSON.parse(kept!));
331
+ expect(existsSync(`${p.stateFile("b")}${UNWRITTEN_SUFFIX}`)).toBe(false);
332
+ expect(r2.held()).toEqual(["a", "b", "c"]);
333
+ } finally {
334
+ await r2.release();
335
+ }
336
+ expect(r2.heldOnPurpose()).toEqual([]);
337
+ expect(lockFiles(p.dir)).toEqual([]);
338
+ });
339
+
340
+ test("a root that planned no change, or whose state came back as pulled, is not written", async () => {
341
+ const p = project();
342
+ // c already has a state and plans no change; b plans a change the apply never reaches.
343
+ writeFileSync(p.stateFile("c"), JSON.stringify(memberState("c", "c-0", "b-0")));
344
+ const fake = fakeTofu(p, { plan: composedPlan(["c"]), failing: "a" });
345
+ const r = run(p, fake.exec);
346
+ try {
347
+ await r.plan();
348
+ const applied = await r.apply();
349
+ expect(applied.members.a).toMatchObject({ status: "failed", state: "written" });
350
+ expect(applied.members.b).toMatchObject({ status: "not-started", state: "unchanged" });
351
+ expect(applied.members.c).toMatchObject({ status: "no-changes", state: "unchanged" });
352
+ expect(fake.pushed).toEqual(["a"]);
353
+ expect(JSON.parse(readFileSync(p.stateFile("c"), "utf-8"))).toEqual(memberState("c", "c-0", "b-0"));
354
+ expect(r.held()).toEqual([]);
355
+ } finally {
356
+ await r.release();
357
+ }
358
+ });
359
+
360
+ test("after release() no lock is held, every root file is as it was and the compose directory is gone, after a thrown error too", async () => {
361
+ const p = project();
362
+ const before = rootFiles(p.dir);
363
+ const fake = fakeTofu(p, { planFails: true });
364
+ const r = run(p, fake.exec);
365
+ const plan = await r.plan();
366
+ expect(plan).toMatchObject({ stage: "plan", error: "the composed plan failed: Error: Unsupported argument", digest: "" });
367
+ expect(fake.calls).toContain("compose: plan");
368
+ expect(lockFiles(p.dir)).toEqual([]);
369
+ expect(rootFiles(p.dir)).toEqual(before);
370
+ for (const m of ["a", "b", "c"]) expect(existsSync(join(p.dir, "roots", m, COMPOSE_FILE))).toBe(false);
371
+ expect(existsSync(join(p.dir, COMPOSE_DIR))).toBe(false);
372
+ // A second release changes nothing.
373
+ await r.release();
374
+ expect(r.heldOnPurpose()).toEqual([]);
375
+ });
376
+
377
+ test("hooks run for each member at the moments of a member applied alone", async () => {
378
+ const p = project();
379
+ const seen: string[] = [];
380
+ const hook = async (member: string, moment: ComposeHookMoment, planFile?: string): Promise<undefined> => {
381
+ seen.push(`${member} ${moment}${planFile ? " (plan)" : ""}`);
382
+ return undefined;
383
+ };
384
+ const r = run(p, fakeTofu(p).exec, { hook });
385
+ try {
386
+ await r.plan();
387
+ await r.apply();
388
+ } finally {
389
+ await r.release();
390
+ }
391
+ for (const m of ["a", "b", "c"]) {
392
+ expect(seen.filter((s) => s.startsWith(`${m} `))).toEqual([`${m} before-observe`, `${m} before-plan`, `${m} after-plan (plan)`, `${m} before-apply (plan)`, `${m} after-apply`]);
393
+ }
394
+ });
395
+
396
+ test("a hook that fails stops the plan and names the member", async () => {
397
+ const p = project();
398
+ const fake = fakeTofu(p);
399
+ const hook = async (member: string, moment: ComposeHookMoment): Promise<string | undefined> => (member === "b" && moment === "before-plan" ? "policy check refused" : undefined);
400
+ const r = run(p, fake.exec, { hook });
401
+ const plan = await r.plan();
402
+ expect(plan).toMatchObject({ stage: "hook", member: "b", error: "b: a before-plan hook failed: policy check refused" });
403
+ expect(fake.calls.some((c) => c.startsWith("compose:"))).toBe(false);
404
+ expect(lockFiles(p.dir)).toEqual([]);
405
+ });
406
+
407
+ test("a hook that throws before the apply leaves every member not-started and writes nothing", async () => {
408
+ const p = project();
409
+ const fake = fakeTofu(p);
410
+ const hook = async (member: string, moment: ComposeHookMoment): Promise<undefined> => {
411
+ if (member === "c" && moment === "before-apply") throw new Error("change window closed");
412
+ return undefined;
413
+ };
414
+ const r = run(p, fake.exec, { hook });
415
+ try {
416
+ await r.plan();
417
+ const applied = await r.apply();
418
+ expect(applied.ok).toBe(false);
419
+ expect(applied.error).toContain("before-apply hook of c failed");
420
+ expect(applied.members.c).toMatchObject({ status: "not-started", state: "unchanged", error: "change window closed" });
421
+ expect(fake.calls.some((c) => c.startsWith("compose: apply"))).toBe(false);
422
+ expect(fake.pushed).toEqual([]);
423
+ } finally {
424
+ await r.release();
425
+ }
426
+ expect(lockFiles(p.dir)).toEqual([]);
427
+ });
428
+
429
+ test("a plan the gate does not let through is released and writes nothing", async () => {
430
+ const p = project();
431
+ const fake = fakeTofu(p);
432
+ const r = run(p, fake.exec);
433
+ await r.plan();
434
+ await r.release();
435
+ expect(fake.pushed).toEqual([]);
436
+ expect(lockFiles(p.dir)).toEqual([]);
437
+ expect(existsSync(join(p.dir, COMPOSE_DIR))).toBe(false);
438
+ await expect(r.apply()).rejects.toThrow("released");
439
+ });
440
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The composed run (#4444) on a real binary: three roots of `terraform_data`,
3
+ * B reading the id A creates through `terraform_remote_state` and C reading
4
+ * B. Local state runs on `tofu` (or `terraform`), skipped when neither is on
5
+ * the PATH. The s3 case runs the same roots against floci, skipped without
6
+ * CHANT_FLOCI_ENDPOINT (for example `docker run -d -p 4611:4566 floci/floci`
7
+ * and `CHANT_FLOCI_ENDPOINT=http://localhost:4611`). Nothing downloads a
8
+ * provider or reaches a cloud account.
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { afterAll, describe, expect, test } from "vitest";
17
+ import type { DriverComponent } from "@intentius/chant/components/driver";
18
+ import { compositionFor } from "../compose";
19
+ import type { TerraformConfig } from "../config";
20
+ import type { StateFile } from "../state/exec";
21
+ import { S3Client, sign } from "../state/s3";
22
+ import { COMPOSE_DIR } from "./root";
23
+ import { ComposedRun } from "./run";
24
+
25
+ function which(bin: string): boolean {
26
+ try {
27
+ execFileSync(bin, ["version"], { stdio: "ignore" });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ const BINARY = which("tofu") ? "tofu" : which("terraform") ? "terraform" : undefined;
34
+ const FLOCI = process.env.CHANT_FLOCI_ENDPOINT?.replace(/\/+$/, "");
35
+
36
+ const made: string[] = [];
37
+ afterAll(() => {
38
+ for (const d of made) rmSync(d, { recursive: true, force: true });
39
+ });
40
+ const tmp = (prefix: string): string => {
41
+ const d = mkdtempSync(join(tmpdir(), prefix));
42
+ made.push(d);
43
+ return d;
44
+ };
45
+
46
+ /** How a root's state is kept: its backend block, and the `config` a reader's `terraform_remote_state` gives to read it. */
47
+ interface Backend {
48
+ block: (m: string) => string;
49
+ read: (m: string) => string;
50
+ }
51
+
52
+ const LOCAL: Backend = { block: () => "", read: (m) => `backend = "local"\n config = {\n path = "../${m}/terraform.tfstate"\n }` };
53
+
54
+ function s3Backend(bucket: string, endpoint: string): Backend {
55
+ const cfg = (m: string) =>
56
+ `bucket = "${bucket}"\n key = "${m}.tfstate"\n region = "us-east-1"\n use_lockfile = true\n use_path_style = true\n skip_credentials_validation = true\n skip_requesting_account_id = true\n skip_metadata_api_check = true\n endpoints = { s3 = "${endpoint}" }`;
57
+ return {
58
+ block: (m) => `terraform {\n backend "s3" {\n ${cfg(m)}\n }\n}\n\n`,
59
+ read: (m) => `backend = "s3"\n config = {\n ${cfg(m)}\n }`,
60
+ };
61
+ }
62
+
63
+ /** A root reading `up`'s id; with `fail`, its resource's provisioner fails, as a provider error would. */
64
+ const reader = (be: Backend, m: string, up: string, fail = false): string =>
65
+ `${be.block(m)}data "terraform_remote_state" "${up}" {\n ${be.read(up)}\n}\n\nresource "terraform_data" "x" {\n input = data.terraform_remote_state.${up}.outputs.id\n${
66
+ fail ? ' provisioner "local-exec" {\n command = "echo injected provider error >&2; exit 1"\n }\n' : ""
67
+ }}\n\noutput "id" {\n value = terraform_data.x.id\n}\n`;
68
+
69
+ const tf = (name: string, dependsOn: string[] = []): DriverComponent => ({ name, dependsOn, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
70
+
71
+ function project(be: Backend, failB = false) {
72
+ const dir = tmp("chant-compose-tofu-");
73
+ const files: Record<string, string> = {
74
+ a: `${be.block("a")}resource "terraform_data" "x" {\n input = "a"\n}\n\noutput "id" {\n value = terraform_data.x.id\n}\n`,
75
+ b: reader(be, "b", "a", failB),
76
+ c: reader(be, "c", "b"),
77
+ };
78
+ for (const [m, text] of Object.entries(files)) {
79
+ mkdirSync(join(dir, "roots", m), { recursive: true });
80
+ writeFileSync(join(dir, "roots", m, "main.tf"), text);
81
+ }
82
+ const terraform: TerraformConfig = { binary: "tofu", roots: { a: { dir: "roots/a" }, b: { dir: "roots/b" }, c: { dir: "roots/c" } }, compose: { enabled: true } };
83
+ const c = compositionFor({ projectRoot: dir, terraform, components: [tf("a"), tf("b", ["a"]), tf("c", ["b"])], env: process.env });
84
+ const env = { ...process.env, ...(FLOCI ? { AWS_ACCESS_KEY_ID: "test", AWS_SECRET_ACCESS_KEY: "test", AWS_REGION: "us-east-1" } : {}) };
85
+ const run = new ComposedRun({ projectRoot: dir, composition: c, terraform, binary: { path: BINARY! }, env, work: tmp("chant-compose-tofu-work-"), lock: true, runId: "tofu-test", retryDelays: [0] });
86
+ return { dir, c, run };
87
+ }
88
+
89
+ const idOf = (s: StateFile): string => (s.resources[0]!.instances![0] as { attributes: { id: string } }).attributes.id;
90
+ /** terraform_data's `input` as state records it: the value itself, or `{ type, value }` (the dynamic attribute's encoding). */
91
+ const inputOf = (s: StateFile): unknown => {
92
+ const input = (s.resources[0]!.instances![0] as { attributes: { input: unknown } }).attributes.input;
93
+ return input !== null && typeof input === "object" && "value" in input ? (input as { value: unknown }).value : input;
94
+ };
95
+
96
+ describe.skipIf(!BINARY)("a composed run on tofu with local state (#4444)", () => {
97
+ const stateOf = (dir: string, m: string): StateFile => JSON.parse(readFileSync(join(dir, "roots", m, "terraform.tfstate"), "utf-8")) as StateFile;
98
+
99
+ test("one plan shows B's input known after apply, one digest covers all three, and one apply creates A, B and C with A's id in B", async () => {
100
+ const { dir, c, run } = project(LOCAL);
101
+ expect(c.fallback).toBeUndefined();
102
+ expect(c.order).toEqual(["a", "b", "c"]);
103
+ try {
104
+ const plan = await run.plan();
105
+ expect(plan.error).toBeUndefined();
106
+ expect(plan.unknownReads).toEqual(["b", "c"]);
107
+ expect(plan.members.b!.shape.unknownInputs).toEqual(["a.id"]);
108
+ const change = (plan.members.b!.slice as { resource_changes: { address: string; change: { after_unknown: Record<string, unknown> } }[] }).resource_changes.find((r) => r.address === "terraform_data.x")!;
109
+ expect(change.change.after_unknown.input).toBe(true);
110
+ expect(plan.digest).toMatch(/\S/);
111
+ expect(Object.keys(plan.members)).toEqual(["a", "b", "c"]);
112
+
113
+ const applied = await run.apply();
114
+ expect(applied.error).toBeUndefined();
115
+ expect(applied.ok).toBe(true);
116
+ for (const m of ["a", "b", "c"]) expect(applied.members[m]).toMatchObject({ status: "applied", state: "written" });
117
+ const a = stateOf(dir, "a");
118
+ const b = stateOf(dir, "b");
119
+ const cs = stateOf(dir, "c");
120
+ expect(inputOf(b)).toBe(idOf(a));
121
+ expect(inputOf(cs)).toBe(idOf(b));
122
+ expect(applied.members.b!.outputs).toEqual({ id: idOf(b) });
123
+ } finally {
124
+ await run.release();
125
+ }
126
+ expect(existsSync(join(dir, COMPOSE_DIR))).toBe(false);
127
+ expect(["a", "b", "c"].filter((m) => existsSync(join(dir, "roots", m, "terraform.tfstate.tflock")))).toEqual([]);
128
+ });
129
+
130
+ test("B failing: A applied and written, B failed with what applied of it written, C not-started", async () => {
131
+ const { dir, run } = project(LOCAL, true);
132
+ try {
133
+ expect((await run.plan()).error).toBeUndefined();
134
+ const applied = await run.apply();
135
+ expect(applied.ok).toBe(false);
136
+ expect(applied.members.a).toMatchObject({ status: "applied", state: "written" });
137
+ expect(applied.members.b).toMatchObject({ status: "failed", state: "written" });
138
+ expect(applied.members.c).toMatchObject({ status: "not-started", state: "unchanged" });
139
+ // B's resource was created and its provisioner failed: its state holds it, tainted, with A's id.
140
+ const b = stateOf(dir, "b");
141
+ expect((b.resources[0]!.instances![0] as { status?: string }).status).toBe("tainted");
142
+ expect(inputOf(b)).toBe(idOf(stateOf(dir, "a")));
143
+ expect(existsSync(join(dir, "roots", "c", "terraform.tfstate"))).toBe(false);
144
+ } finally {
145
+ await run.release();
146
+ }
147
+ });
148
+ });
149
+
150
+ describe.skipIf(!BINARY || !FLOCI)("a composed run on tofu with s3 state on floci (#4444)", () => {
151
+ const bucket = `chant-compose-${process.pid}-${Date.now()}`;
152
+ const creds = { region: "us-east-1", accessKeyId: "test", secretAccessKey: "test" };
153
+
154
+ test("one apply creates A, B and C, and each state is written back to its own key with its lock released", async () => {
155
+ const url = `${FLOCI}/${bucket}`;
156
+ const made = await fetch(url, { method: "PUT", headers: sign(creds, "PUT", url, {}, createHash("sha256").update("").digest("hex"), new Date()) });
157
+ expect(made.ok).toBe(true);
158
+ const client = new S3Client({ bucket, endpoint: FLOCI!, ...creds });
159
+ const { run } = project(s3Backend(bucket, FLOCI!));
160
+ try {
161
+ const plan = await run.plan();
162
+ expect(plan.error).toBeUndefined();
163
+ expect(plan.unknownReads).toEqual(["b", "c"]);
164
+ const applied = await run.apply();
165
+ expect(applied.ok).toBe(true);
166
+ const state = async (m: string): Promise<StateFile> => JSON.parse((await client.get(`${m}.tfstate`))!) as StateFile;
167
+ const a = await state("a");
168
+ const b = await state("b");
169
+ expect(inputOf(b)).toBe(idOf(a));
170
+ expect(inputOf(await state("c"))).toBe(idOf(b));
171
+ } finally {
172
+ await run.release();
173
+ }
174
+ for (const m of ["a", "b", "c"]) expect(await client.get(`${m}.tfstate.tflock`)).toBeUndefined();
175
+ });
176
+ });