@intentius/chant-lexicon-terraform 0.124.51 → 0.124.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/__fixtures__/pr-loop/estate.component.d.ts +7 -0
- package/dist/__fixtures__/pr-loop/estate.component.d.ts.map +1 -0
- package/dist/__fixtures__/pr-loop/estate.component.js +7 -0
- package/dist/__fixtures__/report/generate.d.ts +26 -0
- package/dist/__fixtures__/report/generate.d.ts.map +1 -0
- package/dist/__fixtures__/report/generate.js +58 -0
- package/dist/applied-state.d.ts +27 -0
- package/dist/applied-state.d.ts.map +1 -0
- package/dist/applied-state.js +44 -0
- package/dist/backend-config.d.ts +53 -0
- package/dist/backend-config.d.ts.map +1 -0
- package/dist/backend-config.js +136 -0
- package/dist/behaviour/kinds.d.ts.map +1 -1
- package/dist/behaviour/kinds.js +4 -0
- package/dist/behaviour/predict.d.ts.map +1 -1
- package/dist/behaviour/predict.js +6 -2
- package/dist/behaviour/reference-catalog.d.ts +16 -5
- package/dist/behaviour/reference-catalog.d.ts.map +1 -1
- package/dist/behaviour/reference-catalog.js +16 -5
- package/dist/behaviour/request.d.ts.map +1 -1
- package/dist/behaviour/request.js +3 -2
- package/dist/changed-roots.d.ts +6 -4
- package/dist/changed-roots.d.ts.map +1 -1
- package/dist/changed-roots.js +14 -18
- package/dist/commands.d.ts +5 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +87 -4
- package/dist/components/capability-plugin.d.ts +65 -3
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +197 -13
- package/dist/components/compose-group.d.ts +145 -0
- package/dist/components/compose-group.d.ts.map +1 -0
- package/dist/components/compose-group.js +407 -0
- package/dist/components/placeholder-preview.d.ts +78 -0
- package/dist/components/placeholder-preview.d.ts.map +1 -0
- package/dist/components/placeholder-preview.js +254 -0
- package/dist/compose/apply-events.d.ts +46 -0
- package/dist/compose/apply-events.d.ts.map +1 -0
- package/dist/compose/apply-events.js +77 -0
- package/dist/compose/estate.d.ts +194 -0
- package/dist/compose/estate.d.ts.map +1 -0
- package/dist/compose/estate.js +363 -0
- package/dist/compose/hcl.d.ts +99 -0
- package/dist/compose/hcl.d.ts.map +1 -0
- package/dist/compose/hcl.js +302 -0
- package/dist/compose/module-address.d.ts +24 -0
- package/dist/compose/module-address.d.ts.map +1 -0
- package/dist/compose/module-address.js +43 -0
- package/dist/compose/root.d.ts +127 -0
- package/dist/compose/root.d.ts.map +1 -0
- package/dist/compose/root.js +586 -0
- package/dist/compose/run.d.ts +183 -0
- package/dist/compose/run.d.ts.map +1 -0
- package/dist/compose/run.js +447 -0
- package/dist/compose/slice.d.ts +102 -0
- package/dist/compose/slice.d.ts.map +1 -0
- package/dist/compose/slice.js +249 -0
- package/dist/compose/state-address.d.ts +28 -0
- package/dist/compose/state-address.d.ts.map +1 -0
- package/dist/compose/state-address.js +154 -0
- package/dist/compose/state-merge.d.ts +52 -0
- package/dist/compose/state-merge.d.ts.map +1 -0
- package/dist/compose/state-merge.js +135 -0
- package/dist/compose.d.ts +179 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +482 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.js +7 -3
- package/dist/config.d.ts +59 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -9
- package/dist/describe-resources.d.ts +74 -12
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +374 -66
- package/dist/drift-read.d.ts +33 -0
- package/dist/drift-read.d.ts.map +1 -0
- package/dist/drift-read.js +243 -0
- package/dist/drift-record.d.ts +147 -0
- package/dist/drift-record.d.ts.map +1 -0
- package/dist/drift-record.js +383 -0
- package/dist/hcl/edges.d.ts.map +1 -1
- package/dist/hcl/edges.js +24 -1
- package/dist/hcl/files.d.ts +61 -0
- package/dist/hcl/files.d.ts.map +1 -0
- package/dist/hcl/files.js +221 -0
- package/dist/hcl/instances.d.ts +82 -0
- package/dist/hcl/instances.d.ts.map +1 -0
- package/dist/hcl/instances.js +390 -0
- package/dist/hcl/parse.d.ts +81 -9
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/parse.js +114 -16
- package/dist/hcl/roots.d.ts.map +1 -1
- package/dist/hcl/roots.js +5 -0
- package/dist/hcl/suppressions.d.ts.map +1 -1
- package/dist/hcl/suppressions.js +8 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -2
- package/dist/integrity.json +3 -3
- package/dist/live-plan-reasons.d.ts +21 -0
- package/dist/live-plan-reasons.d.ts.map +1 -0
- package/dist/live-plan-reasons.js +47 -0
- package/dist/lsp/option-keys.d.ts.map +1 -1
- package/dist/lsp/option-keys.js +2 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +6 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +5 -1
- package/dist/op/activities/live-detect.d.ts +4 -3
- package/dist/op/activities/live-detect.d.ts.map +1 -1
- package/dist/op/activities/live-detect.js +57 -32
- package/dist/op/activities/pin-rollout.d.ts +2 -0
- package/dist/op/activities/pin-rollout.d.ts.map +1 -1
- package/dist/op/activities/pin-rollout.js +1 -0
- package/dist/op/activities/terraform.d.ts +153 -5
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activities/terraform.js +205 -20
- package/dist/op/activity-contracts.d.ts +27 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/activity-contracts.js +19 -1
- package/dist/op/builders.d.ts +7 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +9 -0
- package/dist/pin/edit.d.ts.map +1 -1
- package/dist/pin/edit.js +35 -0
- package/dist/pin/rollout.d.ts +2 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +14 -11
- package/dist/plan-shape.d.ts +30 -0
- package/dist/plan-shape.d.ts.map +1 -0
- package/dist/plan-shape.js +125 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +32 -18
- package/dist/report.d.ts +216 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +666 -0
- package/dist/skills/chant-terraform.md +1 -1
- package/dist/state/azure-blob.d.ts +68 -0
- package/dist/state/azure-blob.d.ts.map +1 -0
- package/dist/state/azure-blob.js +201 -0
- package/dist/state/backend.d.ts +158 -0
- package/dist/state/backend.d.ts.map +1 -0
- package/dist/state/backend.js +406 -0
- package/dist/state/exec.d.ts +39 -0
- package/dist/state/exec.d.ts.map +1 -0
- package/dist/state/exec.js +80 -0
- package/dist/state/gcs.d.ts +62 -0
- package/dist/state/gcs.d.ts.map +1 -0
- package/dist/state/gcs.js +224 -0
- package/dist/state/index.d.ts +15 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +14 -0
- package/dist/state/locks.d.ts +105 -0
- package/dist/state/locks.d.ts.map +1 -0
- package/dist/state/locks.js +130 -0
- package/dist/state/object-store.d.ts +37 -0
- package/dist/state/object-store.d.ts.map +1 -0
- package/dist/state/object-store.js +20 -0
- package/dist/state/root-states.d.ts +131 -0
- package/dist/state/root-states.d.ts.map +1 -0
- package/dist/state/root-states.js +241 -0
- package/dist/state/s3.d.ts +68 -0
- package/dist/state/s3.d.ts.map +1 -0
- package/dist/state/s3.js +170 -0
- package/dist/state/store.d.ts +51 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +185 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +2 -1
- package/package.json +7 -3
- package/src/__fixtures__/ACCEPTANCE.md +69 -7
- package/src/__fixtures__/compose/apply-failed-in-app.jsonl +12 -0
- package/src/__fixtures__/compose/state-app.json +66 -0
- package/src/__fixtures__/compose/state-edge.json +40 -0
- package/src/__fixtures__/compose/state-net.json +58 -0
- package/src/__fixtures__/compose/three-app.plan.json +184 -0
- package/src/__fixtures__/compose/three-composed.plan.json +509 -0
- package/src/__fixtures__/compose/three-edge.plan.json +144 -0
- package/src/__fixtures__/compose/three-net.plan.json +174 -0
- package/src/__fixtures__/compose/unknown-composed.plan.json +228 -0
- package/src/__fixtures__/compose/working-after-apply.json +145 -0
- package/src/__fixtures__/config-blocks/app/main.tf +61 -0
- package/src/__fixtures__/config-blocks/app/modules/network/main.tf +4 -0
- package/src/__fixtures__/config-blocks/app-json/main.tf.json +39 -0
- package/src/__fixtures__/config-files/shadow/main.tf +3 -0
- package/src/__fixtures__/config-files/shadow/main.tofu +3 -0
- package/src/__fixtures__/config-files/shadow/other.tf +3 -0
- package/src/__fixtures__/config-files/tf/main.tf +11 -0
- package/src/__fixtures__/config-files/tf-json/main.tf.json +18 -0
- package/src/__fixtures__/config-files/tofu/main.tofu +11 -0
- package/src/__fixtures__/config-files/tofu-json/main.tofu.json +18 -0
- package/src/__fixtures__/config-files/tofu-only/main.tofu +3 -0
- package/src/__fixtures__/drift-record/README.md +11 -0
- package/src/__fixtures__/drift-record/live-plan-sections.json +27 -0
- package/src/__fixtures__/instance-forms/app/main.tf +110 -0
- package/src/__fixtures__/instance-forms/app/modules/svc/main.tf +3 -0
- package/src/__fixtures__/instance-vars/app/main.tf +52 -0
- package/src/__fixtures__/instance-vars/app/modules/app/main.tf +12 -0
- package/src/__fixtures__/instance-vars/app/modules/edge/main.tf +3 -0
- package/src/__fixtures__/instance-vars/app/terraform.tfvars +1 -0
- package/src/__fixtures__/live-module-keyed/live-graph.json +259 -0
- package/src/__fixtures__/live-module-keyed/live-plan.json +87 -0
- package/src/__fixtures__/live-module-keyed/root/estate.chdf.hcl +1 -0
- package/src/__fixtures__/live-module-keyed/root/main.tf +21 -0
- package/src/__fixtures__/live-module-keyed/root/modules/app/main.tf +17 -0
- package/src/__fixtures__/live-module-keyed/root/modules/edge/main.tf +7 -0
- package/src/__fixtures__/live-module-keyed/root/versions.tf +30 -0
- package/src/__fixtures__/live-module-keyed/show-state.json +146 -0
- package/src/__fixtures__/pr-loop/README.md +50 -0
- package/src/__fixtures__/pr-loop/chant.config.json +13 -0
- package/src/__fixtures__/pr-loop/estate.component.ts +10 -0
- package/src/__fixtures__/pr-loop/roots/a/main.tf +11 -0
- package/src/__fixtures__/pr-loop/roots/app/main.tf +19 -0
- package/src/__fixtures__/pr-loop/roots/b/main.tf +7 -0
- package/src/__fixtures__/pr-loop/roots/dns/main.tf +3 -0
- package/src/__fixtures__/pr-loop/roots/net/main.tf +7 -0
- package/src/__fixtures__/report/applied.show.json +51 -0
- package/src/__fixtures__/report/generate.ts +63 -0
- package/src/__fixtures__/report/plan.show.json +200 -0
- package/src/__fixtures__/report/tf-apply/change-set.json +170 -0
- package/src/__fixtures__/report/tf-apply/report.json +338 -0
- package/src/__fixtures__/report/tf-plan/change-set.json +170 -0
- package/src/__fixtures__/report/tf-plan/report.json +320 -0
- package/src/__fixtures__/three-cloud-azure.graph.json +290 -0
- package/src/applied-state.ts +52 -0
- package/src/backend-config.test.ts +105 -0
- package/src/backend-config.ts +133 -0
- package/src/behaviour/kinds.ts +4 -0
- package/src/behaviour/predict.ts +5 -2
- package/src/behaviour/reference-catalog.plugin.test.ts +65 -0
- package/src/behaviour/reference-catalog.ts +16 -5
- package/src/behaviour/request.ts +3 -2
- package/src/change-set-instances.test.ts +98 -0
- package/src/changed-roots.ts +14 -16
- package/src/commands.ts +84 -4
- package/src/components/capability-plugin.test.ts +172 -5
- package/src/components/capability-plugin.ts +212 -11
- package/src/components/change-set-graph.e2e.test.ts +11 -5
- package/src/components/compose-group.test.ts +486 -0
- package/src/components/compose-group.ts +489 -0
- package/src/components/demo-composed-roots.e2e.test.ts +440 -0
- package/src/components/member-outputs.test.ts +76 -0
- package/src/components/placeholder-preview.test.ts +147 -0
- package/src/components/placeholder-preview.ts +257 -0
- package/src/components/pr-loop-compose.tofu.e2e.test.ts +363 -0
- package/src/components/pr-loop.tofu.test.ts +112 -56
- package/src/compose/apply-events.test.ts +62 -0
- package/src/compose/apply-events.ts +100 -0
- package/src/compose/estate.choudoufu.e2e.test.ts +112 -0
- package/src/compose/estate.test.ts +421 -0
- package/src/compose/estate.ts +443 -0
- package/src/compose/hcl.ts +311 -0
- package/src/compose/module-address.ts +49 -0
- package/src/compose/root.test.ts +416 -0
- package/src/compose/root.ts +592 -0
- package/src/compose/run.test.ts +440 -0
- package/src/compose/run.tofu.test.ts +176 -0
- package/src/compose/run.ts +527 -0
- package/src/compose/slice.test.ts +184 -0
- package/src/compose/slice.ts +290 -0
- package/src/compose/state-address.ts +157 -0
- package/src/compose/state-merge.test.ts +131 -0
- package/src/compose/state-merge.ts +148 -0
- package/src/compose.test.ts +248 -0
- package/src/compose.ts +627 -0
- package/src/composites/terraform-apply-op.test.ts +9 -0
- package/src/composites/terraform-apply-op.ts +7 -3
- package/src/composites/terraform-pin-rollout-op.test.ts +2 -2
- package/src/config-file-listers.test.ts +265 -0
- package/src/config.ts +44 -9
- package/src/describe-resources.engine-facts.test.ts +412 -0
- package/src/describe-resources.live-module-keyed.test.ts +290 -0
- package/src/describe-resources.ts +416 -70
- package/src/drift-read.ts +284 -0
- package/src/drift-record.test.ts +223 -0
- package/src/drift-record.ts +455 -0
- package/src/hcl/config-blocks.test.ts +174 -0
- package/src/hcl/edges.ts +21 -1
- package/src/hcl/files.ts +234 -0
- package/src/hcl/instance-forms.test.ts +94 -0
- package/src/hcl/instances.test.ts +137 -0
- package/src/hcl/instances.ts +422 -0
- package/src/hcl/parse.test.ts +139 -1
- package/src/hcl/parse.ts +149 -16
- package/src/hcl/roots.ts +6 -0
- package/src/hcl/suppressions.ts +8 -2
- package/src/index.ts +76 -0
- package/src/live-plan-reasons.ts +54 -0
- package/src/lsp/completions.test.ts +1 -1
- package/src/lsp/option-keys.ts +2 -0
- package/src/op/activities/choudoufu-plan-set-op.test.ts +111 -0
- package/src/op/activities/index.ts +9 -0
- package/src/op/activities/live-detect.ts +55 -26
- package/src/op/activities/pin-rollout.ts +3 -0
- package/src/op/activities/registry.test.ts +2 -1
- package/src/op/activities/terraform.test.ts +55 -0
- package/src/op/activities/terraform.ts +266 -22
- package/src/op/activity-contracts.test.ts +1 -0
- package/src/op/activity-contracts.ts +24 -1
- package/src/op/builders.ts +14 -0
- package/src/pin/bundle.test.ts +2 -0
- package/src/pin/edit.ts +30 -0
- package/src/pin/rollout.ts +16 -11
- package/src/plan-shape.test.ts +148 -0
- package/src/plan-shape.ts +128 -0
- package/src/plugin.ts +33 -19
- package/src/report.test.ts +423 -0
- package/src/report.ts +752 -0
- package/src/signal-only-root.test.ts +72 -0
- package/src/skills/chant-terraform.md +1 -1
- package/src/state/azure-blob.ts +223 -0
- package/src/state/backend-verdict.test.ts +187 -0
- package/src/state/backend.test.ts +127 -0
- package/src/state/backend.ts +488 -0
- package/src/state/exec.ts +111 -0
- package/src/state/gcs.ts +254 -0
- package/src/state/index.ts +15 -0
- package/src/state/locks.test.ts +84 -0
- package/src/state/locks.ts +201 -0
- package/src/state/object-store.ts +35 -0
- package/src/state/root-states-floci.e2e.test.ts +66 -0
- package/src/state/root-states.test.ts +462 -0
- package/src/state/root-states.ts +309 -0
- package/src/state/s3.ts +193 -0
- package/src/state/store.ts +225 -0
- package/src/terragrunt/affected.ts +2 -1
- package/src/workspace-kinds.test.ts +4 -17
- package/workspace-kinds.json +1 -24
- package/dist/workspace-member.d.ts +0 -30
- package/dist/workspace-member.d.ts.map +0 -1
- package/dist/workspace-member.js +0 -91
- package/schemas/pulumi.schema.json +0 -34
- package/src/testdata/pulumi-member/Pulumi.yaml +0 -2
- package/src/testdata/pulumi-member/stack-export.json +0 -53
- package/src/testdata/pulumi-unexported/Pulumi.yaml +0 -2
- package/src/workspace-member.test.ts +0 -89
- package/src/workspace-member.ts +0 -126
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks, pulls, writes and the unwritten keep (#4437), with a fake exec and
|
|
3
|
+
* in-memory object stores that answer the real stores' requests.
|
|
4
|
+
*
|
|
5
|
+
* Carried over from terragucci at c493e0e: test/migrate.test.ts "the S3 lock
|
|
6
|
+
* a migration holds", test/state-stores.test.ts "a gcs or azurerm state's
|
|
7
|
+
* lock" (with its fakeGcs and fakeAzure), and "refuses when the gcs state's
|
|
8
|
+
* lock is held, and writes nothing" rewritten as a take that names the
|
|
9
|
+
* holder. The keep and its later write are compose.ts's, which terragucci
|
|
10
|
+
* tested only through a whole composed run; they are tested here on their own.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { stateObject, type LockableState } from "./backend";
|
|
17
|
+
import { pullState, type BinaryExec, type StateFile } from "./exec";
|
|
18
|
+
import { lockInfo, StateLockedError } from "./locks";
|
|
19
|
+
import type { StoreFetch } from "./object-store";
|
|
20
|
+
import { COMPOSE_LOCK, DEFAULT_RETRY_DELAYS, LEGACY_UNWRITTEN_SUFFIX, RootStates, StateRefusedError, UNWRITTEN_SUFFIX, type RootState } from "./root-states";
|
|
21
|
+
import { S3Client } from "./s3";
|
|
22
|
+
import { stateStore } from "./store";
|
|
23
|
+
|
|
24
|
+
const T = (m: number): string => new Date(Date.UTC(2026, 9, 25, 12, m)).toISOString();
|
|
25
|
+
const tmp = (): string => mkdtempSync(join(tmpdir(), "chant-states-"));
|
|
26
|
+
const state = (lineage: string, serial: number): StateFile => ({ version: 4, terraform_version: "1.10.0", serial, lineage, outputs: {}, resources: [], check_results: null });
|
|
27
|
+
const lockText = (who: string, op = "OperationTypeApply", info = "") => JSON.stringify({ ID: `id-${who}`, Operation: op, Info: info, Who: who, Version: "1.10.0", Created: T(1), Path: "x" });
|
|
28
|
+
|
|
29
|
+
type Answer = Awaited<ReturnType<StoreFetch>>;
|
|
30
|
+
const answer = (status = 200, body = "", headers: Record<string, string> = {}): Answer => {
|
|
31
|
+
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
32
|
+
return { ok: status < 300, status, text: async () => body, headers: { get: (n: string) => h[n.toLowerCase()] ?? null, forEach: (fn: (v: string, k: string) => void) => Object.entries(h).forEach(([k, v]) => fn(v, k)) } as Answer["headers"] };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** S3 in memory, path-style at http://s3.test/<bucket>/<key>: a PUT with If-None-Match only when absent, and every request seen. `fail` refuses a PUT whose key it matches. */
|
|
36
|
+
function fakeS3() {
|
|
37
|
+
const objects = new Map<string, string>();
|
|
38
|
+
const seen: string[] = [];
|
|
39
|
+
let fail: RegExp | undefined;
|
|
40
|
+
const fetch: StoreFetch = async (url, init) => {
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
const key = decodeURIComponent(u.pathname.slice(1));
|
|
43
|
+
seen.push(`${init.method} ${key}`);
|
|
44
|
+
if (init.method === "PUT") {
|
|
45
|
+
if (fail?.test(key)) return answer(503, "SlowDown");
|
|
46
|
+
if (init.headers["if-none-match"] === "*" && objects.has(key)) return answer(412);
|
|
47
|
+
objects.set(key, String(init.body));
|
|
48
|
+
return answer(200);
|
|
49
|
+
}
|
|
50
|
+
if (init.method === "DELETE") {
|
|
51
|
+
objects.delete(key);
|
|
52
|
+
return answer(204);
|
|
53
|
+
}
|
|
54
|
+
const body = objects.get(key);
|
|
55
|
+
if (body === undefined) return answer(404);
|
|
56
|
+
return answer(200, init.method === "HEAD" ? "" : body);
|
|
57
|
+
};
|
|
58
|
+
return { objects, seen, fetch, failPuts: (re?: RegExp) => void (fail = re) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** GCS's JSON API in memory, at http://gcs.test: objects by generation, a write conditional on ifGenerationMatch. */
|
|
62
|
+
function fakeGcs() {
|
|
63
|
+
const objects = new Map<string, { generation: string; body: string }>();
|
|
64
|
+
let n = 1000;
|
|
65
|
+
const fetch: StoreFetch = async (url, init) => {
|
|
66
|
+
const u = new URL(url);
|
|
67
|
+
let m = /^\/upload\/storage\/v1\/b\/([^/]+)\/o$/.exec(u.pathname);
|
|
68
|
+
if (m && init.method === "POST") {
|
|
69
|
+
const key = `${m[1]}/${u.searchParams.get("name")}`;
|
|
70
|
+
const cond = u.searchParams.get("ifGenerationMatch");
|
|
71
|
+
if (cond !== null && (objects.get(key)?.generation ?? "0") !== cond) return answer(412);
|
|
72
|
+
const generation = String(++n);
|
|
73
|
+
objects.set(key, { generation, body: String(init.body) });
|
|
74
|
+
return answer(200, JSON.stringify({ generation }));
|
|
75
|
+
}
|
|
76
|
+
m = /^\/storage\/v1\/b\/([^/]+)\/o\/(.+)$/.exec(u.pathname);
|
|
77
|
+
if (!m) return answer(404);
|
|
78
|
+
const key = `${m[1]}/${decodeURIComponent(m[2]!)}`;
|
|
79
|
+
if (init.method === "DELETE") return answer(objects.delete(key) ? 204 : 404);
|
|
80
|
+
const found = objects.get(key);
|
|
81
|
+
if (!found) return answer(404);
|
|
82
|
+
if (u.searchParams.get("alt") === "media") return answer(200, found.body, { "x-goog-generation": found.generation });
|
|
83
|
+
return answer(200, JSON.stringify({ name: m[2], generation: found.generation }));
|
|
84
|
+
};
|
|
85
|
+
return { objects, fetch };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Azure Blob Storage in memory, at https://acme.blob.core.windows.net: blobs with snapshots, leases and metadata, a write refused without the lease that holds the blob. */
|
|
89
|
+
function fakeAzure() {
|
|
90
|
+
type Blob = { body: string; meta: Record<string, string>; lease?: string; snapshots: string[] };
|
|
91
|
+
const blobs = new Map<string, Blob>();
|
|
92
|
+
let n = 0;
|
|
93
|
+
const fetch: StoreFetch = async (url, init) => {
|
|
94
|
+
const u = new URL(url);
|
|
95
|
+
const key = decodeURIComponent(u.pathname.slice(1));
|
|
96
|
+
const h = Object.fromEntries(Object.entries(init.headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
97
|
+
const comp = u.searchParams.get("comp");
|
|
98
|
+
const b = blobs.get(key);
|
|
99
|
+
const meta = (): Record<string, string> => Object.fromEntries(Object.entries(h).filter(([k]) => k.startsWith("x-ms-meta-")).map(([k, v]) => [k.slice(10), v]));
|
|
100
|
+
if (init.method === "PUT" && comp === "lease") {
|
|
101
|
+
if (!b) return answer(404);
|
|
102
|
+
if (h["x-ms-lease-action"] === "acquire") {
|
|
103
|
+
if (b.lease) return answer(409);
|
|
104
|
+
b.lease = h["x-ms-proposed-lease-id"];
|
|
105
|
+
return answer(201);
|
|
106
|
+
}
|
|
107
|
+
if (b.lease !== h["x-ms-lease-id"]) return answer(409);
|
|
108
|
+
delete b.lease;
|
|
109
|
+
return answer(200);
|
|
110
|
+
}
|
|
111
|
+
if (init.method === "PUT" && comp === "snapshot") {
|
|
112
|
+
if (!b) return answer(404);
|
|
113
|
+
b.snapshots.push(b.body);
|
|
114
|
+
return answer(201, "", { "x-ms-snapshot": `snap-${++n}` });
|
|
115
|
+
}
|
|
116
|
+
if (init.method === "PUT" && comp === "metadata") {
|
|
117
|
+
if (!b) return answer(404);
|
|
118
|
+
if (b.lease && b.lease !== h["x-ms-lease-id"]) return answer(412);
|
|
119
|
+
b.meta = meta();
|
|
120
|
+
return answer(200);
|
|
121
|
+
}
|
|
122
|
+
if (init.method === "PUT") {
|
|
123
|
+
if (h["if-none-match"] === "*" && b) return answer(409);
|
|
124
|
+
if (b?.lease && b.lease !== h["x-ms-lease-id"]) return answer(412);
|
|
125
|
+
blobs.set(key, { body: String(init.body), meta: meta(), ...(b?.lease ? { lease: b.lease } : {}), snapshots: b?.snapshots ?? [] });
|
|
126
|
+
return answer(201);
|
|
127
|
+
}
|
|
128
|
+
if (init.method === "DELETE") return answer(blobs.delete(key) ? 202 : 404);
|
|
129
|
+
if (!b) return answer(404);
|
|
130
|
+
const headers = { etag: `"${b.body.length}"`, "x-ms-lease-state": b.lease ? "leased" : "available", ...Object.fromEntries(Object.entries(b.meta).map(([k, v]) => [`x-ms-meta-${k}`, v])) };
|
|
131
|
+
return answer(200, init.method === "HEAD" ? "" : b.body, headers);
|
|
132
|
+
};
|
|
133
|
+
return { blobs, fetch };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The binary, faked: `state pull` prints the state `held` has for the
|
|
138
|
+
* root's directory, `state push -lock=false <file>` stores the file's state
|
|
139
|
+
* there. `pushFails` fails that many pushes first (Infinity: every one), and
|
|
140
|
+
* `onPush` sees each push before it is answered.
|
|
141
|
+
*/
|
|
142
|
+
function fakeBinary(o: { pushFails?: number; onPush?: (dir: string) => void } = {}) {
|
|
143
|
+
const held = new Map<string, StateFile>();
|
|
144
|
+
const calls: string[] = [];
|
|
145
|
+
const pushed: { dir: string; state: StateFile }[] = [];
|
|
146
|
+
let fails = o.pushFails ?? 0;
|
|
147
|
+
const exec: BinaryExec = async (_bin, args, dir) => {
|
|
148
|
+
calls.push(args.join(" "));
|
|
149
|
+
if (args[0] === "state" && args[1] === "pull") {
|
|
150
|
+
const s = held.get(dir);
|
|
151
|
+
return { code: 0, stdout: s ? JSON.stringify(s) : "", out: "" };
|
|
152
|
+
}
|
|
153
|
+
if (args[0] === "state" && args[1] === "push") {
|
|
154
|
+
expect(args).toContain("-lock=false");
|
|
155
|
+
o.onPush?.(dir);
|
|
156
|
+
if (fails > 0) {
|
|
157
|
+
fails--;
|
|
158
|
+
return { code: 1, stdout: "", out: "Error: Failed to persist state: RequestTimeout" };
|
|
159
|
+
}
|
|
160
|
+
const s = JSON.parse(readFileSync(args.at(-1)!, "utf-8")) as StateFile;
|
|
161
|
+
held.set(dir, s);
|
|
162
|
+
pushed.push({ dir, state: s });
|
|
163
|
+
return { code: 0, stdout: "", out: "" };
|
|
164
|
+
}
|
|
165
|
+
return { code: 1, stdout: "", out: `unexpected ${args.join(" ")}` };
|
|
166
|
+
};
|
|
167
|
+
return { exec, held, calls, pushed };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A root directory initialised with `type` and `config`, as init leaves it. */
|
|
171
|
+
function root(name: string, type: string | undefined, config: Record<string, unknown> = {}, env: NodeJS.ProcessEnv = {}): RootState {
|
|
172
|
+
const dir = join(tmp(), name);
|
|
173
|
+
mkdirSync(join(dir, ".terraform"), { recursive: true });
|
|
174
|
+
if (type) writeFileSync(join(dir, ".terraform/terraform.tfstate"), JSON.stringify({ version: 3, backend: { type, config } }));
|
|
175
|
+
return { root: name, dir, env };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const S3_ENV = { AWS_ACCESS_KEY_ID: "AK", AWS_SECRET_ACCESS_KEY: "SK", AWS_REGION: "us-east-1" };
|
|
179
|
+
const s3Root = (name: string, extra: Record<string, unknown> = {}) => root(name, "s3", { bucket: "acme", key: `${name}.tfstate`, use_lockfile: true, endpoints: { s3: "http://s3.test" }, ...extra }, S3_ENV);
|
|
180
|
+
const GCS_CONFIG = { bucket: "acme", prefix: "app", storage_custom_endpoint: "http://gcs.test/storage/v1/", access_token: "t" };
|
|
181
|
+
const AZ_CONFIG = { storage_account_name: "acme", container_name: "tfstate", key: "app.tfstate", access_key: Buffer.from("key").toString("base64") };
|
|
182
|
+
|
|
183
|
+
const noSleep = { sleep: async () => {} };
|
|
184
|
+
const runOpts = (fetch: StoreFetch | undefined, exec: BinaryExec, roots: RootState[], extra: Partial<ConstructorParameters<typeof RootStates>[0]> = {}) => ({
|
|
185
|
+
roots,
|
|
186
|
+
binary: "tofu",
|
|
187
|
+
exec,
|
|
188
|
+
...(fetch ? { fetch } : {}),
|
|
189
|
+
work: tmp(),
|
|
190
|
+
reportDir: tmp(),
|
|
191
|
+
runId: "run-7",
|
|
192
|
+
now: () => T(2),
|
|
193
|
+
who: "chant@ci",
|
|
194
|
+
...noSleep,
|
|
195
|
+
...extra,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("a lock is taken and released", () => {
|
|
199
|
+
it("on s3: the lock file use_lockfile takes, written only when absent and naming the operation and the run", async () => {
|
|
200
|
+
const s3 = fakeS3();
|
|
201
|
+
const bin = fakeBinary({ onPush: () => expect(s3.objects.has("acme/app.tfstate.tflock")).toBe(true) });
|
|
202
|
+
const states = new RootStates(runOpts(s3.fetch, bin.exec, [s3Root("app")]));
|
|
203
|
+
await states.take();
|
|
204
|
+
const info = JSON.parse(s3.objects.get("acme/app.tfstate.tflock")!) as Record<string, string>;
|
|
205
|
+
expect(info).toMatchObject({ Operation: COMPOSE_LOCK.operation, Info: expect.stringContaining("(run run-7)"), Who: "chant@ci", Created: T(2), Path: "acme/app.tfstate" });
|
|
206
|
+
expect(await states.write("app", state("L", 2))).toEqual({ written: true, attempts: 1 });
|
|
207
|
+
await states.release();
|
|
208
|
+
expect(s3.objects.has("acme/app.tfstate.tflock")).toBe(false);
|
|
209
|
+
expect(states.held()).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("on gcs: the lock object beside the state, taken only when there is none, and its generation the lock's ID", async () => {
|
|
213
|
+
const g = fakeGcs();
|
|
214
|
+
const bin = fakeBinary({ onPush: () => expect(g.objects.has("acme/app/default.tflock")).toBe(true) });
|
|
215
|
+
const r = root("app", "gcs", GCS_CONFIG);
|
|
216
|
+
const states = new RootStates(runOpts(g.fetch, bin.exec, [r]));
|
|
217
|
+
await states.take();
|
|
218
|
+
const store = stateStore(stateObject(r.dir, {}) as LockableState, r.dir, g.fetch);
|
|
219
|
+
expect(store.lockLocation).toBe("gs://acme/app/default.tflock");
|
|
220
|
+
expect(await store.heldLock()).toMatchObject({ id: g.objects.get("acme/app/default.tflock")!.generation });
|
|
221
|
+
await states.write("app", state("L", 2));
|
|
222
|
+
await states.release();
|
|
223
|
+
expect(g.objects.has("acme/app/default.tflock")).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("on azurerm: a lease on the state blob with the lock info in its metadata, the state written by the store under the lease", async () => {
|
|
227
|
+
const a = fakeAzure();
|
|
228
|
+
const bin = fakeBinary();
|
|
229
|
+
const states = new RootStates(runOpts(a.fetch, bin.exec, [root("app", "azurerm", { ...AZ_CONFIG, snapshot: true })]));
|
|
230
|
+
await states.take();
|
|
231
|
+
const blob = a.blobs.get("tfstate/app.tfstate")!;
|
|
232
|
+
expect(blob.lease).toBeTruthy();
|
|
233
|
+
expect(JSON.parse(Buffer.from(blob.meta.terraformlockid!, "base64").toString())).toMatchObject({ Operation: COMPOSE_LOCK.operation, ID: blob.lease });
|
|
234
|
+
expect(await states.write("app", state("L", 2))).toMatchObject({ written: true });
|
|
235
|
+
// The binary cannot write a leased blob: the store wrote it, snapshotting first as the backend does.
|
|
236
|
+
expect(bin.calls.filter((c) => c.startsWith("state push"))).toEqual([]);
|
|
237
|
+
expect(JSON.parse(a.blobs.get("tfstate/app.tfstate")!.body)).toMatchObject({ lineage: "L", serial: 2 });
|
|
238
|
+
expect(a.blobs.get("tfstate/app.tfstate")!.snapshots).toHaveLength(1);
|
|
239
|
+
await states.release();
|
|
240
|
+
expect(a.blobs.get("tfstate/app.tfstate")!.lease).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("on local: <path>.tflock beside the state file, created only when absent", async () => {
|
|
244
|
+
const r = root("app", "local", { path: "state/app.tfstate" });
|
|
245
|
+
const lockFile = join(r.dir, "state/app.tfstate.tflock");
|
|
246
|
+
const bin = fakeBinary({ onPush: () => expect(existsSync(lockFile)).toBe(true) });
|
|
247
|
+
const states = new RootStates(runOpts(undefined, bin.exec, [r]));
|
|
248
|
+
await states.take();
|
|
249
|
+
expect(JSON.parse(readFileSync(lockFile, "utf-8"))).toMatchObject({ Operation: COMPOSE_LOCK.operation });
|
|
250
|
+
// A second run finds it held.
|
|
251
|
+
await expect(new RootStates(runOpts(undefined, bin.exec, [r])).take()).rejects.toBeInstanceOf(StateLockedError);
|
|
252
|
+
await states.write("app", state("L", 2));
|
|
253
|
+
await states.release();
|
|
254
|
+
expect(existsSync(lockFile)).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("in the order given, and a pull reads each state through the binary", async () => {
|
|
258
|
+
const s3 = fakeS3();
|
|
259
|
+
const bin = fakeBinary();
|
|
260
|
+
const [a, b] = [s3Root("a"), s3Root("b")];
|
|
261
|
+
bin.held.set(a.dir, state("LA", 3));
|
|
262
|
+
const states = new RootStates(runOpts(s3.fetch, bin.exec, [b, a]));
|
|
263
|
+
await states.take();
|
|
264
|
+
expect(s3.seen.filter((s) => s.startsWith("PUT"))).toEqual(["PUT acme/b.tfstate.tflock", "PUT acme/a.tfstate.tflock"]);
|
|
265
|
+
expect(states.held()).toEqual(["b", "a"]);
|
|
266
|
+
expect(await states.pull("a")).toMatchObject({ lineage: "LA", serial: 3 });
|
|
267
|
+
expect(await states.pull("b")).toBeNull();
|
|
268
|
+
await states.release();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("a lock another run holds", () => {
|
|
273
|
+
it("stops the take before anything is pulled, releases what was taken, and names the holder", async () => {
|
|
274
|
+
const s3 = fakeS3();
|
|
275
|
+
s3.objects.set("acme/b.tfstate.tflock", lockText("alice@laptop", "OperationTypeApply", "manual apply"));
|
|
276
|
+
const bin = fakeBinary();
|
|
277
|
+
const states = new RootStates(runOpts(s3.fetch, bin.exec, [s3Root("a"), s3Root("b")]));
|
|
278
|
+
const err = await states.take().catch((e: unknown) => e);
|
|
279
|
+
expect(err).toBeInstanceOf(StateLockedError);
|
|
280
|
+
expect((err as StateLockedError).holder).toMatchObject({ who: "alice@laptop", operation: "OperationTypeApply", info: "manual apply", created: T(1) });
|
|
281
|
+
expect((err as Error).message).toBe(`b: its state is locked at s3://acme/b.tfstate.tflock by alice@laptop OperationTypeApply (manual apply) since ${T(1)}, so nothing was pulled`);
|
|
282
|
+
expect(s3.objects.has("acme/a.tfstate.tflock")).toBe(false);
|
|
283
|
+
expect(s3.objects.get("acme/b.tfstate.tflock")).toContain("alice@laptop");
|
|
284
|
+
expect(bin.calls).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("on gcs stops the take the same way", async () => {
|
|
288
|
+
const g = fakeGcs();
|
|
289
|
+
g.objects.set("acme/app/default.tflock", { generation: "7", body: lockText("bob@ci") });
|
|
290
|
+
const err = await new RootStates(runOpts(g.fetch, fakeBinary().exec, [root("app", "gcs", GCS_CONFIG)])).take().catch((e: unknown) => e);
|
|
291
|
+
expect(err).toMatchObject({ root: "app", lockLocation: "gs://acme/app/default.tflock", holder: { id: "7", who: "bob@ci" } });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("is not taken over when it is a composed run's lock with no kept state", async () => {
|
|
295
|
+
const s3 = fakeS3();
|
|
296
|
+
s3.objects.set("acme/app.tfstate.tflock", lockText("chant@other", COMPOSE_LOCK.operation));
|
|
297
|
+
await expect(new RootStates(runOpts(s3.fetch, fakeBinary().exec, [s3Root("app")])).take()).rejects.toBeInstanceOf(StateLockedError);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("a write with retries", () => {
|
|
302
|
+
it("that fails four times then succeeds is written once, the lock held throughout, after 5s, 15s, 30s and 60s", async () => {
|
|
303
|
+
const s3 = fakeS3();
|
|
304
|
+
const lockSeen: boolean[] = [];
|
|
305
|
+
const bin = fakeBinary({ pushFails: 4, onPush: () => lockSeen.push(s3.objects.has("acme/app.tfstate.tflock")) });
|
|
306
|
+
const slept: number[] = [];
|
|
307
|
+
const r = s3Root("app");
|
|
308
|
+
const states = new RootStates(runOpts(s3.fetch, bin.exec, [r], { sleep: async (ms: number) => void slept.push(ms) }));
|
|
309
|
+
await states.take();
|
|
310
|
+
expect(await states.write("app", state("L", 9))).toEqual({ written: true, attempts: 5 });
|
|
311
|
+
expect(lockSeen).toEqual([true, true, true, true, true]);
|
|
312
|
+
expect(slept).toEqual([5_000, 15_000, 30_000, 60_000]);
|
|
313
|
+
expect(bin.pushed).toEqual([{ dir: r.dir, state: state("L", 9) }]);
|
|
314
|
+
expect(s3.objects.has("acme/app.tfstate.chant-unwritten")).toBe(false);
|
|
315
|
+
await states.release();
|
|
316
|
+
expect(s3.objects.has("acme/app.tfstate.tflock")).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("waits 5s, 15s, 30s, 60s and 120s by default: inside the 5-minute lock timeout", () => {
|
|
320
|
+
expect(DEFAULT_RETRY_DELAYS).toEqual([5_000, 15_000, 30_000, 60_000, 120_000]);
|
|
321
|
+
expect(DEFAULT_RETRY_DELAYS.reduce((a, b) => a + b, 0)).toBeLessThan(300_000);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("the unwritten keep", () => {
|
|
326
|
+
it("leaves <key>.chant-unwritten beside the state and the lock held when every retry fails; a later take writes it first and only then returns", async () => {
|
|
327
|
+
const s3 = fakeS3();
|
|
328
|
+
const r = s3Root("app");
|
|
329
|
+
const failing = fakeBinary({ pushFails: Infinity });
|
|
330
|
+
const first = new RootStates(runOpts(s3.fetch, failing.exec, [r]));
|
|
331
|
+
await first.take();
|
|
332
|
+
const out = await first.write("app", state("L", 4));
|
|
333
|
+
expect(out).toEqual({ written: false, attempts: 6, error: expect.stringContaining("state push failed"), kept: `s3://acme/app.tfstate${UNWRITTEN_SUFFIX}`, lockHeld: true });
|
|
334
|
+
expect(JSON.parse(s3.objects.get("acme/app.tfstate.chant-unwritten")!)).toEqual(state("L", 4));
|
|
335
|
+
await first.release();
|
|
336
|
+
expect(s3.objects.get("acme/app.tfstate.tflock")).toContain("(run run-7)");
|
|
337
|
+
|
|
338
|
+
const ok = fakeBinary();
|
|
339
|
+
const later = new RootStates(runOpts(s3.fetch, ok.exec, [r], { runId: "run-8" }));
|
|
340
|
+
await later.take();
|
|
341
|
+
// By the time take returns, the kept state is written and gone, and the lock is this run's.
|
|
342
|
+
expect(ok.pushed).toEqual([{ dir: r.dir, state: state("L", 4) }]);
|
|
343
|
+
expect(s3.objects.has("acme/app.tfstate.chant-unwritten")).toBe(false);
|
|
344
|
+
expect(later.held()).toEqual(["app"]);
|
|
345
|
+
await later.release();
|
|
346
|
+
expect(s3.objects.has("acme/app.tfstate.tflock")).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("on azurerm takes the lease over by its ID and writes the kept state under it", async () => {
|
|
350
|
+
const a = fakeAzure();
|
|
351
|
+
const r = root("app", "azurerm", AZ_CONFIG);
|
|
352
|
+
const failing = new RootStates(runOpts(a.fetch, fakeBinary().exec, [r]));
|
|
353
|
+
await failing.take();
|
|
354
|
+
a.blobs.set("tfstate/app.tfstate", { ...a.blobs.get("tfstate/app.tfstate")!, lease: "someone-else" });
|
|
355
|
+
// The blob's lease moved under the run: every write fails, and the state is kept beside it.
|
|
356
|
+
const out = await failing.write("app", state("L", 5));
|
|
357
|
+
expect(out).toMatchObject({ written: false, kept: `az://acme/tfstate/app.tfstate${UNWRITTEN_SUFFIX}` });
|
|
358
|
+
const lease = a.blobs.get("tfstate/app.tfstate")!;
|
|
359
|
+
lease.lease = "kept-lease";
|
|
360
|
+
lease.meta = { terraformlockid: Buffer.from(JSON.stringify({ ID: "kept-lease", Operation: COMPOSE_LOCK.operation })).toString("base64") };
|
|
361
|
+
const later = new RootStates(runOpts(a.fetch, fakeBinary().exec, [r]));
|
|
362
|
+
await later.take();
|
|
363
|
+
expect(JSON.parse(a.blobs.get("tfstate/app.tfstate")!.body)).toMatchObject({ lineage: "L", serial: 5 });
|
|
364
|
+
expect(a.blobs.has(`tfstate/app.tfstate${UNWRITTEN_SUFFIX}`)).toBe(false);
|
|
365
|
+
await later.release();
|
|
366
|
+
expect(a.blobs.get("tfstate/app.tfstate")!.lease).toBeUndefined();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("goes under the job's report directory when the backend cannot take it either", async () => {
|
|
370
|
+
const s3 = fakeS3();
|
|
371
|
+
s3.failPuts(/chant-unwritten$/);
|
|
372
|
+
const states = new RootStates(runOpts(s3.fetch, fakeBinary({ pushFails: Infinity }).exec, [s3Root("envs/app")]));
|
|
373
|
+
await states.take();
|
|
374
|
+
const out = await states.write("envs/app", state("L", 4));
|
|
375
|
+
expect(out).toMatchObject({ written: false, lockHeld: true });
|
|
376
|
+
const kept = (out as { kept: string }).kept;
|
|
377
|
+
expect(kept.endsWith(join("unwritten", "envs_app.tfstate"))).toBe(true);
|
|
378
|
+
expect(JSON.parse(readFileSync(kept, "utf-8"))).toEqual(state("L", 4));
|
|
379
|
+
await states.release();
|
|
380
|
+
expect(s3.objects.has("acme/envs/app.tfstate.tflock")).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("reads a state terragucci kept as .terragucci-unwritten under its lock, writes it, removes it, and never writes that name", async () => {
|
|
384
|
+
const s3 = fakeS3();
|
|
385
|
+
s3.objects.set("acme/app.tfstate.tflock", lockText("terragucci@ci", "terragucci compose"));
|
|
386
|
+
s3.objects.set(`acme/app.tfstate${LEGACY_UNWRITTEN_SUFFIX}`, JSON.stringify(state("L", 6)));
|
|
387
|
+
const bin = fakeBinary();
|
|
388
|
+
const r = s3Root("app");
|
|
389
|
+
const states = new RootStates(runOpts(s3.fetch, bin.exec, [r]));
|
|
390
|
+
await states.take();
|
|
391
|
+
expect(bin.pushed).toEqual([{ dir: r.dir, state: state("L", 6) }]);
|
|
392
|
+
expect(s3.objects.has(`acme/app.tfstate${LEGACY_UNWRITTEN_SUFFIX}`)).toBe(false);
|
|
393
|
+
expect(s3.seen.filter((s) => s.startsWith("PUT") && s.includes("terragucci"))).toEqual([]);
|
|
394
|
+
await states.release();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("keeps the lock held when the kept state still cannot be written, and releases the others", async () => {
|
|
398
|
+
const s3 = fakeS3();
|
|
399
|
+
s3.objects.set("acme/b.tfstate.tflock", lockText("chant@ci", COMPOSE_LOCK.operation));
|
|
400
|
+
s3.objects.set("acme/b.tfstate.chant-unwritten", JSON.stringify(state("L", 2)));
|
|
401
|
+
const states = new RootStates(runOpts(s3.fetch, fakeBinary({ pushFails: Infinity }).exec, [s3Root("a"), s3Root("b")]));
|
|
402
|
+
await expect(states.take()).rejects.toThrow("b: the state an earlier run kept for it could not be written");
|
|
403
|
+
expect(s3.objects.has("acme/a.tfstate.tflock")).toBe(false);
|
|
404
|
+
expect(s3.objects.has("acme/b.tfstate.tflock")).toBe(true);
|
|
405
|
+
expect(s3.objects.has("acme/b.tfstate.chant-unwritten")).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe("a root chant does not lock", () => {
|
|
410
|
+
it("is named with its reason, and the take takes no lock at all", async () => {
|
|
411
|
+
const s3 = fakeS3();
|
|
412
|
+
const roots = [
|
|
413
|
+
s3Root("locked"),
|
|
414
|
+
s3Root("nolockfile", { use_lockfile: false }),
|
|
415
|
+
root("hcp", "cloud", { organization: "acme" }),
|
|
416
|
+
root("pg", "pg", { conn_str: "postgres://db/states" }),
|
|
417
|
+
];
|
|
418
|
+
const bin = fakeBinary();
|
|
419
|
+
const states = new RootStates(runOpts(s3.fetch, bin.exec, roots));
|
|
420
|
+
expect(states.refusals().map((r) => [r.root, r.refused])).toEqual([
|
|
421
|
+
["nolockfile", "no-lockfile"],
|
|
422
|
+
["hcp", "cloud"],
|
|
423
|
+
["pg", "backend"],
|
|
424
|
+
]);
|
|
425
|
+
const err = await states.take().catch((e: unknown) => e);
|
|
426
|
+
expect(err).toBeInstanceOf(StateRefusedError);
|
|
427
|
+
expect((err as Error).message).toContain("nolockfile: the s3 backend takes no lock file");
|
|
428
|
+
expect(s3.seen).toEqual([]);
|
|
429
|
+
expect(bin.calls).toEqual([]);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("the S3 lock request", () => {
|
|
434
|
+
it("is the state's lock file, taken only when absent and never without the condition", async () => {
|
|
435
|
+
expect(JSON.parse(lockInfo("b/k", T(1), COMPOSE_LOCK, "me"))).toMatchObject({ Operation: COMPOSE_LOCK.operation, Who: "me", Created: T(1), Path: "b/k" });
|
|
436
|
+
const reply = (status: number): StoreFetch => async (_u, init) => {
|
|
437
|
+
expect(init.headers["if-none-match"]).toBe("*");
|
|
438
|
+
return { ok: status < 300, status, text: async () => "" };
|
|
439
|
+
};
|
|
440
|
+
const client = (f: StoreFetch) => new S3Client({ bucket: "b", endpoint: "http://minio:9000", region: "us-east-1", accessKeyId: "AK", secretAccessKey: "SK" }, f);
|
|
441
|
+
expect(await client(reply(200)).putIfAbsent("k.tflock", "{}", "application/json")).toBe(true);
|
|
442
|
+
expect(await client(reply(412)).putIfAbsent("k.tflock", "{}", "application/json")).toBe(false);
|
|
443
|
+
await expect(client(reply(501)).putIfAbsent("k.tflock", "{}", "application/json")).rejects.toThrow("does not take a conditional write");
|
|
444
|
+
const seen: string[] = [];
|
|
445
|
+
await client(async (u, init) => {
|
|
446
|
+
seen.push(`${init.method} ${u}`);
|
|
447
|
+
return { ok: false, status: 404, text: async () => "" };
|
|
448
|
+
}).remove("k.tflock");
|
|
449
|
+
expect(seen).toEqual(["DELETE http://minio:9000/b/k.tflock"]);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("a pull", () => {
|
|
454
|
+
it("is the state the binary prints, null for none or an empty state with no lineage, and an error naming the root when it fails", async () => {
|
|
455
|
+
const run = (code: number, stdout: string, out = ""): BinaryExec => async () => ({ code, stdout, out });
|
|
456
|
+
expect(await pullState(run(0, JSON.stringify(state("L", 1))), "tofu", "app", "/r", {})).toMatchObject({ lineage: "L", resources: [] });
|
|
457
|
+
expect(await pullState(run(0, ""), "tofu", "app", "/r", {})).toBeNull();
|
|
458
|
+
expect(await pullState(run(0, JSON.stringify({ version: 4, serial: 0, lineage: "" })), "tofu", "app", "/r", {})).toBeNull();
|
|
459
|
+
await expect(pullState(run(1, "", "Error: AccessDenied"), "tofu", "app", "/r", {})).rejects.toThrow("state pull in app failed: Error: AccessDenied");
|
|
460
|
+
await expect(pullState(run(0, "not json"), "tofu", "app", "/r", {})).rejects.toThrow("not a state");
|
|
461
|
+
});
|
|
462
|
+
});
|