@intentius/chant-lexicon-terraform 0.124.52 → 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 (336) 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.map +1 -1
  27. package/dist/commands.js +4 -3
  28. package/dist/components/capability-plugin.d.ts +65 -3
  29. package/dist/components/capability-plugin.d.ts.map +1 -1
  30. package/dist/components/capability-plugin.js +197 -13
  31. package/dist/components/compose-group.d.ts +145 -0
  32. package/dist/components/compose-group.d.ts.map +1 -0
  33. package/dist/components/compose-group.js +407 -0
  34. package/dist/components/placeholder-preview.d.ts +78 -0
  35. package/dist/components/placeholder-preview.d.ts.map +1 -0
  36. package/dist/components/placeholder-preview.js +254 -0
  37. package/dist/compose/apply-events.d.ts +46 -0
  38. package/dist/compose/apply-events.d.ts.map +1 -0
  39. package/dist/compose/apply-events.js +77 -0
  40. package/dist/compose/estate.d.ts +194 -0
  41. package/dist/compose/estate.d.ts.map +1 -0
  42. package/dist/compose/estate.js +363 -0
  43. package/dist/compose/hcl.d.ts +99 -0
  44. package/dist/compose/hcl.d.ts.map +1 -0
  45. package/dist/compose/hcl.js +302 -0
  46. package/dist/compose/module-address.d.ts +24 -0
  47. package/dist/compose/module-address.d.ts.map +1 -0
  48. package/dist/compose/module-address.js +43 -0
  49. package/dist/compose/root.d.ts +127 -0
  50. package/dist/compose/root.d.ts.map +1 -0
  51. package/dist/compose/root.js +586 -0
  52. package/dist/compose/run.d.ts +183 -0
  53. package/dist/compose/run.d.ts.map +1 -0
  54. package/dist/compose/run.js +447 -0
  55. package/dist/compose/slice.d.ts +102 -0
  56. package/dist/compose/slice.d.ts.map +1 -0
  57. package/dist/compose/slice.js +249 -0
  58. package/dist/compose/state-address.d.ts +28 -0
  59. package/dist/compose/state-address.d.ts.map +1 -0
  60. package/dist/compose/state-address.js +154 -0
  61. package/dist/compose/state-merge.d.ts +52 -0
  62. package/dist/compose/state-merge.d.ts.map +1 -0
  63. package/dist/compose/state-merge.js +135 -0
  64. package/dist/compose.d.ts +179 -0
  65. package/dist/compose.d.ts.map +1 -0
  66. package/dist/compose.js +482 -0
  67. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  68. package/dist/composites/terraform-apply-op.js +7 -3
  69. package/dist/config.d.ts +59 -13
  70. package/dist/config.d.ts.map +1 -1
  71. package/dist/config.js +43 -9
  72. package/dist/describe-resources.d.ts +74 -12
  73. package/dist/describe-resources.d.ts.map +1 -1
  74. package/dist/describe-resources.js +374 -66
  75. package/dist/drift-read.d.ts +33 -0
  76. package/dist/drift-read.d.ts.map +1 -0
  77. package/dist/drift-read.js +243 -0
  78. package/dist/drift-record.d.ts +147 -0
  79. package/dist/drift-record.d.ts.map +1 -0
  80. package/dist/drift-record.js +383 -0
  81. package/dist/hcl/edges.d.ts.map +1 -1
  82. package/dist/hcl/edges.js +24 -1
  83. package/dist/hcl/files.d.ts +61 -0
  84. package/dist/hcl/files.d.ts.map +1 -0
  85. package/dist/hcl/files.js +221 -0
  86. package/dist/hcl/instances.d.ts +82 -0
  87. package/dist/hcl/instances.d.ts.map +1 -0
  88. package/dist/hcl/instances.js +390 -0
  89. package/dist/hcl/parse.d.ts +81 -9
  90. package/dist/hcl/parse.d.ts.map +1 -1
  91. package/dist/hcl/parse.js +114 -16
  92. package/dist/hcl/roots.d.ts.map +1 -1
  93. package/dist/hcl/roots.js +5 -0
  94. package/dist/hcl/suppressions.d.ts.map +1 -1
  95. package/dist/hcl/suppressions.js +8 -2
  96. package/dist/index.d.ts +11 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +21 -2
  99. package/dist/integrity.json +3 -3
  100. package/dist/live-plan-reasons.d.ts +21 -0
  101. package/dist/live-plan-reasons.d.ts.map +1 -0
  102. package/dist/live-plan-reasons.js +47 -0
  103. package/dist/lsp/option-keys.d.ts.map +1 -1
  104. package/dist/lsp/option-keys.js +2 -0
  105. package/dist/manifest.json +1 -1
  106. package/dist/op/activities/index.d.ts +6 -2
  107. package/dist/op/activities/index.d.ts.map +1 -1
  108. package/dist/op/activities/index.js +5 -1
  109. package/dist/op/activities/live-detect.d.ts +4 -3
  110. package/dist/op/activities/live-detect.d.ts.map +1 -1
  111. package/dist/op/activities/live-detect.js +57 -32
  112. package/dist/op/activities/pin-rollout.d.ts +2 -0
  113. package/dist/op/activities/pin-rollout.d.ts.map +1 -1
  114. package/dist/op/activities/pin-rollout.js +1 -0
  115. package/dist/op/activities/terraform.d.ts +153 -5
  116. package/dist/op/activities/terraform.d.ts.map +1 -1
  117. package/dist/op/activities/terraform.js +205 -20
  118. package/dist/op/activity-contracts.d.ts +27 -0
  119. package/dist/op/activity-contracts.d.ts.map +1 -1
  120. package/dist/op/activity-contracts.js +19 -1
  121. package/dist/op/builders.d.ts +7 -1
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/builders.js +9 -0
  124. package/dist/pin/edit.d.ts.map +1 -1
  125. package/dist/pin/edit.js +35 -0
  126. package/dist/pin/rollout.d.ts +2 -0
  127. package/dist/pin/rollout.d.ts.map +1 -1
  128. package/dist/pin/rollout.js +14 -11
  129. package/dist/plan-shape.d.ts +30 -0
  130. package/dist/plan-shape.d.ts.map +1 -0
  131. package/dist/plan-shape.js +125 -0
  132. package/dist/plugin.d.ts.map +1 -1
  133. package/dist/plugin.js +32 -18
  134. package/dist/report.d.ts +15 -6
  135. package/dist/report.d.ts.map +1 -1
  136. package/dist/report.js +28 -55
  137. package/dist/skills/chant-terraform.md +1 -1
  138. package/dist/state/azure-blob.d.ts +68 -0
  139. package/dist/state/azure-blob.d.ts.map +1 -0
  140. package/dist/state/azure-blob.js +201 -0
  141. package/dist/state/backend.d.ts +158 -0
  142. package/dist/state/backend.d.ts.map +1 -0
  143. package/dist/state/backend.js +406 -0
  144. package/dist/state/exec.d.ts +39 -0
  145. package/dist/state/exec.d.ts.map +1 -0
  146. package/dist/state/exec.js +80 -0
  147. package/dist/state/gcs.d.ts +62 -0
  148. package/dist/state/gcs.d.ts.map +1 -0
  149. package/dist/state/gcs.js +224 -0
  150. package/dist/state/index.d.ts +15 -0
  151. package/dist/state/index.d.ts.map +1 -0
  152. package/dist/state/index.js +14 -0
  153. package/dist/state/locks.d.ts +105 -0
  154. package/dist/state/locks.d.ts.map +1 -0
  155. package/dist/state/locks.js +130 -0
  156. package/dist/state/object-store.d.ts +37 -0
  157. package/dist/state/object-store.d.ts.map +1 -0
  158. package/dist/state/object-store.js +20 -0
  159. package/dist/state/root-states.d.ts +131 -0
  160. package/dist/state/root-states.d.ts.map +1 -0
  161. package/dist/state/root-states.js +241 -0
  162. package/dist/state/s3.d.ts +68 -0
  163. package/dist/state/s3.d.ts.map +1 -0
  164. package/dist/state/s3.js +170 -0
  165. package/dist/state/store.d.ts +51 -0
  166. package/dist/state/store.d.ts.map +1 -0
  167. package/dist/state/store.js +185 -0
  168. package/dist/terragrunt/affected.d.ts.map +1 -1
  169. package/dist/terragrunt/affected.js +2 -1
  170. package/package.json +7 -3
  171. package/src/__fixtures__/ACCEPTANCE.md +69 -7
  172. package/src/__fixtures__/compose/apply-failed-in-app.jsonl +12 -0
  173. package/src/__fixtures__/compose/state-app.json +66 -0
  174. package/src/__fixtures__/compose/state-edge.json +40 -0
  175. package/src/__fixtures__/compose/state-net.json +58 -0
  176. package/src/__fixtures__/compose/three-app.plan.json +184 -0
  177. package/src/__fixtures__/compose/three-composed.plan.json +509 -0
  178. package/src/__fixtures__/compose/three-edge.plan.json +144 -0
  179. package/src/__fixtures__/compose/three-net.plan.json +174 -0
  180. package/src/__fixtures__/compose/unknown-composed.plan.json +228 -0
  181. package/src/__fixtures__/compose/working-after-apply.json +145 -0
  182. package/src/__fixtures__/config-blocks/app/main.tf +61 -0
  183. package/src/__fixtures__/config-blocks/app/modules/network/main.tf +4 -0
  184. package/src/__fixtures__/config-blocks/app-json/main.tf.json +39 -0
  185. package/src/__fixtures__/config-files/shadow/main.tf +3 -0
  186. package/src/__fixtures__/config-files/shadow/main.tofu +3 -0
  187. package/src/__fixtures__/config-files/shadow/other.tf +3 -0
  188. package/src/__fixtures__/config-files/tf/main.tf +11 -0
  189. package/src/__fixtures__/config-files/tf-json/main.tf.json +18 -0
  190. package/src/__fixtures__/config-files/tofu/main.tofu +11 -0
  191. package/src/__fixtures__/config-files/tofu-json/main.tofu.json +18 -0
  192. package/src/__fixtures__/config-files/tofu-only/main.tofu +3 -0
  193. package/src/__fixtures__/drift-record/README.md +11 -0
  194. package/src/__fixtures__/drift-record/live-plan-sections.json +27 -0
  195. package/src/__fixtures__/instance-forms/app/main.tf +110 -0
  196. package/src/__fixtures__/instance-forms/app/modules/svc/main.tf +3 -0
  197. package/src/__fixtures__/instance-vars/app/main.tf +52 -0
  198. package/src/__fixtures__/instance-vars/app/modules/app/main.tf +12 -0
  199. package/src/__fixtures__/instance-vars/app/modules/edge/main.tf +3 -0
  200. package/src/__fixtures__/instance-vars/app/terraform.tfvars +1 -0
  201. package/src/__fixtures__/live-module-keyed/live-graph.json +259 -0
  202. package/src/__fixtures__/live-module-keyed/live-plan.json +87 -0
  203. package/src/__fixtures__/live-module-keyed/root/estate.chdf.hcl +1 -0
  204. package/src/__fixtures__/live-module-keyed/root/main.tf +21 -0
  205. package/src/__fixtures__/live-module-keyed/root/modules/app/main.tf +17 -0
  206. package/src/__fixtures__/live-module-keyed/root/modules/edge/main.tf +7 -0
  207. package/src/__fixtures__/live-module-keyed/root/versions.tf +30 -0
  208. package/src/__fixtures__/live-module-keyed/show-state.json +146 -0
  209. package/src/__fixtures__/pr-loop/README.md +50 -0
  210. package/src/__fixtures__/pr-loop/chant.config.json +13 -0
  211. package/src/__fixtures__/pr-loop/estate.component.ts +10 -0
  212. package/src/__fixtures__/pr-loop/roots/a/main.tf +11 -0
  213. package/src/__fixtures__/pr-loop/roots/app/main.tf +19 -0
  214. package/src/__fixtures__/pr-loop/roots/b/main.tf +7 -0
  215. package/src/__fixtures__/pr-loop/roots/dns/main.tf +3 -0
  216. package/src/__fixtures__/pr-loop/roots/net/main.tf +7 -0
  217. package/src/__fixtures__/report/applied.show.json +51 -0
  218. package/src/__fixtures__/report/generate.ts +63 -0
  219. package/src/__fixtures__/report/plan.show.json +200 -0
  220. package/src/__fixtures__/report/tf-apply/change-set.json +170 -0
  221. package/src/__fixtures__/report/tf-apply/report.json +338 -0
  222. package/src/__fixtures__/report/tf-plan/change-set.json +170 -0
  223. package/src/__fixtures__/report/tf-plan/report.json +320 -0
  224. package/src/__fixtures__/three-cloud-azure.graph.json +290 -0
  225. package/src/applied-state.ts +52 -0
  226. package/src/backend-config.test.ts +105 -0
  227. package/src/backend-config.ts +133 -0
  228. package/src/behaviour/kinds.ts +4 -0
  229. package/src/behaviour/predict.ts +5 -2
  230. package/src/behaviour/reference-catalog.plugin.test.ts +65 -0
  231. package/src/behaviour/reference-catalog.ts +16 -5
  232. package/src/behaviour/request.ts +3 -2
  233. package/src/change-set-instances.test.ts +98 -0
  234. package/src/changed-roots.ts +14 -16
  235. package/src/commands.ts +4 -3
  236. package/src/components/capability-plugin.test.ts +172 -5
  237. package/src/components/capability-plugin.ts +212 -11
  238. package/src/components/change-set-graph.e2e.test.ts +11 -5
  239. package/src/components/compose-group.test.ts +486 -0
  240. package/src/components/compose-group.ts +489 -0
  241. package/src/components/demo-composed-roots.e2e.test.ts +440 -0
  242. package/src/components/member-outputs.test.ts +76 -0
  243. package/src/components/placeholder-preview.test.ts +147 -0
  244. package/src/components/placeholder-preview.ts +257 -0
  245. package/src/components/pr-loop-compose.tofu.e2e.test.ts +363 -0
  246. package/src/components/pr-loop.tofu.test.ts +112 -56
  247. package/src/compose/apply-events.test.ts +62 -0
  248. package/src/compose/apply-events.ts +100 -0
  249. package/src/compose/estate.choudoufu.e2e.test.ts +112 -0
  250. package/src/compose/estate.test.ts +421 -0
  251. package/src/compose/estate.ts +443 -0
  252. package/src/compose/hcl.ts +311 -0
  253. package/src/compose/module-address.ts +49 -0
  254. package/src/compose/root.test.ts +416 -0
  255. package/src/compose/root.ts +592 -0
  256. package/src/compose/run.test.ts +440 -0
  257. package/src/compose/run.tofu.test.ts +176 -0
  258. package/src/compose/run.ts +527 -0
  259. package/src/compose/slice.test.ts +184 -0
  260. package/src/compose/slice.ts +290 -0
  261. package/src/compose/state-address.ts +157 -0
  262. package/src/compose/state-merge.test.ts +131 -0
  263. package/src/compose/state-merge.ts +148 -0
  264. package/src/compose.test.ts +248 -0
  265. package/src/compose.ts +627 -0
  266. package/src/composites/terraform-apply-op.test.ts +9 -0
  267. package/src/composites/terraform-apply-op.ts +7 -3
  268. package/src/config-file-listers.test.ts +265 -0
  269. package/src/config.ts +44 -9
  270. package/src/describe-resources.engine-facts.test.ts +412 -0
  271. package/src/describe-resources.live-module-keyed.test.ts +290 -0
  272. package/src/describe-resources.ts +416 -70
  273. package/src/drift-read.ts +284 -0
  274. package/src/drift-record.test.ts +223 -0
  275. package/src/drift-record.ts +455 -0
  276. package/src/hcl/config-blocks.test.ts +174 -0
  277. package/src/hcl/edges.ts +21 -1
  278. package/src/hcl/files.ts +234 -0
  279. package/src/hcl/instance-forms.test.ts +94 -0
  280. package/src/hcl/instances.test.ts +137 -0
  281. package/src/hcl/instances.ts +422 -0
  282. package/src/hcl/parse.test.ts +139 -1
  283. package/src/hcl/parse.ts +149 -16
  284. package/src/hcl/roots.ts +6 -0
  285. package/src/hcl/suppressions.ts +8 -2
  286. package/src/index.ts +76 -0
  287. package/src/live-plan-reasons.ts +54 -0
  288. package/src/lsp/completions.test.ts +1 -1
  289. package/src/lsp/option-keys.ts +2 -0
  290. package/src/op/activities/choudoufu-plan-set-op.test.ts +111 -0
  291. package/src/op/activities/index.ts +9 -0
  292. package/src/op/activities/live-detect.ts +55 -26
  293. package/src/op/activities/pin-rollout.ts +3 -0
  294. package/src/op/activities/registry.test.ts +2 -1
  295. package/src/op/activities/terraform.test.ts +55 -0
  296. package/src/op/activities/terraform.ts +266 -22
  297. package/src/op/activity-contracts.test.ts +1 -0
  298. package/src/op/activity-contracts.ts +24 -1
  299. package/src/op/builders.ts +14 -0
  300. package/src/pin/bundle.test.ts +2 -0
  301. package/src/pin/edit.ts +30 -0
  302. package/src/pin/rollout.ts +16 -11
  303. package/src/plan-shape.test.ts +148 -0
  304. package/src/plan-shape.ts +128 -0
  305. package/src/plugin.ts +33 -19
  306. package/src/report.test.ts +82 -1
  307. package/src/report.ts +31 -54
  308. package/src/signal-only-root.test.ts +72 -0
  309. package/src/skills/chant-terraform.md +1 -1
  310. package/src/state/azure-blob.ts +223 -0
  311. package/src/state/backend-verdict.test.ts +187 -0
  312. package/src/state/backend.test.ts +127 -0
  313. package/src/state/backend.ts +488 -0
  314. package/src/state/exec.ts +111 -0
  315. package/src/state/gcs.ts +254 -0
  316. package/src/state/index.ts +15 -0
  317. package/src/state/locks.test.ts +84 -0
  318. package/src/state/locks.ts +201 -0
  319. package/src/state/object-store.ts +35 -0
  320. package/src/state/root-states-floci.e2e.test.ts +66 -0
  321. package/src/state/root-states.test.ts +462 -0
  322. package/src/state/root-states.ts +309 -0
  323. package/src/state/s3.ts +193 -0
  324. package/src/state/store.ts +225 -0
  325. package/src/terragrunt/affected.ts +2 -1
  326. package/src/workspace-kinds.test.ts +4 -17
  327. package/workspace-kinds.json +1 -24
  328. package/dist/workspace-member.d.ts +0 -30
  329. package/dist/workspace-member.d.ts.map +0 -1
  330. package/dist/workspace-member.js +0 -91
  331. package/schemas/pulumi.schema.json +0 -34
  332. package/src/testdata/pulumi-member/Pulumi.yaml +0 -2
  333. package/src/testdata/pulumi-member/stack-export.json +0 -53
  334. package/src/testdata/pulumi-unexported/Pulumi.yaml +0 -2
  335. package/src/workspace-member.test.ts +0 -89
  336. package/src/workspace-member.ts +0 -126
