@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
package/src/state/gcs.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the state stores share: the fetch they call, their errors, and how a
|
|
3
|
+
* key goes into a URL. Lifted from terragucci's `report/object-store.ts`
|
|
4
|
+
* (c493e0e), cut to what a state's lock and write need.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type StoreFetch = (url: string, init: { method: string; headers: Record<string, string>; body?: string | Uint8Array }) => Promise<{
|
|
8
|
+
ok: boolean;
|
|
9
|
+
status: number;
|
|
10
|
+
text(): Promise<string>;
|
|
11
|
+
/** A store that sends no headers (or a test's fake) gets no conditional writes. */
|
|
12
|
+
headers?: { get(name: string): string | null };
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
export class StoreError extends Error {}
|
|
16
|
+
|
|
17
|
+
/** A conditional write that lost: the object changed (412) or another write to it was in flight (409). */
|
|
18
|
+
export class StoreConflict extends StoreError {}
|
|
19
|
+
|
|
20
|
+
/** A condition on a write: the version the object must still have, or `*` for "there is no object yet". */
|
|
21
|
+
export type StoreCondition = { ifMatch: string } | { ifNoneMatch: "*" };
|
|
22
|
+
|
|
23
|
+
/** A key as a URL path: each segment percent-encoded the strict way (RFC 3986), the slashes kept. */
|
|
24
|
+
export const encodePath = (key: string): string => key.split("/").map(encodeStrict).join("/");
|
|
25
|
+
|
|
26
|
+
export const encodeStrict = (v: string): string => encodeURIComponent(v).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
27
|
+
|
|
28
|
+
/** The first value of an XML element, unescaped. Enough for the small answers these stores send. */
|
|
29
|
+
export const xmlText = (xml: string, tag: string): string | undefined => {
|
|
30
|
+
const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml);
|
|
31
|
+
return m?.[1]!.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&").trim();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The global fetch, as a StoreFetch. */
|
|
35
|
+
export const defaultFetch: StoreFetch = (url, init) => fetch(url, init as RequestInit);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An s3 state's lock against floci (#4437): a real lock object taken, a
|
|
3
|
+
* state written under it, and the lock released. Set CHANT_FLOCI_ENDPOINT to
|
|
4
|
+
* a running floci, for example `docker run -d -p 4611:4566 floci/floci` and
|
|
5
|
+
* `CHANT_FLOCI_ENDPOINT=http://localhost:4611`. Skipped without it.
|
|
6
|
+
*
|
|
7
|
+
* The binary is faked: its `state push` puts the state object in the bucket
|
|
8
|
+
* the way the s3 backend does, after checking the lock object is there.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
15
|
+
import type { BinaryExec, StateFile } from "./exec";
|
|
16
|
+
import { RootStates, UNWRITTEN_SUFFIX } from "./root-states";
|
|
17
|
+
import { S3Client, sign } from "./s3";
|
|
18
|
+
|
|
19
|
+
const endpoint = process.env.CHANT_FLOCI_ENDPOINT?.replace(/\/+$/, "");
|
|
20
|
+
|
|
21
|
+
describe.skipIf(!endpoint)("an s3 state's lock against floci (#4437)", () => {
|
|
22
|
+
const bucket = `chant-state-${process.pid}-${Date.now()}`;
|
|
23
|
+
const creds = { region: "us-east-1", accessKeyId: "test", secretAccessKey: "test" };
|
|
24
|
+
const env = { AWS_ACCESS_KEY_ID: "test", AWS_SECRET_ACCESS_KEY: "test", AWS_REGION: "us-east-1" };
|
|
25
|
+
const client = new S3Client({ bucket, endpoint: endpoint!, ...creds });
|
|
26
|
+
const state: StateFile = { version: 4, terraform_version: "1.10.0", serial: 3, lineage: "floci-lineage", outputs: {}, resources: [] };
|
|
27
|
+
let dir: string;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const url = `${endpoint}/${bucket}`;
|
|
31
|
+
const res = await fetch(url, { method: "PUT", headers: sign(creds, "PUT", url, {}, createHash("sha256").update("").digest("hex"), new Date()) });
|
|
32
|
+
expect(res.ok).toBe(true);
|
|
33
|
+
dir = join(mkdtempSync(join(tmpdir(), "chant-floci-")), "app");
|
|
34
|
+
mkdirSync(join(dir, ".terraform"), { recursive: true });
|
|
35
|
+
writeFileSync(join(dir, ".terraform/terraform.tfstate"), JSON.stringify({ version: 3, backend: { type: "s3", config: { bucket, key: "app.tfstate", region: "us-east-1", use_lockfile: true, endpoints: { s3: endpoint } } } }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
for (const k of ["app.tfstate", "app.tfstate.tflock", `app.tfstate${UNWRITTEN_SUFFIX}`]) await client.remove(k).catch(() => {});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("takes the lock object, writes the state under it, and releases it", async () => {
|
|
43
|
+
const lockAtPush: (string | undefined)[] = [];
|
|
44
|
+
const exec: BinaryExec = async (_bin, args) => {
|
|
45
|
+
if (args[0] === "state" && args[1] === "push") {
|
|
46
|
+
lockAtPush.push(await client.get("app.tfstate.tflock"));
|
|
47
|
+
await client.put("app.tfstate", readFileSync(args.at(-1)!, "utf-8"), "application/json");
|
|
48
|
+
return { code: 0, stdout: "", out: "" };
|
|
49
|
+
}
|
|
50
|
+
if (args[0] === "state" && args[1] === "pull") return { code: 0, stdout: (await client.get("app.tfstate")) ?? "", out: "" };
|
|
51
|
+
return { code: 1, stdout: "", out: "unexpected" };
|
|
52
|
+
};
|
|
53
|
+
const states = new RootStates({ roots: [{ root: "app", dir, env }], binary: "tofu", exec, work: mkdtempSync(join(tmpdir(), "chant-floci-work-")), reportDir: mkdtempSync(join(tmpdir(), "chant-floci-report-")), runId: "floci-1" });
|
|
54
|
+
await states.take();
|
|
55
|
+
const lock = await client.get("app.tfstate.tflock");
|
|
56
|
+
expect(JSON.parse(lock!)).toMatchObject({ Operation: "chant terraform.compose", Info: expect.stringContaining("(run floci-1)"), Path: `${bucket}/app.tfstate` });
|
|
57
|
+
// A second run finds the real lock object held.
|
|
58
|
+
const other = new RootStates({ roots: [{ root: "app", dir, env }], binary: "tofu", exec, work: mkdtempSync(join(tmpdir(), "chant-floci-work-")), reportDir: mkdtempSync(join(tmpdir(), "chant-floci-report-")) });
|
|
59
|
+
await expect(other.take()).rejects.toThrow("its state is locked at s3://");
|
|
60
|
+
expect(await states.write("app", state)).toEqual({ written: true, attempts: 1 });
|
|
61
|
+
expect(lockAtPush).toEqual([lock]);
|
|
62
|
+
expect(await states.pull("app")).toMatchObject({ lineage: "floci-lineage", serial: 3 });
|
|
63
|
+
await states.release();
|
|
64
|
+
expect(await client.get("app.tfstate.tflock")).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|