@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,111 @@
1
+ /**
2
+ * The binary a state is read and written through: `state pull` and
3
+ * `state push`. The exec is injectable, so a test runs none.
4
+ *
5
+ * Lifted from terragucci's `migrate.ts` (c493e0e): `Ran`, `BinaryIo`,
6
+ * `BinaryExec`, `runBinary`, `StateFile`, `StateResource` and `pullState`.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+
10
+ export interface Ran {
11
+ code: number;
12
+ stdout: string;
13
+ /** stdout and stderr together, as they came. */
14
+ out: string;
15
+ }
16
+
17
+ /** What a run of the binary hands back while it runs: `stderr` sees each line of stderr as it arrives, and a line it takes (true) is left out of `out`. */
18
+ export interface BinaryIo {
19
+ stderr?: (line: string) => boolean;
20
+ }
21
+
22
+ /** How the binary runs: in `dir`, with `env`. Injectable for tests. */
23
+ export type BinaryExec = (binary: string, args: string[], dir: string, env: NodeJS.ProcessEnv, io?: BinaryIo) => Promise<Ran>;
24
+
25
+ /** The binary run with `-chdir=<dir>` and `TF_IN_AUTOMATION=1`. */
26
+ export const runBinary: BinaryExec = (binary, args, dir, env, io) =>
27
+ new Promise((done) => {
28
+ const child = spawn(binary, [`-chdir=${dir}`, ...args], { env: { ...env, TF_IN_AUTOMATION: "1" }, stdio: ["ignore", "pipe", "pipe"] });
29
+ let stdout = "";
30
+ let out = "";
31
+ let partial = "";
32
+ const stderrLine = (line: string): void => {
33
+ let taken = false;
34
+ try {
35
+ taken = io!.stderr!(line);
36
+ } catch {
37
+ taken = false;
38
+ }
39
+ if (!taken) out += `${line}\n`;
40
+ };
41
+ child.stdout.on("data", (d) => {
42
+ stdout += d;
43
+ out += d;
44
+ });
45
+ // A line split across two chunks, a character too, is put back together before it is read.
46
+ if (io?.stderr) child.stderr.setEncoding("utf8");
47
+ child.stderr.on("data", (d) => {
48
+ if (!io?.stderr) {
49
+ out += d;
50
+ return;
51
+ }
52
+ partial += d;
53
+ const lines = partial.split("\n");
54
+ partial = lines.pop()!;
55
+ for (const l of lines) stderrLine(l);
56
+ });
57
+ const rest = (): void => {
58
+ if (io?.stderr && partial) stderrLine(partial);
59
+ partial = "";
60
+ };
61
+ child.on("error", (e) => {
62
+ rest();
63
+ done({ code: 127, stdout, out: `${out}${e.message}` });
64
+ });
65
+ child.on("close", (code) => {
66
+ rest();
67
+ done({ code: code ?? 1, stdout, out });
68
+ });
69
+ });
70
+
71
+ /** A state file as `state pull` prints it (format version 4); only what a run touches is named. */
72
+ export interface StateFile {
73
+ version: number;
74
+ terraform_version?: string;
75
+ serial: number;
76
+ lineage: string;
77
+ outputs?: Record<string, unknown>;
78
+ resources: StateResource[];
79
+ check_results?: unknown;
80
+ [k: string]: unknown;
81
+ }
82
+
83
+ export interface StateResource {
84
+ module?: string;
85
+ mode: string;
86
+ type: string;
87
+ name: string;
88
+ provider?: string;
89
+ instances?: unknown[];
90
+ [k: string]: unknown;
91
+ }
92
+
93
+ /** The last lines of the binary's output, on one line, for an error. */
94
+ export const lastLines = (s: string, n = 4): string => s.trim().split("\n").slice(-n).join(" ").slice(0, 400);
95
+
96
+ /** A root's state as `state pull` gives it, or null when it has none. */
97
+ export async function pullState(exec: BinaryExec, binary: string, root: string, dir: string, env: NodeJS.ProcessEnv): Promise<StateFile | null> {
98
+ const pulled = await exec(binary, ["state", "pull"], dir, env);
99
+ if (pulled.code !== 0) throw new Error(`state pull in ${root} failed: ${lastLines(pulled.out)}`);
100
+ if (!pulled.stdout.trim()) return null;
101
+ let s: StateFile;
102
+ try {
103
+ s = JSON.parse(pulled.stdout) as StateFile;
104
+ } catch {
105
+ throw new Error(`state pull in ${root} printed something that is not a state`);
106
+ }
107
+ if (!Array.isArray(s.resources)) s.resources = [];
108
+ // A backend with no state object yet prints an empty state with no lineage: that is no state.
109
+ if (!s.lineage && !s.serial && s.resources.length === 0) return null;
110
+ return s;
111
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Google Cloud Storage for a gcs backend's state, over its JSON API with
3
+ * `fetch` and `node:crypto`: an object's text and generation, a write, a
4
+ * write only when there is no object (the lock), and a delete.
5
+ *
6
+ * The identity, first match wins: a bearer token (`access_token`, or
7
+ * GOOGLE_OAUTH_ACCESS_TOKEN), else a credentials file or its JSON:
8
+ *
9
+ * external_account the job's OIDC token, traded at Google's STS, then the
10
+ * service account it impersonates.
11
+ * service_account a key: requests carry a self-signed JWT.
12
+ *
13
+ * With `impersonate_service_account`, every request carries that service
14
+ * account's token, which the identity asks IAM for.
15
+ *
16
+ * Lifted from terragucci's `report/gcs.ts` (c493e0e), without signed URLs,
17
+ * version listing and the re-plan's traded tokens.
18
+ */
19
+ import { createSign } from "node:crypto";
20
+ import { readFileSync } from "node:fs";
21
+ import { defaultFetch, encodeStrict, StoreConflict, StoreError, type StoreFetch } from "./object-store";
22
+
23
+ export const GCS_ENDPOINT = "https://storage.googleapis.com";
24
+ /** IAM Credentials, where a token for an impersonated service account comes from. */
25
+ export const IAM_CREDENTIALS = "https://iamcredentials.googleapis.com";
26
+ const CLOUD_PLATFORM = "https://www.googleapis.com/auth/cloud-platform";
27
+ const READ_WRITE = "https://www.googleapis.com/auth/devstorage.read_write";
28
+
29
+ export interface GcsTarget {
30
+ bucket: string;
31
+ /** The JSON API's address, no trailing slash. Default https://storage.googleapis.com. */
32
+ endpoint: string;
33
+ /** The credentials file, read at the first request. */
34
+ credentialsFile?: string;
35
+ /** The credentials as JSON: a gcs backend's `credentials` given inline. */
36
+ credentialsJson?: string;
37
+ /** A bearer token used as it is. */
38
+ accessToken?: string;
39
+ /** The service account every request acts as, through IAM's generateAccessToken. */
40
+ impersonate?: string;
41
+ /** IAM Credentials' address, no trailing slash. */
42
+ iamEndpoint?: string;
43
+ }
44
+
45
+ interface ExternalAccount {
46
+ type: "external_account";
47
+ audience: string;
48
+ subject_token_type: string;
49
+ token_url: string;
50
+ service_account_impersonation_url?: string;
51
+ credential_source: { file?: string; url?: string; headers?: Record<string, string>; format?: { type?: string; subject_token_field_name?: string } };
52
+ }
53
+
54
+ interface ServiceAccountKey {
55
+ type: "service_account";
56
+ client_email: string;
57
+ private_key: string;
58
+ private_key_id?: string;
59
+ }
60
+
61
+ type Credentials = ExternalAccount | ServiceAccountKey;
62
+
63
+ const b64url = (b: string | Buffer): string => Buffer.from(b).toString("base64url");
64
+
65
+ /** A JWT the key signs for itself, which Google's APIs take as an access token for the scope it names. */
66
+ export function selfSignedJwt(key: ServiceAccountKey, now: Date): string {
67
+ const iat = Math.floor(now.getTime() / 1000);
68
+ const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", ...(key.private_key_id ? { kid: key.private_key_id } : {}) }));
69
+ const body = b64url(JSON.stringify({ iss: key.client_email, sub: key.client_email, scope: READ_WRITE, iat, exp: iat + 3600 }));
70
+ return `${head}.${body}.${createSign("RSA-SHA256").update(`${head}.${body}`).sign(key.private_key).toString("base64url")}`;
71
+ }
72
+
73
+ /** Google's error message, or the start of the body. */
74
+ const why = (text: string): string => {
75
+ try {
76
+ const j = JSON.parse(text) as { error?: string | { message?: string }; error_description?: string };
77
+ if (typeof j.error === "object" && j.error?.message) return j.error.message;
78
+ if (typeof j.error === "string") return [j.error, j.error_description].filter(Boolean).join(": ");
79
+ } catch {
80
+ // Not JSON.
81
+ }
82
+ return text.slice(0, 300);
83
+ };
84
+
85
+ type Token = { token: string; expires: number };
86
+
87
+ export class GcsClient {
88
+ private creds?: Credentials;
89
+ private token?: Promise<Token>;
90
+
91
+ constructor(readonly target: GcsTarget, private readonly fetchFn: StoreFetch = defaultFetch, private readonly readFile: (file: string) => string = (f) => readFileSync(f, "utf-8")) {}
92
+
93
+ private where(key: string): string {
94
+ return `gs://${this.target.bucket}/${key}`;
95
+ }
96
+
97
+ private credentials(): Credentials {
98
+ if (this.creds) return this.creds;
99
+ const source = this.target.credentialsFile ?? "the backend's credentials";
100
+ let c: Partial<Credentials>;
101
+ try {
102
+ c = JSON.parse(this.target.credentialsJson ?? this.readFile(this.target.credentialsFile ?? "")) as Partial<Credentials>;
103
+ } catch (e) {
104
+ throw new StoreError(`cannot read the credentials in ${source}: ${(e as Error).message}`);
105
+ }
106
+ if (c.type === "external_account" && c.token_url && c.audience && c.credential_source && (c.credential_source.file || c.credential_source.url)) return (this.creds = c as ExternalAccount);
107
+ if (c.type === "service_account" && c.client_email && c.private_key) return (this.creds = c as ServiceAccountKey);
108
+ throw new StoreError(`${source} is not an external_account file with a credential_source file or url, nor a service_account key`);
109
+ }
110
+
111
+ private async post(url: string, headers: Record<string, string>, body: string, what: string): Promise<Record<string, unknown>> {
112
+ const res = await this.fetchFn(url, { method: "POST", headers, body });
113
+ const text = await res.text();
114
+ if (!res.ok) throw new StoreError(`${what}: ${res.status} ${why(text)}`);
115
+ try {
116
+ return JSON.parse(text) as Record<string, unknown>;
117
+ } catch {
118
+ throw new StoreError(`${what}: the answer is not JSON`);
119
+ }
120
+ }
121
+
122
+ /** The job's OIDC token, from the file or URL the credentials name. */
123
+ private async subjectToken(c: ExternalAccount): Promise<string> {
124
+ const src = c.credential_source;
125
+ let text: string;
126
+ if (src.file) {
127
+ try {
128
+ text = this.readFile(src.file);
129
+ } catch (e) {
130
+ throw new StoreError(`cannot read the OIDC token in ${src.file}: ${(e as Error).message}`);
131
+ }
132
+ } else {
133
+ const res = await this.fetchFn(src.url!, { method: "GET", headers: src.headers ?? {} });
134
+ text = await res.text();
135
+ if (!res.ok) throw new StoreError(`the OIDC token at ${src.url!.split("?")[0]}: ${res.status} ${why(text)}`);
136
+ }
137
+ const field = src.format?.type === "json" ? src.format.subject_token_field_name : undefined;
138
+ let token = text.trim();
139
+ if (field) {
140
+ try {
141
+ token = String((JSON.parse(text) as Record<string, unknown>)[field] ?? "");
142
+ } catch {
143
+ throw new StoreError(`the job's OIDC token is not JSON with ${field}`);
144
+ }
145
+ }
146
+ if (!token) throw new StoreError("the job's OIDC token is empty");
147
+ return token;
148
+ }
149
+
150
+ private async impersonateWith(url: string, bearer: string, sa: string): Promise<Token> {
151
+ const r = await this.post(url, { "content-type": "application/json", authorization: `Bearer ${bearer}` }, JSON.stringify({ scope: [CLOUD_PLATFORM], lifetime: "3600s" }), `impersonating ${sa}`);
152
+ if (typeof r.accessToken !== "string") throw new StoreError(`IAM answered the impersonation of ${sa} without a token`);
153
+ const at = typeof r.expireTime === "string" ? Date.parse(r.expireTime) : NaN;
154
+ return { token: r.accessToken, expires: Number.isNaN(at) ? Date.now() + 3600_000 : at };
155
+ }
156
+
157
+ /** STS's federated token, then the impersonated service account's, when the credentials name one. */
158
+ private async exchange(c: ExternalAccount): Promise<Token> {
159
+ const sts = await this.post(
160
+ c.token_url,
161
+ { "content-type": "application/x-www-form-urlencoded" },
162
+ new URLSearchParams({
163
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
164
+ audience: c.audience,
165
+ scope: CLOUD_PLATFORM,
166
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
167
+ subject_token: await this.subjectToken(c),
168
+ subject_token_type: c.subject_token_type,
169
+ }).toString(),
170
+ `Google STS refused the OIDC token for ${c.audience}`,
171
+ );
172
+ if (typeof sts.access_token !== "string") throw new StoreError("Google STS answered without a token");
173
+ const fed = { token: sts.access_token, expires: Date.now() + Number(sts.expires_in ?? 3600) * 1000 };
174
+ if (!c.service_account_impersonation_url) return fed;
175
+ return this.impersonateWith(c.service_account_impersonation_url, fed.token, "the service account");
176
+ }
177
+
178
+ /** The identity's own token: the one the target gives, a key's self-signed JWT, or STS's for the OIDC token. */
179
+ private async own(): Promise<Token> {
180
+ if (this.target.accessToken) return { token: this.target.accessToken, expires: Date.now() + 3600_000 };
181
+ const c = this.credentials();
182
+ return c.type === "service_account" ? { token: selfSignedJwt(c, new Date()), expires: Date.now() + 3600_000 } : this.exchange(c);
183
+ }
184
+
185
+ /** An access token: once per client, again a minute before it expires. */
186
+ private async bearer(): Promise<string> {
187
+ const sa = this.target.impersonate;
188
+ const fresh = async (): Promise<Token> => {
189
+ if (!sa) return this.own();
190
+ const base = await this.own();
191
+ return this.impersonateWith(`${(this.target.iamEndpoint ?? IAM_CREDENTIALS).replace(/\/+$/, "")}/v1/projects/-/serviceAccounts/${encodeURIComponent(sa)}:generateAccessToken`, base.token, sa);
192
+ };
193
+ const again = (): Promise<Token> =>
194
+ (this.token = fresh().catch((e: unknown) => {
195
+ this.token = undefined;
196
+ throw e;
197
+ }));
198
+ let held = await (this.token ?? again());
199
+ if (held.expires - Date.now() < 60_000) held = await again();
200
+ return held.token;
201
+ }
202
+
203
+ private objectUrl(key: string): string {
204
+ return `${this.target.endpoint}/storage/v1/b/${encodeURIComponent(this.target.bucket)}/o/${encodeStrict(key)}`;
205
+ }
206
+
207
+ /** Write an object; with `ifGenerationMatch`, only when the object still has that generation (`0`: there is none), else StoreConflict. */
208
+ async put(key: string, body: string, contentType: string, ifGenerationMatch?: string): Promise<void> {
209
+ const t = this.target;
210
+ const url = `${t.endpoint}/upload/storage/v1/b/${encodeURIComponent(t.bucket)}/o?uploadType=media&name=${encodeStrict(key)}${ifGenerationMatch !== undefined ? `&ifGenerationMatch=${encodeStrict(ifGenerationMatch)}` : ""}`;
211
+ const res = await this.fetchFn(url, { method: "POST", headers: { "content-type": contentType, authorization: `Bearer ${await this.bearer()}` }, body });
212
+ if (res.status === 412) throw new StoreConflict(`PUT ${this.where(key)}: 412, it changed since it was read`);
213
+ if (!res.ok) throw new StoreError(`PUT ${this.where(key)}: ${res.status} ${why(await res.text())}`);
214
+ }
215
+
216
+ async get(key: string): Promise<string | undefined> {
217
+ const res = await this.fetchFn(`${this.objectUrl(key)}?alt=media`, { method: "GET", headers: { authorization: `Bearer ${await this.bearer()}` } });
218
+ if (res.status === 404) return undefined;
219
+ if (!res.ok) throw new StoreError(`GET ${this.where(key)}: ${res.status} ${why(await res.text())}`);
220
+ return res.text();
221
+ }
222
+
223
+ /** An object's metadata and never its body: whether it is there, and its generation. */
224
+ async head(key: string): Promise<{ exists: boolean; generation?: string }> {
225
+ const res = await this.fetchFn(this.objectUrl(key), { method: "GET", headers: { authorization: `Bearer ${await this.bearer()}` } });
226
+ const text = await res.text();
227
+ if (res.status === 404) return { exists: false };
228
+ if (!res.ok) throw new StoreError(`GET ${this.where(key)} metadata: ${res.status} ${why(text)}`);
229
+ let g: unknown;
230
+ try {
231
+ g = (JSON.parse(text) as { generation?: unknown }).generation;
232
+ } catch {
233
+ // Metadata that is not JSON: no generation.
234
+ }
235
+ return { exists: true, ...(g !== undefined && g !== null && g !== "" ? { generation: String(g) } : {}) };
236
+ }
237
+
238
+ /** Write an object only when there is none (ifGenerationMatch=0): true when this write made it, false when one was there. */
239
+ async putIfAbsent(key: string, body: string, contentType: string): Promise<boolean> {
240
+ try {
241
+ await this.put(key, body, contentType, "0");
242
+ return true;
243
+ } catch (e) {
244
+ if (e instanceof StoreConflict) return false;
245
+ throw e;
246
+ }
247
+ }
248
+
249
+ /** Delete an object's live generation. One that is not there is deleted already. */
250
+ async remove(key: string): Promise<void> {
251
+ const res = await this.fetchFn(this.objectUrl(key), { method: "DELETE", headers: { authorization: `Bearer ${await this.bearer()}` } });
252
+ if (!res.ok && res.status !== 404) throw new StoreError(`DELETE ${this.where(key)}: ${res.status} ${why(await res.text())}`);
253
+ }
254
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A root's state for a composed run (#4437): where it is, its lock, its pull
3
+ * and its write, and the unwritten keep. Plain lexicon code, lifted from
4
+ * terragucci at c493e0e.
5
+ */
6
+
7
+ export * from "./object-store";
8
+ export * from "./backend";
9
+ export * from "./store";
10
+ export * from "./exec";
11
+ export * from "./locks";
12
+ export * from "./root-states";
13
+ export { S3Client, s3FromEnv, type S3Target } from "./s3";
14
+ export { GcsClient, type GcsTarget } from "./gcs";
15
+ export { AzureBlobClient, type AzureTarget } from "./azure-blob";
@@ -0,0 +1,84 @@
1
+ // The source-workspace lock a state migration takes (terragucci#1274): a workspace a state is read from is locked
2
+ // through its own API before any state lock, and released when a later lock cannot be taken.
3
+ import { describe, expect, it } from "vitest";
4
+ import { SourceLockedError, StateLockedError, takeLocksWithSources, takeSourceLocks, type LockStore, type SourceLock } from "./locks";
5
+
6
+ const T = "2026-10-25T00:00:00Z";
7
+ const op = { operation: "terragucci migrate", info: "a state migration is writing this state" };
8
+
9
+ function store(location: string, held?: string): LockStore & { taken: string[]; released: number } {
10
+ const s = {
11
+ location,
12
+ lockLocation: `${location}.tflock`,
13
+ taken: [] as string[],
14
+ released: 0,
15
+ heldLock: async () => (held ? { id: "other", info: held } : undefined),
16
+ lock: async (info: string) => {
17
+ if (held) return false;
18
+ s.taken.push(info);
19
+ return true;
20
+ },
21
+ unlock: async () => {
22
+ s.released++;
23
+ },
24
+ adopt: () => {},
25
+ };
26
+ return s;
27
+ }
28
+
29
+ function source(root: string, location: string, refuse?: string): SourceLock & { reasons: string[]; released: number } {
30
+ const s = {
31
+ root,
32
+ location,
33
+ reasons: [] as string[],
34
+ released: 0,
35
+ lock: async (reason: string) => {
36
+ if (refuse) throw new Error(refuse);
37
+ s.reasons.push(reason);
38
+ return async () => {
39
+ s.released++;
40
+ };
41
+ },
42
+ };
43
+ return s;
44
+ }
45
+
46
+ describe("source-workspace locks", () => {
47
+ it("takes the source workspaces first, naming what reads them, then the states", async () => {
48
+ const src = source("envs/dev", "app.terraform.io/acme/dev");
49
+ const st = store("s3://b/envs/dev.tfstate");
50
+ const got = await takeLocksWithSources({ sources: [src], stores: [{ root: "envs/dev", store: st }], now: T, op, sourceOp: { operation: "terragucci migrate", info: "a state migration is reading this state" } });
51
+ expect(src.reasons).toEqual(["terragucci migrate: a state migration is reading this state"]);
52
+ expect(st.taken).toHaveLength(1);
53
+ expect(JSON.parse(st.taken[0]!)).toMatchObject({ Operation: "terragucci migrate", Created: T, Path: "b/envs/dev.tfstate" });
54
+ expect(got.sources.map((s) => s.root)).toEqual(["envs/dev"]);
55
+ expect(got.locks.map((l) => l.root)).toEqual(["envs/dev"]);
56
+ await got.sources[0]!.release();
57
+ expect(src.released).toBe(1);
58
+ });
59
+
60
+ it("names the operation's own info on a source lock without sourceOp", async () => {
61
+ const src = source("a", "ws");
62
+ await takeSourceLocks([src], op);
63
+ expect(src.reasons).toEqual(["terragucci migrate: a state migration is writing this state"]);
64
+ });
65
+
66
+ it("releases the source locks when a state is locked by another run", async () => {
67
+ const src = source("envs/dev", "ws");
68
+ const st = store("s3://b/k", JSON.stringify({ ID: "x", Operation: "OperationTypeApply", Who: "someone" }));
69
+ await expect(takeLocksWithSources({ sources: [src], stores: [{ root: "envs/dev", store: st }], now: T, op })).rejects.toBeInstanceOf(StateLockedError);
70
+ expect(src.released).toBe(1);
71
+ });
72
+
73
+ it("releases what it took and takes no state lock when a source workspace is locked", async () => {
74
+ const first = source("a", "ws-a");
75
+ const second = source("b", "ws-b", "its workspace ws-b is locked");
76
+ const st = store("s3://b/k");
77
+ const err = await takeLocksWithSources({ sources: [first, second], stores: [{ root: "a", store: st }], now: T, op }).catch((e: unknown) => e);
78
+ expect(err).toBeInstanceOf(SourceLockedError);
79
+ expect((err as SourceLockedError).root).toBe("b");
80
+ expect((err as Error).message).toContain("ws-b is locked");
81
+ expect(first.released).toBe(1);
82
+ expect(st.taken).toEqual([]);
83
+ });
84
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Taking a set of state locks in one order, each the way its backend takes
3
+ * it, with lock info that names the operation and the run. A lock another
4
+ * run holds stops the take: every lock taken so far is released, and the
5
+ * error names the holder from the lock info it left.
6
+ *
7
+ * A run can also hold the lock of a workspace a state is read from, which
8
+ * no backend block of the root names (a Terraform Cloud or Enterprise
9
+ * workspace a state migration moves from, terragucci#1274): the workspace's
10
+ * own lock, taken through its API before any state lock (`takeSourceLocks`,
11
+ * `takeLocksWithSources`).
12
+ *
13
+ * Lifted from terragucci's `migrate.ts` (c493e0e): `lockInfo`,
14
+ * `LockOperation`, `HeldLock` and `takeLocks`, and the source-workspace lock
15
+ * of its `takeLocks` (lines 1045 to 1056).
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ import { hostname } from "node:os";
19
+ import type { LockHolder, StateStore } from "./store";
20
+
21
+ /** What a lock says holds it: the operation and a line of info, as the binary prints them when it finds the lock held. */
22
+ export interface LockOperation {
23
+ operation: string;
24
+ info: string;
25
+ }
26
+
27
+ /** The lock info a run writes, in the shape the binary writes and prints when it finds a lock held. */
28
+ export function lockInfo(path: string, now: string, op: LockOperation, who = `chant@${hostname()}`): string {
29
+ return JSON.stringify({ ID: randomUUID(), Operation: op.operation, Info: op.info, Who: who, Version: "", Created: now, Path: path });
30
+ }
31
+
32
+ /** Who holds a lock, from the lock info it left: what is there of it. */
33
+ export interface LockHolderInfo {
34
+ id: string;
35
+ who?: string;
36
+ operation?: string;
37
+ info?: string;
38
+ created?: string;
39
+ }
40
+
41
+ /** The holder a lock info names. */
42
+ export function holderOf(held: LockHolder): LockHolderInfo {
43
+ let v: Record<string, unknown> = {};
44
+ try {
45
+ v = JSON.parse(held.info) as Record<string, unknown>;
46
+ } catch {
47
+ // Lock info that is not JSON names no one.
48
+ }
49
+ const s = (k: string): string | undefined => (typeof v[k] === "string" && v[k] !== "" ? (v[k] as string) : undefined);
50
+ return { id: held.id, ...(s("Who") ? { who: s("Who") } : {}), ...(s("Operation") ? { operation: s("Operation") } : {}), ...(s("Info") ? { info: s("Info") } : {}), ...(s("Created") ? { created: s("Created") } : {}) };
51
+ }
52
+
53
+ /** A lock held by another run stopped the take; nothing was pulled. */
54
+ export class StateLockedError extends Error {
55
+ constructor(
56
+ readonly root: string,
57
+ readonly lockLocation: string,
58
+ readonly holder?: LockHolderInfo,
59
+ ) {
60
+ const by = holder ? [holder.who && `by ${holder.who}`, holder.operation, holder.info && `(${holder.info})`, holder.created && `since ${holder.created}`].filter(Boolean).join(" ") : "";
61
+ super(`${root}: its state is locked at ${lockLocation}${by ? ` ${by}` : ""}, so nothing was pulled`);
62
+ this.name = "StateLockedError";
63
+ }
64
+ }
65
+
66
+ /** What taking a lock needs of a store: a caller with a store of its own (terragucci's, which also reads versions) passes that. */
67
+ export type LockStore = Pick<StateStore, "location" | "lockLocation" | "heldLock" | "lock" | "unlock" | "adopt">;
68
+
69
+ /** A lock taken, on one root's state. */
70
+ export interface HeldLock<S extends LockStore = StateStore> {
71
+ root: string;
72
+ store: S;
73
+ /** The lock was another run's, kept on purpose with a state it could not write, and this take took it over. */
74
+ adopted: boolean;
75
+ release: () => Promise<void>;
76
+ }
77
+
78
+ /**
79
+ * Take the lock of each state, in the order given. Releases what it took and
80
+ * throws StateLockedError when one is held and `adopt` does not take it over.
81
+ * `adopt` sees a held lock and says whether this take takes it over (a lock
82
+ * an earlier run of the same operation left held on purpose).
83
+ */
84
+ export async function takeLocks<S extends LockStore = StateStore>(
85
+ stores: readonly { root: string; store: S }[],
86
+ now: string,
87
+ op: LockOperation,
88
+ adopt?: (root: string, store: S, held: LockHolder) => Promise<boolean>,
89
+ who?: string,
90
+ ): Promise<HeldLock<S>[]> {
91
+ const held: HeldLock<S>[] = [];
92
+ const releaseAll = async (): Promise<void> => {
93
+ for (const h of held.reverse()) await h.release().catch(() => {});
94
+ };
95
+ for (const { root, store } of stores) {
96
+ let got: boolean;
97
+ try {
98
+ got = await store.lock(lockInfo(store.location.replace(/^[a-z0-9]+:\/\//, ""), now, op, who));
99
+ } catch (e) {
100
+ await releaseAll();
101
+ throw new Error(`${root}: the lock ${store.lockLocation} could not be taken: ${(e as Error).message}`);
102
+ }
103
+ if (got) {
104
+ held.push({ root, store, adopted: false, release: () => store.unlock() });
105
+ continue;
106
+ }
107
+ const info = await store.heldLock().catch(() => undefined);
108
+ if (info && adopt && (await adopt(root, store, info).catch(() => false))) {
109
+ store.adopt(info);
110
+ held.push({ root, store, adopted: true, release: () => store.unlock() });
111
+ continue;
112
+ }
113
+ await releaseAll();
114
+ throw new StateLockedError(root, store.lockLocation, info ? holderOf(info) : undefined);
115
+ }
116
+ return held;
117
+ }
118
+
119
+ /**
120
+ * The lock of a workspace a state is read from, taken through the
121
+ * workspace's own API (Terraform Cloud's `actions/lock`), not a lock object
122
+ * beside a state. `lock` takes it with a reason the workspace shows, and
123
+ * returns its release; it throws when another run holds it.
124
+ */
125
+ export interface SourceLock {
126
+ root: string;
127
+ /** The workspace, for messages. */
128
+ location: string;
129
+ lock(reason: string): Promise<() => Promise<void>>;
130
+ }
131
+
132
+ /** A source workspace's lock, taken. */
133
+ export interface HeldSourceLock {
134
+ root: string;
135
+ location: string;
136
+ release: () => Promise<void>;
137
+ }
138
+
139
+ /** A source workspace's lock could not be taken: another run holds it, or the API refused. Nothing was read. */
140
+ export class SourceLockedError extends Error {
141
+ constructor(
142
+ readonly root: string,
143
+ readonly location: string,
144
+ readonly reason: string,
145
+ ) {
146
+ super(`${root}: the lock of the workspace it is read from (${location}) could not be taken: ${reason}`);
147
+ this.name = "SourceLockedError";
148
+ }
149
+ }
150
+
151
+ /** The reason a source workspace's lock shows: the operation and its info, as a state lock's info names them. */
152
+ export const sourceLockReason = (op: LockOperation): string => `${op.operation}: ${op.info}`;
153
+
154
+ /**
155
+ * Take each source workspace's lock, in the order given, so no run writes a
156
+ * state while it is read. Releases what it took and throws SourceLockedError
157
+ * when one cannot be taken.
158
+ */
159
+ export async function takeSourceLocks(sources: readonly SourceLock[], op: LockOperation): Promise<HeldSourceLock[]> {
160
+ const held: HeldSourceLock[] = [];
161
+ for (const s of sources) {
162
+ try {
163
+ held.push({ root: s.root, location: s.location, release: await s.lock(sourceLockReason(op)) });
164
+ } catch (e) {
165
+ for (const h of held.reverse()) await h.release().catch(() => {});
166
+ throw new SourceLockedError(s.root, s.location, (e as Error).message);
167
+ }
168
+ }
169
+ return held;
170
+ }
171
+
172
+ export interface LocksWithSourcesInput<S extends LockStore> {
173
+ /** The workspaces states are read from, locked first. */
174
+ sources: readonly SourceLock[];
175
+ /** The states written, locked after the sources, in the order given. */
176
+ stores: readonly { root: string; store: S }[];
177
+ now: string;
178
+ /** What the state locks name. */
179
+ op: LockOperation;
180
+ /** What a source workspace's lock names; default `op`. A run that only reads the source says so here. */
181
+ sourceOp?: LockOperation;
182
+ adopt?: (root: string, store: S, held: LockHolder) => Promise<boolean>;
183
+ who?: string;
184
+ }
185
+
186
+ /**
187
+ * Every lock a run that reads from source workspaces and writes states
188
+ * holds: the source workspaces' first, then the states'. When any cannot be
189
+ * taken, every lock taken so far is released, and the error is
190
+ * SourceLockedError or StateLockedError.
191
+ */
192
+ export async function takeLocksWithSources<S extends LockStore = StateStore>(input: LocksWithSourcesInput<S>): Promise<{ sources: HeldSourceLock[]; locks: HeldLock<S>[] }> {
193
+ const sources = await takeSourceLocks(input.sources, input.sourceOp ?? input.op);
194
+ try {
195
+ const locks = await takeLocks(input.stores, input.now, input.op, input.adopt, input.who);
196
+ return { sources, locks };
197
+ } catch (e) {
198
+ for (const h of sources.reverse()) await h.release().catch(() => {});
199
+ throw e;
200
+ }
201
+ }