@@ -11,8 +11,17 @@
11
11
  * The roots use `terraform_data`, which is built into `tofu` and `terraform`,
12
12
  * with local state, so nothing downloads a provider or reaches a cloud. The
13
13
  * estate is net -> a, b and a -> app, with dns on its own; a and b read net's
14
- * output and app reads a's, through `stackOutput()`. Skipped when neither
15
- * binary is on the PATH.
14
+ * output and app reads a's, through `stackOutput()`. It lives in
15
+ * `../__fixtures__/pr-loop`, whose README also gives chant#4463's recorded
16
+ * run over it. Skipped when neither binary is on the PATH.
17
+ *
18
+ * app is previewed with a placeholder for `var.subnet` (#4463). A pull
19
+ * request that makes a's id end in `-v2` stays within that preview, so one
20
+ * approval applies both roots. One that makes it end in `-wide` adds
21
+ * `terraform_data.wide[0]` to app's real plan through a count the preview
22
+ * does not follow, so app's round 2 waits on its own gate (#4487).
23
+ *
24
+ * Composed runs over three roots are `pr-loop-compose.tofu.e2e.test.ts`.
16
25
  */
17
26
 
18
27
  import { execFileSync } from "node:child_process";
@@ -22,7 +31,7 @@ import { dirname, join } from "node:path";
22
31
  import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
23
32
  import type { ParsedArgs } from "@intentius/chant/cli/registry";
24
33
  import type { PrReport } from "@intentius/chant/pr-loop";
25
- import { buildGraphIr } from "@intentius/chant/graph-ir";
34
+ import { buildGraphIr, withInstanceNodes } from "@intentius/chant/graph-ir";
26
35
  import { terraformPlugin } from "../plugin";
27
36
  import { renderTerraformRoots } from "../hcl/roots";
28
37
 
@@ -38,40 +47,27 @@ function which(bin: string): boolean {
38
47
  }
39
48
  const BINARY = which("tofu") ? "tofu" : which("terraform") ? "terraform" : undefined;
40
49
 
41
- const ROOTS: Record<string, string> = {
42
- net: 'resource "terraform_data" "vpc" {\n input = "10.0.0.0/16"\n}\n\noutput "cidr" {\n value = terraform_data.vpc.output\n}\n',
43
- a: 'variable "cidr" {\n type = string\n}\n\nresource "terraform_data" "subnet" {\n input = "${var.cidr}/a-v1"\n}\n\noutput "id" {\n value = terraform_data.subnet.output\n}\n',
44
- b: 'variable "cidr" {\n type = string\n}\n\nresource "terraform_data" "subnet" {\n input = "${var.cidr}/b-v1"\n}\n',
45
- app: 'variable "subnet" {\n type = string\n}\n\nresource "terraform_data" "app" {\n input = "app on ${var.subnet}"\n}\n',
46
- dns: 'resource "terraform_data" "zone" {\n input = "example.test"\n}\n',
47
- };
50
+ /** The workspace, `../__fixtures__/pr-loop` (its README says what each pull request below exercises). */
51
+ const FIXTURE = join(import.meta.dirname, "..", "__fixtures__", "pr-loop");
52
+ const ROOT_NAMES = ["net", "a", "b", "app", "dns"];
53
+ const ROOTS: Record<string, string> = Object.fromEntries(ROOT_NAMES.map((r) => [r, readFileSync(join(FIXTURE, "roots", r, "main.tf"), "utf8")]));
48
54
 
49
55
  // The source passes `chant lint`, since `chant graph --format ir`, which maps
50
56
  // the stored change set onto nodes (#4022), refuses source that does not.
51
57
  /**
52
58
  * The estate's graph node ids, read in process with the pair `chant graph
53
59
  * --format ir` runs for terraform roots (`renderTerraformRoots`, then
54
- * `buildGraphIr`), so pr-apply maps its stored change set without starting a
60
+ * `buildGraphIr` and `withInstanceNodes`), so pr-apply maps its stored change set without starting a
55
61
  * chant child process. `change-set-graph.e2e.test.ts` holds these ids equal
56
62
  * to the spawned `chant graph --format ir`'s.
57
63
  */
58
64
  async function estateGraphIds(projectRoot: string): Promise<{ ids: string[] }> {
59
65
  const config = JSON.parse(readFileSync(join(projectRoot, "chant.config.json"), "utf8")) as { terraform: { binary?: string; roots: Record<string, { dir: string }> } };
60
66
  const rendered = await renderTerraformRoots({ projectRoot, roots: config.terraform.roots, ...(config.terraform.binary ? { binary: config.terraform.binary } : {}) });
61
- return { ids: buildGraphIr(rendered.entities).nodes.map((n) => n.id) };
67
+ return { ids: withInstanceNodes(buildGraphIr(rendered.entities), rendered.entities).nodes.map((n) => n.id) };
62
68
  }
63
69
 
64
- const COMPONENTS = `import { phase, stackOutput, type Component } from "@intentius/chant/components";
65
-
66
- const apply = (root: string, vars?: Record<string, unknown>) =>
67
- [phase("Apply", [{ kind: "terraform-apply", root, noRollback: "test roots with local state", vars: vars ?? {} }])];
68
-
69
- export const net: Component = { name: "net", dependsOn: [], deploy: apply("net") };
70
- export const a: Component = { name: "a", dependsOn: ["net"], deploy: apply("a", { cidr: stackOutput("net", "cidr") }) };
71
- export const b: Component = { name: "b", dependsOn: ["net"], deploy: apply("b", { cidr: stackOutput("net", "cidr") }) };
72
- export const app: Component = { name: "app", dependsOn: ["a"], deploy: apply("app", { subnet: stackOutput("a", "id") }) };
73
- export const dns: Component = { name: "dns", dependsOn: [], deploy: apply("dns") };
74
- `;
70
+ const COMPONENTS = readFileSync(join(FIXTURE, "estate.component.ts"), "utf8");
75
71
 
76
72
  /** GitHub as far as the loop uses it: comments, statuses, reviews and the merged pull request of a commit. */
77
73
  class FakeGithub {
@@ -215,14 +211,20 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
215
211
  expect(plan.status).toBe("planned");
216
212
  expect(plan.selection).toMatchObject({ changed: ["a"], dependents: ["app"], waves: [["a"], ["app"]] });
217
213
  // a's id is terraform_data's output, which tofu knows only after apply,
218
- // so app is not planned against the id a has now (#3999): the note says
219
- // the approval does not cover what app does with it.
214
+ // so app is not planned against the id a has now (#3999). It is previewed
215
+ // with a stand-in for var.subnet instead (#4463): its update is in the
216
+ // change set, and the note says the approval covers app's plan once a
217
+ // applies only while that plan does what the preview shows.
220
218
  expect(plan.members.map((m) => [m.member, m.counts.update, m.unknownInputs])).toEqual([
221
219
  ["a", 1, undefined],
222
- ["app", 0, ["a.id"]],
220
+ ["app", 1, ["a.id"]],
223
221
  ]);
224
- expect(plan.changeSet.members.find((m) => m.member === "app")?.holes).toEqual([
225
- { address: "app", type: "terraform_root", reason: "inputs from a; planned again once they apply" },
222
+ const app = plan.changeSet.members.find((m) => m.member === "app");
223
+ expect(app?.holes).toEqual([]);
224
+ expect(app?.placeholders).toEqual(["var.subnet"]);
225
+ expect(plan.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([
226
+ ["a", "terraform_data.subnet", "update"],
227
+ ["app", "terraform_data.app", "update"],
226
228
  ]);
227
229
  expect(plan.plannedOutputs).toEqual({ a: [] });
228
230
  expect(plan.deployedOutputs).toEqual({ net: ["cidr"] });
@@ -233,6 +235,7 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
233
235
  expect(note.body.startsWith("<!-- chant-pr:local -->")).toBe(true);
234
236
  expect(note.body).toContain(`chant approve pr-1 pr-apply --plan ${plan.digest} --approver github:<your-login> --sign`);
235
237
  expect(note.body).toContain("Known only after apply: `app` reads `a.id`.");
238
+ expect(note.body).toContain("previewed with placeholders for `var.subnet`");
236
239
  expect(github.statuses.at(-1)).toMatchObject({ context: "chant/plan", state: "success", sha: plan.head });
237
240
  }, 240_000);
238
241
 
@@ -241,43 +244,29 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
241
244
  github.reviews[1] = [{ user: { login: "alice" }, state: "APPROVED" }];
242
245
  expect((await recordGateApproval("pr-1", "pr-apply", { actor: "github:alice", plan: plan.digest })).ok).toBe(true);
243
246
 
244
- // a applies; app, planned now against the id a applied as, waits on its own gate.
245
- expect(await merge(1)).toBe(3);
246
- const waiting = report("apply");
247
- expect(waiting.digest).toBe(plan.digest);
248
- expect(waiting.status).toBe("waiting");
249
- expect(waiting.members.map((m) => [m.member, m.status, m.round])).toEqual([
250
- ["a", "applied", undefined],
251
- ["app", "waiting", 2],
252
- ]);
253
- const round = waiting.rounds![0]!;
254
- expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", components: ["app"], status: "waiting", approval: { status: "pending" } });
255
- expect(round.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([["app", "terraform_data.app", "update"]]);
256
- expect(state("a")).toContain("10.0.0.0/16/a-v2");
257
- expect(state("app")).not.toContain("a-v2");
258
- expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "pending" });
259
- expect(github.comments.find((c) => c.pr === 1)!.body).toContain(`chant approve pr-1 pr-apply-round-2 --plan ${round.digest}`);
260
- }, 240_000);
261
-
262
- test("once the reviewer approves the round's plan, the same apply carries on from its record and applies the dependent (#3999)", async () => {
263
- const plan = report("plan");
264
- const round = report("apply").rounds![0]!;
265
- expect((await recordGateApproval("pr-1", "pr-apply-round-2", { actor: "github:alice", plan: round.digest })).ok).toBe(true);
266
- expect(await handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never)).toBe(0);
247
+ // a applies; app is planned again against the id a applied as. That plan
248
+ // does what the placeholder preview showed, with a.id filled in, so the
249
+ // first approval covers it and it applies in the same run (#4463).
250
+ expect(await merge(1)).toBe(0);
267
251
  const applied = report("apply");
268
252
  expect(applied.digest).toBe(plan.digest);
269
253
  expect(applied.status).toBe("applied");
270
254
  expect(applied.approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
271
- expect(applied.resumed).toEqual({ applied: ["a"] });
272
255
  expect(applied.members.map((m) => [m.member, m.status, m.round])).toEqual([
273
256
  ["a", "applied", undefined],
274
257
  ["app", "applied", 2],
275
258
  ]);
276
- expect(applied.rounds?.map((r) => [r.digest, r.status, r.approval.approvedBy])).toEqual([[round.digest, "applied", ["github:alice"]]]);
259
+ const round = applied.rounds![0]!;
260
+ expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", components: ["app"], status: "applied" });
261
+ expect(round.covered).toEqual({ digest: plan.digest, filledInputs: ["a.id"] });
262
+ expect(round.notCovered).toBeUndefined();
263
+ expect(round.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([["app", "terraform_data.app", "update"]]);
264
+ expect(state("a")).toContain("10.0.0.0/16/a-v2");
277
265
  expect(state("app")).toContain("app on 10.0.0.0/16/a-v2");
278
- // The same note, now with the apply's outcome.
279
266
  expect(github.comments.filter((c) => c.pr === 1)).toHaveLength(1);
280
- expect(github.comments.find((c) => c.pr === 1)!.body).toContain("### chant apply for `local`");
267
+ const body = github.comments.find((c) => c.pr === 1)!.body;
268
+ expect(body).toContain("### chant apply for `local`");
269
+ expect(body).toContain(`covered by the approval of \`${plan.digest}\`, with \`a.id\` filled in, so it applied without a new approval`);
281
270
  expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "success", sha: applied.head });
282
271
  }, 240_000);
