@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
@@ -0,0 +1,309 @@
1
+ /**
2
+ * The states of a set of roots, held for one run: take every lock (in the
3
+ * order given), pull each state, write each state back with retries, and
4
+ * release each lock.
5
+ *
6
+ * A state whose write still fails after the retries is kept beside the state
7
+ * in its own backend as `<key>.chant-unwritten`, and its lock stays held,
8
+ * naming the run. The next take of that lock finds the kept state, takes
9
+ * the lock over, writes the kept state, removes it, and only then returns.
10
+ * When the backend cannot take the kept copy either, it goes under the job's
11
+ * report directory (`<reportDir>/unwritten/<root>.tfstate`), never the
12
+ * reports bucket.
13
+ *
14
+ * A take also reads a state terragucci kept as `<key>.terragucci-unwritten`
15
+ * under a terragucci composed run's lock, so a repo that moves to chant
16
+ * mid-incident keeps it. That name is read and removed, never written.
17
+ *
18
+ * Lifted from terragucci's `compose.ts` (c493e0e): `ComposedRun.plan`'s lock
19
+ * take and kept write, `writeState`, `writeWithRetries`, `keep`,
20
+ * `releaseOne` and `release`.
21
+ */
22
+ import { mkdirSync, writeFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { isRefused, stateObject, type LockableState, type StateObject, type StateRefusalKind } from "./backend";
25
+ import { lastLines, pullState, runBinary, type BinaryExec, type StateFile } from "./exec";
26
+ import { takeLocks, type HeldLock, type LockOperation } from "./locks";
27
+ import type { StoreFetch } from "./object-store";
28
+ import { stateStore, type StateStore } from "./store";
29
+
30
+ /** The suffix of the object beside a root's state that holds a state a run could not write. */
31
+ export const UNWRITTEN_SUFFIX = ".chant-unwritten";
32
+ /** The suffix terragucci keeps an unwritten state under: read and removed, never written. */
33
+ export const LEGACY_UNWRITTEN_SUFFIX = ".terragucci-unwritten";
34
+ /** The operation terragucci's composed run names in its locks. */
35
+ export const LEGACY_COMPOSE_OPERATION = "terragucci compose";
36
+ /** Where a kept state goes under the job's report directory when its own backend could not take it. */
37
+ export const UNWRITTEN_DIR = "unwritten";
38
+
39
+ /** The operation a composed run's locks name. */
40
+ export const COMPOSE_LOCK: LockOperation = { operation: "chant terraform.compose", info: "a composed run holds this state while it plans, applies and writes it" };
41
+
42
+ /** How long to wait before each retry of a state's write: five retries, inside the lock timeout of 5 minutes. */
43
+ export const DEFAULT_RETRY_DELAYS = [5_000, 15_000, 30_000, 60_000, 120_000];
44
+
45
+ /** One root whose state the run holds. */
46
+ export interface RootState {
47
+ root: string;
48
+ /** The root's directory, where the binary runs. */
49
+ dir: string;
50
+ /** The root's environment for the binary. */
51
+ env: NodeJS.ProcessEnv;
52
+ /** Where its state is; read from what `init` recorded, compared with the root's code, when absent. */
53
+ object?: StateObject;
54
+ /** `terraform.roots.<name>.backendConfig`, which the comparison with what init recorded reads (chant#4478). */
55
+ backendConfig?: Readonly<Record<string, string>>;
56
+ /** `terraform.roots.<name>.backendConfigFiles`, whose keys the same comparison reads (chant#4459). */
57
+ backendConfigFiles?: readonly string[];
58
+ }
59
+
60
+ export interface RootStatesOptions {
61
+ /** The roots, in the order their locks are taken. */
62
+ roots: RootState[];
63
+ /** The binary's path. */
64
+ binary: string;
65
+ exec?: BinaryExec;
66
+ /** The operation the locks name. Default COMPOSE_LOCK. */
67
+ op?: LockOperation;
68
+ /** Names the run in the locks it takes. */
69
+ runId?: string;
70
+ /** A directory of the job's own for the state files `state push` reads. */
71
+ work: string;
72
+ /** The job's report directory, where a kept state goes when its backend cannot take it. */
73
+ reportDir: string;
74
+ retryDelays?: number[];
75
+ sleep?: (ms: number) => Promise<void>;
76
+ fetch?: StoreFetch;
77
+ /** Opens a root's store; a test's fake stands in here. */
78
+ openStore?: (root: RootState, object: LockableState) => StateStore;
79
+ now?: () => string;
80
+ /** Who the lock info names. */
81
+ who?: string;
82
+ log?: (line: string) => void;
83
+ }
84
+
85
+ /** Why a root's state is not locked. */
86
+ export interface RootRefusal {
87
+ root: string;
88
+ backend: string;
89
+ refused: StateRefusalKind;
90
+ reason: string;
91
+ }
92
+
93
+ /** The roots whose state chant does not lock: the take stops before any lock. */
94
+ export class StateRefusedError extends Error {
95
+ constructor(readonly refusals: RootRefusal[]) {
96
+ super(refusals.map((r) => `${r.root}: ${r.reason}`).join("\n"));
97
+ this.name = "StateRefusedError";
98
+ }
99
+ }
100
+
101
+ /** How a write ended: written, or kept with the lock held. */
102
+ export type WriteOutcome = { written: true; attempts: number } | { written: false; attempts: number; error: string; kept: string; lockHeld: true };
103
+
104
+ const sleepMs = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
105
+ const safeName = (root: string): string => (root === "." ? "root" : root.replace(/[^\w.-]/g, "_"));
106
+ const body = (s: StateFile): string => JSON.stringify(s, null, 2) + "\n";
107
+
108
+ export class RootStates {
109
+ private readonly exec: BinaryExec;
110
+ private readonly op: LockOperation;
111
+ private readonly objects = new Map<string, StateObject>();
112
+ private locks: HeldLock[] = [];
113
+ private readonly heldOnPurpose = new Set<string>();
114
+
115
+ constructor(private readonly o: RootStatesOptions) {
116
+ this.exec = o.exec ?? runBinary;
117
+ this.op = o.op ?? COMPOSE_LOCK;
118
+ if (o.runId) this.op = { operation: this.op.operation, info: `${this.op.info} (run ${o.runId})` };
119
+ }
120
+
121
+ private log(line: string): void {
122
+ this.o.log?.(line);
123
+ }
124
+
125
+ private rootOf(root: string): RootState {
126
+ const r = this.o.roots.find((x) => x.root === root);
127
+ if (!r) throw new Error(`${root} is not one of this run's roots`);
128
+ return r;
129
+ }
130
+
131
+ /** Where a root's state is, or why it is not locked. */
132
+ object(root: string): StateObject {
133
+ let o = this.objects.get(root);
134
+ if (!o) {
135
+ const r = this.rootOf(root);
136
+ o = r.object ?? stateObject(r.dir, r.env, undefined, this.o.binary, {
137
+ ...(r.backendConfig ? { backendConfig: r.backendConfig } : {}),
138
+ ...(r.backendConfigFiles ? { backendConfigFiles: r.backendConfigFiles } : {}),
139
+ });
140
+ this.objects.set(root, o);
141
+ }
142
+ return o;
143
+ }
144
+
145
+ /** Every root whose state chant does not lock, with why. */
146
+ refusals(): RootRefusal[] {
147
+ return this.o.roots.flatMap((r) => {
148
+ const o = this.object(r.root);
149
+ return isRefused(o) ? [{ root: r.root, backend: o.backend, refused: o.refused, reason: o.reason }] : [];
150
+ });
151
+ }
152
+
153
+ /** The locks held now, by root. */
154
+ held(): string[] {
155
+ return this.locks.map((l) => l.root);
156
+ }
157
+
158
+ private lockOf(root: string): HeldLock | undefined {
159
+ return this.locks.find((l) => l.root === root);
160
+ }
161
+
162
+ /**
163
+ * Take every root's lock, in order. A root chant does not lock stops the
164
+ * take before any lock (StateRefusedError); a lock another run holds
165
+ * releases what was taken and throws StateLockedError, naming the holder.
166
+ * A lock an earlier run of this operation left held with a kept state is
167
+ * taken over, and every kept state is written before this returns.
168
+ */
169
+ async take(): Promise<void> {
170
+ const refused = this.refusals();
171
+ if (refused.length) throw new StateRefusedError(refused);
172
+ const stores = this.o.roots.map((r) => {
173
+ const object = this.object(r.root) as LockableState;
174
+ return { root: r.root, store: this.o.openStore ? this.o.openStore(r, object) : stateStore(object, r.dir, this.o.fetch) };
175
+ });
176
+ const now = this.o.now?.() ?? new Date().toISOString();
177
+ this.locks = await takeLocks(
178
+ stores,
179
+ now,
180
+ this.op,
181
+ async (root, store, held) => {
182
+ // A lock an earlier composed run left held for a state it could not write: this run writes that state first.
183
+ const ours = held.info.includes(this.op.operation) || held.info.includes(LEGACY_COMPOSE_OPERATION);
184
+ if (!ours || !(await this.kept(store))) return false;
185
+ this.log(`${root}: its lock is held by an earlier run that kept a state it could not write; this run takes the lock over and writes that state first`);
186
+ return true;
187
+ },
188
+ this.o.who,
189
+ );
190
+ for (const l of this.locks) {
191
+ try {
192
+ await this.writeKept(l);
193
+ } catch (e) {
194
+ // The kept state is still unwritten: its lock stays held, the others are released.
195
+ this.heldOnPurpose.add(l.root);
196
+ await this.release();
197
+ throw new Error(`${l.root}: the state an earlier run kept for it could not be written: ${(e as Error).message}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ /** The state kept beside a store's state and the suffix it is under, chant's name first; undefined when none is kept. */
203
+ private async kept(store: StateStore): Promise<{ suffix: string; text: string } | undefined> {
204
+ for (const suffix of [UNWRITTEN_SUFFIX, LEGACY_UNWRITTEN_SUFFIX]) {
205
+ const text = await store.readBeside(suffix).catch(() => undefined);
206
+ if (text?.trim()) return { suffix, text };
207
+ }
208
+ return undefined;
209
+ }
210
+
211
+ /** A state an earlier run kept beside a root's state: write it, then remove what was kept. */
212
+ private async writeKept(lock: HeldLock): Promise<void> {
213
+ const kept = await this.kept(lock.store);
214
+ if (!kept) return;
215
+ await this.writeState(lock.root, JSON.parse(kept.text) as StateFile);
216
+ await lock.store.removeBeside(kept.suffix);
217
+ this.log(`${lock.root}: wrote the state an earlier run kept at ${lock.store.besideLocation(kept.suffix)}`);
218
+ }
219
+
220
+ /** A root's state as `state pull` gives it, or null when it has none. */
221
+ pull(root: string): Promise<StateFile | null> {
222
+ const r = this.rootOf(root);
223
+ return pullState(this.exec, this.o.binary, root, r.dir, r.env);
224
+ }
225
+
226
+ /** Write one root's state under its lock: the store's own write (azurerm's lease), else `state push -lock=false`. */
227
+ private async writeState(root: string, state: StateFile): Promise<void> {
228
+ const lock = this.lockOf(root);
229
+ if (!lock) throw new Error(`${root}: its state is written under its lock, and none is held`);
230
+ if (lock.store.write) {
231
+ await lock.store.write(body(state));
232
+ return;
233
+ }
234
+ const r = this.rootOf(root);
235
+ const file = join(this.o.work, `${safeName(root)}.tfstate`);
236
+ mkdirSync(this.o.work, { recursive: true });
237
+ writeFileSync(file, body(state));
238
+ const push = await this.exec(this.o.binary, ["state", "push", "-lock=false", file], r.dir, r.env);
239
+ if (push.code !== 0) throw new Error(`state push failed: ${lastLines(push.out, 3)}`);
240
+ }
241
+
242
+ /**
243
+ * Write one root's state, retrying after each delay while the lock is
244
+ * held. A state still unwritten after the last retry is kept, and its lock
245
+ * stays held: `release` leaves it.
246
+ */
247
+ async write(root: string, state: StateFile): Promise<WriteOutcome> {
248
+ const delays = this.o.retryDelays ?? DEFAULT_RETRY_DELAYS;
249
+ const sleep = this.o.sleep ?? sleepMs;
250
+ let last = "";
251
+ for (let i = 0; i <= delays.length; i++) {
252
+ try {
253
+ await this.writeState(root, state);
254
+ return { written: true, attempts: i + 1 };
255
+ } catch (e) {
256
+ last = (e as Error).message;
257
+ if (i < delays.length) {
258
+ this.log(`${root}: the write of its state failed (${last}); trying again in ${Math.round(delays[i]! / 1000)}s`);
259
+ await sleep(delays[i]!);
260
+ }
261
+ }
262
+ }
263
+ const kept = await this.keep(root, state);
264
+ this.heldOnPurpose.add(root);
265
+ this.log(`${root}: its state could not be written (${last}); kept at ${kept}, and its lock stays held until a later run writes it`);
266
+ return { written: false, attempts: delays.length + 1, error: last, kept, lockHeld: true };
267
+ }
268
+
269
+ /** Keep a state that could not be written: beside it in its own backend, else as a file of the job's report directory. Returns where. */
270
+ private async keep(root: string, state: StateFile): Promise<string> {
271
+ const lock = this.lockOf(root);
272
+ try {
273
+ if (lock) {
274
+ await lock.store.writeBeside(UNWRITTEN_SUFFIX, body(state));
275
+ return lock.store.besideLocation(UNWRITTEN_SUFFIX);
276
+ }
277
+ } catch (e) {
278
+ this.log(`${root}: the state could not be kept beside its backend's state either (${(e as Error).message}); it is kept under the job's report directory`);
279
+ }
280
+ const file = join(this.o.reportDir, UNWRITTEN_DIR, `${safeName(root)}.tfstate`);
281
+ mkdirSync(dirname(file), { recursive: true });
282
+ writeFileSync(file, body(state));
283
+ return file;
284
+ }
285
+
286
+ /**
287
+ * Keep a root's lock held past {@link release}, as a kept state's is: for a
288
+ * root whose state the run could neither write nor keep, so no other run
289
+ * plans against a state that may not hold what applied.
290
+ */
291
+ hold(root: string): void {
292
+ if (this.lockOf(root)) this.heldOnPurpose.add(root);
293
+ }
294
+
295
+ /** The roots whose lock stays held past {@link release}. */
296
+ heldOnPurposeRoots(): string[] {
297
+ return [...this.heldOnPurpose].filter((r) => this.lockOf(r)).sort();
298
+ }
299
+
300
+ /** Release a root's lock, or every lock but those held on purpose for a kept state. */
301
+ async release(root?: string): Promise<void> {
302
+ const which = root ? [root] : [...new Set(this.locks.map((l) => l.root))].filter((r) => !this.heldOnPurpose.has(r));
303
+ for (const r of which) {
304
+ const mine = this.locks.filter((l) => l.root === r);
305
+ this.locks = this.locks.filter((l) => l.root !== r);
306
+ for (const l of mine) await l.release().catch((e: unknown) => this.log(`${r}: its lock could not be released: ${(e as Error).message}`));
307
+ }
308
+ }
309
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The little of S3 a state's lock and write need: get, put, put only when
3
+ * absent, head and delete one object, signed with AWS Signature Version 4
4
+ * from `node:crypto`. Any S3-compatible store answers it (S3, MinIO, floci).
5
+ * With no static keys, a role is assumed with the job's OIDC token through
6
+ * STS AssumeRoleWithWebIdentity. `fetch` is injectable so the signing is
7
+ * tested against recorded requests.
8
+ *
9
+ * Lifted from terragucci's `report/s3.ts` (c493e0e), without presigning,
10
+ * version listing and the re-plan's traded credentials.
11
+ */
12
+ import { createHash, createHmac } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import { defaultFetch, encodePath, encodeStrict, StoreError, xmlText, type StoreFetch } from "./object-store";
15
+
16
+ export interface S3Credentials {
17
+ accessKeyId: string;
18
+ secretAccessKey: string;
19
+ sessionToken?: string;
20
+ }
21
+
22
+ /** A role to assume with the job's OIDC token, the way the AWS SDKs read `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE`. */
23
+ export interface S3WebIdentity {
24
+ roleArn: string;
25
+ tokenFile: string;
26
+ sessionName: string;
27
+ /** STS's address, https://host[:port], no path. */
28
+ endpoint: string;
29
+ }
30
+
31
+ export interface S3Location {
32
+ bucket: string;
33
+ /** https://host[:port], no path. Default: AWS's regional endpoint. */
34
+ endpoint?: string;
35
+ region: string;
36
+ }
37
+
38
+ export type S3Target = S3Location & (S3Credentials | { webIdentity: S3WebIdentity });
39
+
40
+ /**
41
+ * The target for `bucket` from the AWS environment variables. Credentials,
42
+ * first match wins: static keys in `AWS_ACCESS_KEY_ID` and
43
+ * `AWS_SECRET_ACCESS_KEY`; `AWS_ROLE_ARN` assumed with the token in
44
+ * `AWS_WEB_IDENTITY_TOKEN_FILE`.
45
+ */
46
+ export function s3FromEnv(where: { bucket: string; endpoint?: string }, env: NodeJS.ProcessEnv = process.env): S3Target {
47
+ const endpoint = where.endpoint ?? env.AWS_ENDPOINT_URL_S3 ?? env.AWS_ENDPOINT_URL;
48
+ const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || "us-east-1";
49
+ const at = { bucket: where.bucket, ...(endpoint ? { endpoint: endpoint.replace(/\/+$/, "") } : {}), region };
50
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
51
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
52
+ if (accessKeyId && secretAccessKey) return { ...at, accessKeyId, secretAccessKey, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}) };
53
+ const tokenFile = env.AWS_WEB_IDENTITY_TOKEN_FILE;
54
+ if (env.AWS_ROLE_ARN && tokenFile) {
55
+ return {
56
+ ...at,
57
+ webIdentity: {
58
+ roleArn: env.AWS_ROLE_ARN,
59
+ tokenFile,
60
+ sessionName: env.AWS_ROLE_SESSION_NAME || "chant-state",
61
+ endpoint: (env.AWS_ENDPOINT_URL_STS || env.AWS_ENDPOINT_URL || `https://sts.${region}.amazonaws.com`).replace(/\/+$/, ""),
62
+ },
63
+ };
64
+ }
65
+ throw new StoreError("set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE");
66
+ }
67
+
68
+ /** STS AssumeRoleWithWebIdentity. The request carries the token and is not signed. */
69
+ export async function assumeRoleWithWebIdentity(w: S3WebIdentity, fetchFn: StoreFetch, readToken: (file: string) => string = (f) => readFileSync(f, "utf-8")): Promise<S3Credentials & { expiration?: Date }> {
70
+ let token: string;
71
+ try {
72
+ token = readToken(w.tokenFile).trim();
73
+ } catch (e) {
74
+ throw new StoreError(`cannot read the OIDC token in ${w.tokenFile}: ${(e as Error).message}`);
75
+ }
76
+ if (!token) throw new StoreError(`the OIDC token file ${w.tokenFile} is empty`);
77
+ const body = new URLSearchParams({ Action: "AssumeRoleWithWebIdentity", Version: "2011-06-15", RoleArn: w.roleArn, RoleSessionName: w.sessionName, WebIdentityToken: token }).toString();
78
+ const res = await fetchFn(`${w.endpoint}/`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded; charset=utf-8", accept: "application/xml" }, body });
79
+ const xml = await res.text();
80
+ if (!res.ok) {
81
+ const why = [xmlText(xml, "Code"), xmlText(xml, "Message")].filter(Boolean).join(": ") || xml.slice(0, 300);
82
+ throw new StoreError(`AssumeRoleWithWebIdentity for ${w.roleArn}: ${res.status} ${why}`);
83
+ }
84
+ const accessKeyId = xmlText(xml, "AccessKeyId");
85
+ const secretAccessKey = xmlText(xml, "SecretAccessKey");
86
+ const sessionToken = xmlText(xml, "SessionToken");
87
+ if (!accessKeyId || !secretAccessKey || !sessionToken) throw new StoreError(`AssumeRoleWithWebIdentity for ${w.roleArn} answered without credentials`);
88
+ const expires = xmlText(xml, "Expiration");
89
+ return { accessKeyId, secretAccessKey, sessionToken, ...(expires && !Number.isNaN(Date.parse(expires)) ? { expiration: new Date(expires) } : {}) };
90
+ }
91
+
92
+ const sha256 = (b: string | Uint8Array): string => createHash("sha256").update(b).digest("hex");
93
+ const hmac = (key: string | Buffer, s: string): Buffer => createHmac("sha256", key).update(s).digest();
94
+
95
+ /** Signature Version 4 headers for a request; its query string, if any, is signed in canonical order. `extra` headers are signed too. */
96
+ export function sign(t: Pick<S3Location, "region"> & S3Credentials, method: string, rawUrl: string, extra: Record<string, string>, payload: string, now: Date, service = "s3"): Record<string, string> {
97
+ const url = new URL(rawUrl);
98
+ const amzDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
99
+ const day = amzDate.slice(0, 8);
100
+ const headers: Record<string, string> = {
101
+ host: url.host,
102
+ "x-amz-content-sha256": payload,
103
+ "x-amz-date": amzDate,
104
+ ...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k.toLowerCase(), v])),
105
+ ...(t.sessionToken ? { "x-amz-security-token": t.sessionToken } : {}),
106
+ };
107
+ const names = Object.keys(headers).sort();
108
+ const query = [...url.searchParams].map(([k, v]) => `${encodeStrict(k)}=${encodeStrict(v)}`).sort().join("&");
109
+ const canonical = [method, url.pathname, query, ...names.map((n) => `${n}:${headers[n]!.trim()}`), "", names.join(";"), payload].join("\n");
110
+ const scope = `${day}/${t.region}/${service}/aws4_request`;
111
+ const toSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonical)].join("\n");
112
+ const key4 = hmac(hmac(hmac(hmac(`AWS4${t.secretAccessKey}`, day), t.region), service), "aws4_request");
113
+ const signature = createHmac("sha256", key4).update(toSign).digest("hex");
114
+ const { host: _host, ...sent } = headers;
115
+ return { ...sent, authorization: `AWS4-HMAC-SHA256 Credential=${t.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${signature}` };
116
+ }
117
+
118
+ /** The address of `key` in the bucket: path-style on a custom endpoint, virtual-hosted on AWS. */
119
+ const objectUrl = (t: S3Location, key: string): string => `${t.endpoint ? `${t.endpoint}/${t.bucket}` : `https://${t.bucket}.s3.${t.region}.amazonaws.com`}/${encodePath(key)}`;
120
+
121
+ export class S3Client {
122
+ private creds?: Promise<S3Credentials & { expiration?: Date }>;
123
+
124
+ constructor(readonly target: S3Target, private readonly fetchFn: StoreFetch = defaultFetch) {}
125
+
126
+ private where(key: string): string {
127
+ return `s3://${this.target.bucket}/${key}`;
128
+ }
129
+
130
+ /** Static keys as given; a web identity's keys once per client, again a minute before they expire. */
131
+ private async signer(): Promise<S3Location & S3Credentials> {
132
+ const t = this.target;
133
+ if (!("webIdentity" in t)) return t;
134
+ const assume = (): Promise<S3Credentials & { expiration?: Date }> =>
135
+ (this.creds = assumeRoleWithWebIdentity(t.webIdentity, this.fetchFn).catch((e: unknown) => {
136
+ this.creds = undefined;
137
+ throw e;
138
+ }));
139
+ let held = await (this.creds ?? assume());
140
+ if (held.expiration && held.expiration.getTime() - Date.now() < 60_000) held = await assume();
141
+ const { accessKeyId, secretAccessKey, sessionToken } = held;
142
+ return { bucket: t.bucket, ...(t.endpoint ? { endpoint: t.endpoint } : {}), region: t.region, accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) };
143
+ }
144
+
145
+ private async request(method: string, key: string, body: string, extra: Record<string, string> = {}) {
146
+ const t = await this.signer();
147
+ const url = objectUrl(t, key);
148
+ return this.fetchFn(url, { method, headers: sign(t, method, url, extra, sha256(body), new Date()), ...(method === "PUT" ? { body } : {}) });
149
+ }
150
+
151
+ /** Write an object, unconditionally. */
152
+ async put(key: string, body: string, contentType: string): Promise<void> {
153
+ const res = await this.request("PUT", key, body, { "content-type": contentType });
154
+ if (!res.ok) throw new StoreError(`PUT ${this.where(key)}: ${res.status} ${(await res.text()).slice(0, 300)}`);
155
+ }
156
+
157
+ /** The object's text, or undefined when there is none. */
158
+ async get(key: string): Promise<string | undefined> {
159
+ const res = await this.request("GET", key, "");
160
+ if (res.status === 404) return undefined;
161
+ if (!res.ok) throw new StoreError(`GET ${this.where(key)}: ${res.status} ${(await res.text()).slice(0, 300)}`);
162
+ return res.text();
163
+ }
164
+
165
+ /** The object's metadata and never its body: whether it exists, its ETag and its version id. */
166
+ async head(key: string): Promise<{ exists: boolean; etag?: string; versionId?: string }> {
167
+ const res = await this.request("HEAD", key, "");
168
+ if (res.status === 404) return { exists: false };
169
+ if (!res.ok) throw new StoreError(`HEAD ${this.where(key)}: ${res.status}`);
170
+ const etag = res.headers?.get("etag") ?? undefined;
171
+ const version = res.headers?.get("x-amz-version-id") ?? undefined;
172
+ return { exists: true, ...(etag ? { etag } : {}), ...(version && version !== "null" ? { versionId: version } : {}) };
173
+ }
174
+
175
+ /**
176
+ * Write an object only when there is none (If-None-Match: *): true when
177
+ * this write made it, false when one was there. A store that refuses the
178
+ * condition fails the write: a lock is never taken unconditionally.
179
+ */
180
+ async putIfAbsent(key: string, body: string, contentType: string): Promise<boolean> {
181
+ const res = await this.request("PUT", key, body, { "content-type": contentType, "if-none-match": "*" });
182
+ if (res.status === 412 || res.status === 409) return false;
183
+ if (res.status === 501) throw new StoreError(`PUT ${this.where(key)}: the store does not take a conditional write (501)`);
184
+ if (!res.ok) throw new StoreError(`PUT ${this.where(key)}: ${res.status} ${(await res.text()).slice(0, 300)}`);
185
+ return true;
186
+ }
187
+
188
+ /** Delete an object. One that is not there is deleted already. */
189
+ async remove(key: string): Promise<void> {
190
+ const res = await this.request("DELETE", key, "");
191
+ if (!res.ok && res.status !== 404) throw new StoreError(`DELETE ${this.where(key)}: ${res.status} ${(await res.text()).slice(0, 300)}`);
192
+ }
193
+ }