283
272
 
@@ -291,9 +280,10 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
291
280
  expect(stored.digest).toBe(applied.digest);
292
281
  expect(stored.graph).toEqual({ member: null, at: applied.head });
293
282
  // The plan's addresses, each on the block the terraform lexicon's graph names `<root>/<address>`.
294
- // app's plan had a hole for a's id, and no entries.
283
+ // app's entry is its placeholder preview (#4463).
295
284
  expect(stored.entries.map((e) => [e.member, e.address, e.action, e.node])).toEqual([
296
285
  ["a", "terraform_data.subnet", "update", "a/terraform_data.subnet"],
286
+ ["app", "terraform_data.app", "update", "app/terraform_data.app"],
297
287
  ]);
298
288
  // And those are ids the estate's graph has.
299
289
  const graph = await estateGraphIds(dir);
@@ -325,4 +315,70 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
325
315
  const gate = gates.find((g) => g.component === "pr-2")!;
326
316
  expect(gate).toMatchObject({ state: "pending", planDigest: refused.digest, changeSet: refused.digest });
327
317
  }, 240_000);
318
+
319
+ // A round the first approval does not cover, over real tofu (#4487). app's
320
+ // `wide` count reads var.subnet through a local, so the preview's stand-in
321
+ // plans no wide instance; a's real id ends in `-wide`, so app's real plan
322
+ // creates one, which is more than the preview showed.
323
+ test("a dependent whose real plan exceeds its placeholder preview waits on its own round-2 gate (#4487)", async () => {
324
+ const plan = await openPullRequest(3, "a-wide");
325
+ expect(plan.status).toBe("planned");
326
+ expect(plan.members.map((m) => [m.member, m.counts.create, m.counts.update, m.unknownInputs])).toEqual([
327
+ ["a", 0, 1, undefined],
328
+ ["app", 0, 1, ["a.id"]],
329
+ ]);
330
+ expect(plan.changeSet.members.find((m) => m.member === "app")?.placeholders).toEqual(["var.subnet"]);
331
+ expect(plan.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([
332
+ ["a", "terraform_data.subnet", "update"],
333
+ ["app", "terraform_data.app", "update"],
334
+ ]);
335
+ github.reviews[3] = [{ user: { login: "alice" }, state: "APPROVED" }];
336
+ expect((await recordGateApproval("pr-3", "pr-apply", { actor: "github:alice", plan: plan.digest })).ok).toBe(true);
337
+
338
+ // a applies; app, planned now against the id a applied as, goes beyond the preview and waits on its own gate.
339
+ expect(await merge(3)).toBe(3);
340
+ const waiting = report("apply");
341
+ expect(waiting.digest).toBe(plan.digest);
342
+ expect(waiting.status).toBe("waiting");
343
+ expect(waiting.members.map((m) => [m.member, m.status, m.round])).toEqual([
344
+ ["a", "applied", undefined],
345
+ ["app", "waiting", 2],
346
+ ]);
347
+ const round = waiting.rounds![0]!;
348
+ expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", components: ["app"], status: "waiting", approval: { status: "pending" } });
349
+ expect(round.changeSet.entries.map((e) => [e.member, e.address, e.action])).toEqual([
350
+ ["app", "terraform_data.app", "update"],
351
+ ["app", "terraform_data.wide[0]", "create"],
352
+ ]);
353
+ // The fresh plan's shape adds a resource the preview did not show, so the first approval does not cover it, and the reason names it.
354
+ expect(round.covered).toBeUndefined();
355
+ expect(round.notCovered).toContain("terraform_data.wide");
356
+ expect(state("a")).toContain("10.0.0.0/16/a-wide");
357
+ expect(state("app")).not.toContain("a-wide");
358
+ expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "pending" });
359
+ expect(github.comments.find((c) => c.pr === 3)!.body).toContain(`chant approve pr-3 pr-apply-round-2 --plan ${round.digest}`);
360
+ }, 240_000);
361
+
362
+ test("once the reviewer approves the round's plan, the same apply carries on from its record and applies the dependent (#4487)", async () => {
363
+ const plan = report("plan");
364
+ const round = report("apply").rounds![0]!;
365
+ expect((await recordGateApproval("pr-3", "pr-apply-round-2", { actor: "github:alice", plan: round.digest })).ok).toBe(true);
366
+ expect(await handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never)).toBe(0);
367
+ const applied = report("apply");
368
+ expect(applied.digest).toBe(plan.digest);
369
+ expect(applied.status).toBe("applied");
370
+ expect(applied.approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
371
+ expect(applied.resumed).toEqual({ applied: ["a"] });
372
+ expect(applied.members.map((m) => [m.member, m.status, m.round])).toEqual([
373
+ ["a", "applied", undefined],
374
+ ["app", "applied", 2],
375
+ ]);
376
+ expect(applied.rounds?.map((r) => [r.digest, r.status, r.approval.approvedBy])).toEqual([[round.digest, "applied", ["github:alice"]]]);
377
+ expect(state("app")).toContain("app on 10.0.0.0/16/a-wide");
378
+ expect(state("app")).toContain("wide on 10.0.0.0/16/a-wide");
379
+ // The same note, now with the apply's outcome.
380
+ expect(github.comments.filter((c) => c.pr === 3)).toHaveLength(1);
381
+ expect(github.comments.find((c) => c.pr === 3)!.body).toContain("### chant apply for `local`");
382
+ expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "success", sha: applied.head });
383
+ }, 240_000);
328
384
  });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Each member's outcome of a composed apply (#4442), read from recorded
3
+ * `apply -json` output of a run of the three-module plan that failed in its
4
+ * second module (`../__fixtures__/compose/apply-failed-in-app.jsonl`).
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { describe, expect, test } from "vitest";
10
+ import { composedApplyOutcomes, memberApply, readApplyEvents } from "./apply-events";
11
+
12
+ const out = readFileSync(join(import.meta.dirname, "..", "__fixtures__", "compose", "apply-failed-in-app.jsonl"), "utf-8");
13
+ const modules = ["net", "app", "edge"];
14
+
15
+ describe("readApplyEvents", () => {
16
+ const ev = readApplyEvents(out, modules);
17
+
18
+ test("counts each module's started and completed changes and keeps its first error", () => {
19
+ expect(ev.started).toEqual(new Map([["net", 1], ["app", 1]]));
20
+ expect(ev.done).toEqual(new Map([["net", 1]]));
21
+ expect(ev.errored.get("app")).toBe("terraform_data.service: module.app.terraform_data.service: Creation errored after 0s");
22
+ expect(ev.errored.has("edge")).toBe(false);
23
+ });
24
+
25
+ test("an error no module's resource names is other", () => {
26
+ expect(ev.other).toEqual(["Provider produced inconsistent final plan: This is a bug in the provider."]);
27
+ });
28
+
29
+ test("a run that failed in the second module: applied, failed, not-started", () => {
30
+ expect(memberApply("net", 1, ev)).toEqual({ apply: "applied", done: 1 });
31
+ expect(memberApply("app", 2, ev)).toEqual({ apply: "failed", done: 0 });
32
+ expect(memberApply("edge", 1, ev)).toEqual({ apply: "not-started", done: 0 });
33
+ });
34
+
35
+ test("some changes done is partial; none planned is no-changes", () => {
36
+ expect(memberApply("net", 2, ev)).toEqual({ apply: "partial", done: 1 });
37
+ expect(memberApply("edge", 0, ev)).toEqual({ apply: "no-changes", done: 0 });
38
+ });
39
+
40
+ test("a diagnostic naming a module's resource is that module's error when no hook named one", () => {
41
+ const line = JSON.stringify({ type: "diagnostic", diagnostic: { severity: "error", summary: "Invalid input", detail: "first\nsecond", address: "module.edge.terraform_data.dns" } });
42
+ expect(readApplyEvents(line, modules).errored.get("edge")).toBe("terraform_data.dns: Error: Invalid input: first");
43
+ });
44
+
45
+ test("a module whose name starts with another's is its own", () => {
46
+ const line = JSON.stringify({ type: "apply_complete", hook: { resource: { addr: "module.app2.terraform_data.x" } } });
47
+ expect(readApplyEvents(line, ["app", "app2"]).done).toEqual(new Map([["app2", 1]]));
48
+ });
49
+ });
50
+
51
+ describe("composedApplyOutcomes", () => {
52
+ test("every member by component, the failed one with its error", () => {
53
+ const c = { order: ["net", "app", "edge"], modules: new Map(modules.map((m) => [m, m])) };
54
+ const { members, other } = composedApplyOutcomes(c, new Map([["net", 1], ["app", 2], ["edge", 1]]), out);
55
+ expect(members).toEqual({
56
+ net: { status: "applied", done: 1 },
57
+ app: { status: "failed", done: 0, error: "terraform_data.service: module.app.terraform_data.service: Creation errored after 0s" },
58
+ edge: { status: "not-started", done: 0 },
59
+ });
60
+ expect(other).toHaveLength(1);
61
+ });
62
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * How each member's part of a composed apply ended (chant#4442, epic #4419),
3
+ * read from the binary's `apply -json` lines: `applied`, `partial`,
4
+ * `failed`, `not-started` or `no-changes`, core's `GroupMemberStatus`
5
+ * (ws-107).
6
+ *
7
+ * Lifted from terragucci's `packages/terragucci/src/compose.ts` at c493e0e
8
+ * (`readApplyEvents`, `memberApply`).
9
+ */
10
+
11
+ import type { GroupMemberStatus } from "@intentius/chant/components/capability";
12
+ import type { Composition } from "../compose";
13
+ import { stripModule, topModule } from "./module-address";
14
+
15
+ /** How one member's part of the apply ended. */
16
+ export type MemberApply = GroupMemberStatus;
17
+
18
+ /** What `apply -json` said of each module: the changes that completed, started, or errored, and the first error. */
19
+ export interface ApplyEvents {
20
+ done: Map<string, number>;
21
+ started: Map<string, number>;
22
+ errored: Map<string, string>;
23
+ /** Errors no module's resource names. */
24
+ other: string[];
25
+ }
26
+
27
+ interface ApplyLine {
28
+ type?: string;
29
+ hook?: { resource?: { addr?: string }; error?: string };
30
+ diagnostic?: { severity?: string; summary?: string; detail?: string; address?: string };
31
+ "@message"?: string;
32
+ }
33
+
34
+ /** Read `apply -json` lines into each module's progress. A line that is not JSON is skipped. */
35
+ export function readApplyEvents(out: string, modules: readonly string[]): ApplyEvents {
36
+ const ev: ApplyEvents = { done: new Map(), started: new Map(), errored: new Map(), other: [] };
37
+ const known = new Set(modules);
38
+ const moduleOf = (addr: string | undefined): string | undefined => {
39
+ const m = addr !== undefined ? topModule(addr) : undefined;
40
+ return m !== undefined && known.has(m) && addr !== `module.${m}` ? m : undefined;
41
+ };
42
+ const bump = (map: Map<string, number>, m: string) => map.set(m, (map.get(m) ?? 0) + 1);
43
+ for (const line of out.split("\n")) {
44
+ if (!line.startsWith("{")) continue;
45
+ let e: ApplyLine;
46
+ try {
47
+ e = JSON.parse(line) as ApplyLine;
48
+ } catch {
49
+ continue;
50
+ }
51
+ const addr = e.hook?.resource?.addr;
52
+ const m = moduleOf(addr);
53
+ if (e.type === "apply_start" && m) bump(ev.started, m);
54
+ else if (e.type === "apply_complete" && m) bump(ev.done, m);
55
+ else if (e.type === "apply_errored" && m && !ev.errored.has(m)) ev.errored.set(m, `${stripModule(addr!)}: ${e.hook?.error ?? e["@message"] ?? "errored"}`);
56
+ else if (e.type === "diagnostic" && e.diagnostic?.severity === "error") {
57
+ const dm = moduleOf(e.diagnostic.address);
58
+ const text = `${e.diagnostic.summary ?? "Error"}${e.diagnostic.detail ? `: ${e.diagnostic.detail.split("\n")[0]}` : ""}`;
59
+ if (dm && !ev.errored.has(dm)) ev.errored.set(dm, `${stripModule(e.diagnostic.address!)}: Error: ${text}`);
60
+ else if (!dm) ev.other.push(text);
61
+ }
62
+ }
63
+ return ev;
64
+ }
65
+
66
+ /** How a member's part of the apply ended, from its planned changes and the events. */
67
+ export function memberApply(m: string, planned: number, ev: ApplyEvents): { apply: MemberApply; done: number } {
68
+ const done = Math.min(ev.done.get(m) ?? 0, planned);
69
+ if (planned === 0) return { apply: "no-changes", done: 0 };
70
+ if (done >= planned) return { apply: "applied", done };
71
+ if (done > 0) return { apply: "partial", done };
72
+ if (ev.errored.has(m) || (ev.started.get(m) ?? 0) > 0) return { apply: "failed", done };
73
+ return { apply: "not-started", done };
74
+ }
75
+
76
+ /** One member's outcome of a composed apply. */
77
+ export interface MemberApplyOutcome {
78
+ status: MemberApply;
79
+ /** Its planned changes that completed. */
80
+ done: number;
81
+ /** The first error its part named. */
82
+ error?: string;
83
+ }
84
+
85
+ /**
86
+ * Every member's outcome of a composed apply, by component, from the
87
+ * `apply -json` output and each member's planned changes
88
+ * (`PlanCounts.changes` of its slice).
89
+ */
90
+ export function composedApplyOutcomes(c: Pick<Composition, "order" | "modules">, planned: ReadonlyMap<string, number>, out: string): { members: Record<string, MemberApplyOutcome>; other: string[] } {
91
+ const ev = readApplyEvents(out, c.order.map((member) => c.modules.get(member)!));
92
+ const members: Record<string, MemberApplyOutcome> = {};
93
+ for (const member of c.order) {
94
+ const m = c.modules.get(member)!;
95
+ const { apply, done } = memberApply(m, planned.get(member) ?? 0, ev);
96
+ const error = ev.errored.get(m);
97
+ members[member] = { status: apply, done, ...(error && apply !== "applied" ? { error } : {}) };
98
+ }
99
+ return { members, other: ev.other };
100
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The composed run of estates (#4445) against a real choudoufu: three
3
+ * estates (network, app, edge), where app tags a bucket with the id of the
4
+ * subnet network creates in the same apply. One plan, one apply, and the
5
+ * values read after it from a post-apply plan's `prior_state`.
6
+ *
7
+ * Gated on a `choudoufu` of v0.31.0 or later on PATH (choudoufu#2059) and
8
+ * `CHOUDOUFU_EMULATOR_ENDPOINT`, like the other acceptance suites
9
+ * (`../__fixtures__/ACCEPTANCE.md`), and skipped with the reason named
10
+ * otherwise.
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { afterAll, describe, expect, it } from "vitest";
18
+ import type { DriverComponent } from "@intentius/chant/components/driver";
19
+ import { compositionFor } from "../compose";
20
+ import type { TerraformConfig } from "../config";
21
+ import { isOlderVersion, MIN_CHOUDOUFU_ESTATES_VERSION, parseChoudoufuVersion } from "../op/activities/terraform";
22
+ import { runBinary, type BinaryExec } from "../state/exec";
23
+ import { EstateRun } from "./estate";
24
+
25
+ const emulatorEndpoint = process.env.CHOUDOUFU_EMULATOR_ENDPOINT;
26
+
27
+ function choudoufuVersion(): string | undefined {
28
+ try {
29
+ return parseChoudoufuVersion(execFileSync("choudoufu", ["version"], { encoding: "utf-8" })) ?? "dev";
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ const version = choudoufuVersion();
35
+ const missing =
36
+ version === undefined
37
+ ? "choudoufu is not on PATH"
38
+ : version !== "dev" && isOlderVersion(version, MIN_CHOUDOUFU_ESTATES_VERSION)
39
+ ? `choudoufu ${version} is older than v${MIN_CHOUDOUFU_ESTATES_VERSION}`
40
+ : !emulatorEndpoint
41
+ ? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
42
+ : undefined;
43
+
44
+ const made: string[] = [];
45
+ afterAll(() => {
46
+ for (const d of made) rmSync(d, { recursive: true, force: true });
47
+ });
48
+
49
+ function env(): NodeJS.ProcessEnv {
50
+ return {
51
+ ...process.env,
52
+ AWS_ENDPOINT_URL: process.env.AWS_ENDPOINT_URL ?? emulatorEndpoint,
53
+ AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ?? "choudoufu-emulator",
54
+ AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ?? "choudoufu-emulator",
55
+ AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION ?? "us-east-1",
56
+ AWS_REGION: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1",
57
+ };
58
+ }
59
+
60
+ const PROVIDERS = 'terraform {\n required_providers {\n aws = {\n source = "hashicorp/aws"\n }\n }\n}\n\nprovider "aws" {\n region = "us-east-1"\n}\n\n';
61
+ const live = (estate: string, body: string): string => `terraform {\n live {\n estate = "${estate}"\n }\n}\n\n${PROVIDERS}${body}`;
62
+ const tf = (name: string): DriverComponent => ({ name, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
63
+
64
+ describe.skipIf(missing !== undefined)(`a composed run of three estates against a real choudoufu (#4445)${missing ? `: skipped, ${missing}` : ""}`, () => {
65
+ it("plans all three as one, applies once, and app receives the subnet id network created", async () => {
66
+ const id = `chant-4445-${Date.now().toString(36)}`;
67
+ const dir = mkdtempSync(join(tmpdir(), "chant-compose-estate-e2e-"));
68
+ made.push(dir);
69
+ const files: Record<string, string> = {
70
+ "roots/network/main.tf": live(
71
+ `${id}-network`,
72
+ 'resource "aws_vpc" "main" {\n cidr_block = "10.61.0.0/16"\n}\n\nresource "aws_subnet" "a" {\n vpc_id = aws_vpc.main.id\n cidr_block = "10.61.1.0/24"\n}\n\noutput "subnet_id" {\n value = aws_subnet.a.id\n}\n',
73
+ ),
74
+ "roots/app/main.tf": live(
75
+ `${id}-app`,
76
+ `data "terraform_estate_outputs" "network" {\n estate = "${id}-network"\n names = ["subnet_id"]\n}\n\nresource "aws_s3_bucket" "app" {\n bucket = "${id}-app"\n tags = {\n subnet = data.terraform_estate_outputs.network.values.subnet_id\n }\n}\n\noutput "subnet_tag" {\n value = aws_s3_bucket.app.tags["subnet"]\n}\n`,
77
+ ),
78
+ "roots/edge/main.tf": live(`${id}-edge`, `resource "aws_cloudwatch_log_group" "edge" {\n name = "/${id}/edge"\n}\n`),
79
+ };
80
+ for (const [path, text] of Object.entries(files)) {
81
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
82
+ writeFileSync(join(dir, path), text);
83
+ }
84
+ const terraform: TerraformConfig = { binary: "choudoufu", roots: { network: { dir: "roots/network" }, app: { dir: "roots/app" }, edge: { dir: "roots/edge" } }, compose: { enabled: true } };
85
+ const composition = compositionFor({ projectRoot: dir, components: [tf("network"), tf("app"), tf("edge")], terraform, env: env() });
86
+ expect(composition.estate).toBe(true);
87
+
88
+ const calls: string[][] = [];
89
+ const exec: BinaryExec = (binary, args, at, e, io) => {
90
+ calls.push(args);
91
+ return runBinary(binary, args, at, e, io);
92
+ };
93
+ const run = new EstateRun({ projectRoot: dir, composition, terraform, env: env(), exec });
94
+ try {
95
+ const p = await run.plan();
96
+ expect(p.fallback).toBeUndefined();
97
+ expect(p.error).toBeUndefined();
98
+ expect(p.members.app!.shape.unknownInputs).toEqual(["network.subnet_id"]);
99
+ const a = await run.apply();
100
+ expect(a.error).toBeUndefined();
101
+ expect(a.ok).toBe(true);
102
+ const subnet = a.members.network!.outputs!.subnet_id;
103
+ expect(subnet).toMatch(/^subnet-/);
104
+ expect(a.members.app!.outputs!.subnet_tag).toBe(subnet);
105
+ expect(calls.filter((c) => c[0] === "apply")).toHaveLength(1);
106
+ expect(calls.some((c) => c[0] === "state" || c[0] === "force-unlock")).toBe(false);
107
+ for (const c of calls.filter((x) => x[0] === "show")) expect(c[c.length - 1]).toMatch(/\.tfplan$/);
108
+ } finally {
109
+ await run.release();
110
+ }
111
+ }, 300_000);
112
+ });