@intentius/chant-lexicon-terraform 0.124.52 → 0.124.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.map +1 -1
- package/dist/commands.js +4 -3
- 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 +15 -6
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +28 -55
- 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 +4 -3
- 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/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 +82 -1
- package/src/report.ts +31 -54
- 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/compose.ts
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which roots a change composes, in what order, and why the others fall back
|
|
3
|
+
* (chant#4438, epic #4419). Pure functions over a project's config, its
|
|
4
|
+
* components and its roots' files: nothing here runs a binary.
|
|
5
|
+
*
|
|
6
|
+
* A composed run plans the `terraform-apply` components a change reaches,
|
|
7
|
+
* and the ones they read, as one plan of a composed root of `module` calls,
|
|
8
|
+
* and applies it as one apply, so a value one root creates reaches the roots
|
|
9
|
+
* that read it inside that apply. Writing the composed root is #4441;
|
|
10
|
+
* splitting its state is #4442; running it is #4444.
|
|
11
|
+
*
|
|
12
|
+
* Lifted from terragucci's `packages/terragucci/src/compose.ts` at c493e0e
|
|
13
|
+
* (`Composition`, `composeSet`, `readOrder`, `moduleNames`, `compositionFor`,
|
|
14
|
+
* `fallbackOf`, `fallbackLine`), with chant's inputs in place of terragucci's:
|
|
15
|
+
*
|
|
16
|
+
* - The roots are `terraform.roots`, each deployed by a component whose step
|
|
17
|
+
* is `{ kind: "terraform-apply", root }`. A member is a component.
|
|
18
|
+
* - A read between members is a `stackOutput()` in the step's `vars`, a
|
|
19
|
+
* `terraform_remote_state` block whose address is another member's
|
|
20
|
+
* backend, or a `terraform_estate_outputs` block naming another member's
|
|
21
|
+
* estate.
|
|
22
|
+
* - `waves.after` is the component's `dependsOn`; `oidc.roles` is the apply
|
|
23
|
+
* identity the caller resolves per member (`ci.identity.cloud`, #4296);
|
|
24
|
+
* `TF_CLI_ARGS*` is read from the environment as before.
|
|
25
|
+
*
|
|
26
|
+
* Unlike terragucci, part of a change can compose (#4419 ruling 1): each
|
|
27
|
+
* member that cannot is left out with its own reason, and the rest compose
|
|
28
|
+
* when two or more remain. A component of another kind, or a root left out,
|
|
29
|
+
* on a dependency path between two members splits them (ruling 2), and only
|
|
30
|
+
* a component whose deploy is that one step joins (ruling 3).
|
|
31
|
+
*
|
|
32
|
+
* Off unless `terraform.compose.enabled` is set: {@link compositionIfEnabled}
|
|
33
|
+
* is the entry point, and calls nothing when it is not.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { isAbsolute, resolve } from "node:path";
|
|
37
|
+
import type { PipelineCloudIdentity } from "@intentius/chant/ci-identity";
|
|
38
|
+
import type { GroupFallback } from "@intentius/chant/components/capability";
|
|
39
|
+
import { wiringReads, type DriverComponent, type DriverPhase, type DriverStep } from "@intentius/chant/components/driver";
|
|
40
|
+
import type { TerraformConfig, TerraformRootConfig } from "./config";
|
|
41
|
+
import { lockRefusal, readBackend } from "./state/backend";
|
|
42
|
+
import {
|
|
43
|
+
estateOutputReads,
|
|
44
|
+
importLiteral,
|
|
45
|
+
jsonImportRefused,
|
|
46
|
+
liveEstate,
|
|
47
|
+
localModules,
|
|
48
|
+
lockedProviders,
|
|
49
|
+
relPath,
|
|
50
|
+
rootState,
|
|
51
|
+
rootTexts,
|
|
52
|
+
sameState,
|
|
53
|
+
blockBody,
|
|
54
|
+
type RootState,
|
|
55
|
+
} from "./compose/hcl";
|
|
56
|
+
|
|
57
|
+
export { blankComments, lockedProviders, blockBody, hclAttr, rootState, liveEstate, estateOutputReads } from "./compose/hcl";
|
|
58
|
+
|
|
59
|
+
/** `terraform.compose.max` when unset. */
|
|
60
|
+
export const DEFAULT_COMPOSE_MAX = 10;
|
|
61
|
+
|
|
62
|
+
/** The step kind a member's deploy is. */
|
|
63
|
+
export const COMPOSE_STEP_KIND = "terraform-apply";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why a member, or the whole change, applies in waves. `steps` and `split`
|
|
67
|
+
* are the kinds core's own group pass uses for the same reasons (ws-107);
|
|
68
|
+
* `off` is what the capability answers core's `groupFallback` with when
|
|
69
|
+
* `terraform.compose` is not enabled. `plan-failed` is kept for a reader of
|
|
70
|
+
* terragucci's reports: under core, a group plan that throws makes every
|
|
71
|
+
* member plan-failed instead (ws-107).
|
|
72
|
+
*/
|
|
73
|
+
export type ComposeFallbackKind =
|
|
74
|
+
| "off"
|
|
75
|
+
| "too-many-roots"
|
|
76
|
+
| "one-root"
|
|
77
|
+
| "binary"
|
|
78
|
+
| "identity"
|
|
79
|
+
| "workspace"
|
|
80
|
+
| "backend"
|
|
81
|
+
| "provider-lock"
|
|
82
|
+
| "cli-args"
|
|
83
|
+
| "read"
|
|
84
|
+
| "order-only"
|
|
85
|
+
| "import"
|
|
86
|
+
| "path"
|
|
87
|
+
| "plan-failed"
|
|
88
|
+
| "targets"
|
|
89
|
+
| "steps"
|
|
90
|
+
| "same-root"
|
|
91
|
+
| "split";
|
|
92
|
+
|
|
93
|
+
/** A fallback, in the shape of core's `GroupFallback` (ws-107) with the member it leaves out. */
|
|
94
|
+
export interface ComposeFallback extends GroupFallback {
|
|
95
|
+
kind: ComposeFallbackKind;
|
|
96
|
+
/** The member left out. Absent when the reason is the whole change's. */
|
|
97
|
+
member?: string;
|
|
98
|
+
/** One line, no trailing period. */
|
|
99
|
+
reason: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One read of a member by another member. */
|
|
103
|
+
export type ComposedRead =
|
|
104
|
+
/** A `stackOutput()` in the step's `vars`: `variable` is the top-level var it is in, `output` the upstream output. */
|
|
105
|
+
| { via: "vars"; upstream: string; variable: string; output: string }
|
|
106
|
+
/** A `terraform_remote_state` block, by label. */
|
|
107
|
+
| { via: "remote-state"; upstream: string; name: string }
|
|
108
|
+
/** A choudoufu `terraform_estate_outputs` block, by label. */
|
|
109
|
+
| { via: "estate-outputs"; upstream: string; name: string };
|
|
110
|
+
|
|
111
|
+
/** What `terraform.compose` comes to for one change. */
|
|
112
|
+
export interface Composition {
|
|
113
|
+
/** The `terraform-apply` components the change reaches plus those they read, followed upstream, sorted. */
|
|
114
|
+
candidates: string[];
|
|
115
|
+
/** The candidates that compose, sorted. Empty when {@link fallback} is set. */
|
|
116
|
+
members: string[];
|
|
117
|
+
/** The members, each after every member it reads. */
|
|
118
|
+
order: string[];
|
|
119
|
+
/** `terraform.compose.max`. */
|
|
120
|
+
max: number;
|
|
121
|
+
/** Each candidate's key in `terraform.roots`. */
|
|
122
|
+
roots: Map<string, string>;
|
|
123
|
+
/** Each candidate's root directory, absolute. */
|
|
124
|
+
dirs: Map<string, string>;
|
|
125
|
+
/** `terraform.binary`, which says which of each root's files are read (chant#4457). Unset is `terraform`. */
|
|
126
|
+
binary?: string;
|
|
127
|
+
/** Each member's module name in the composed root: a valid HCL identifier, stable across runs. */
|
|
128
|
+
modules: Map<string, string>;
|
|
129
|
+
/** Each member's reads of another member, sorted. */
|
|
130
|
+
reads: Map<string, ComposedRead[]>;
|
|
131
|
+
/** Every member runs choudoufu against a `live` estate: one choudoufu run over estates, no state to pull, split or write back. */
|
|
132
|
+
estate?: boolean;
|
|
133
|
+
/** Each candidate left out and why, in member order. Those plan wave by wave as without the setting. */
|
|
134
|
+
fallbacks: ComposeFallback[];
|
|
135
|
+
/** Why nothing composes. */
|
|
136
|
+
fallback?: ComposeFallback;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The binary a member runs, before anything is installed. */
|
|
140
|
+
export interface ExpectedBinary {
|
|
141
|
+
name: string;
|
|
142
|
+
version?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CompositionInput {
|
|
146
|
+
/** The directory `chant.config.*` was loaded from; a root's `dir` resolves against it. */
|
|
147
|
+
projectRoot: string;
|
|
148
|
+
/** The config's `terraform` namespace. */
|
|
149
|
+
terraform: TerraformConfig;
|
|
150
|
+
/** Every component of the project, of every kind. */
|
|
151
|
+
components: readonly DriverComponent[];
|
|
152
|
+
/** The components the change reaches; undefined when every component plans. */
|
|
153
|
+
selected?: ReadonlySet<string>;
|
|
154
|
+
/** The environment the run has: `TF_CLI_ARGS*` is read from it. */
|
|
155
|
+
env: NodeJS.ProcessEnv;
|
|
156
|
+
/**
|
|
157
|
+
* The apply identity a member runs under, as one comparable string
|
|
158
|
+
* ({@link identityKey} of `resolveTargetCloudIdentity` for its root, #4296).
|
|
159
|
+
* Members with different strings do not compose. Unset: one identity.
|
|
160
|
+
*/
|
|
161
|
+
identity?: (member: string) => string | undefined;
|
|
162
|
+
/** The targets each member is given (`--target`, #4300). */
|
|
163
|
+
targets?: Readonly<Record<string, readonly string[]>>;
|
|
164
|
+
/** Each member's binary. Default: `terraform.binary`, at no stated version. */
|
|
165
|
+
binary?: (member: string) => ExpectedBinary;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A member's identity as {@link CompositionInput.identity} compares it: its apply trade, or `""` for none. */
|
|
169
|
+
export function identityKey(identity: PipelineCloudIdentity | undefined): string {
|
|
170
|
+
return identity ? JSON.stringify(identity.apply) : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
|
|
174
|
+
const list = (xs: readonly string[]): string => xs.map((x) => `\`${x}\``).join(", ");
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The module name of each root: its name with every character a name cannot
|
|
178
|
+
* hold as `_` (`envs/prod-app` is `envs_prod_app`), `root` for an empty one,
|
|
179
|
+
* `r_` before a leading digit, and `_2`, `_3` on a clash, given in name order.
|
|
180
|
+
*/
|
|
181
|
+
export function moduleNames(roots: readonly string[]): Map<string, string> {
|
|
182
|
+
const out = new Map<string, string>();
|
|
183
|
+
const taken = new Set<string>();
|
|
184
|
+
for (const r of [...new Set(roots)].sort()) {
|
|
185
|
+
let base = r === "." || r === "" ? "root" : r.replace(/[^A-Za-z0-9_]/g, "_").replace(/^_+|_+$/g, "") || "root";
|
|
186
|
+
if (/^[0-9]/.test(base)) base = `r_${base}`;
|
|
187
|
+
let name = base;
|
|
188
|
+
for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
|
|
189
|
+
taken.add(name);
|
|
190
|
+
out.set(r, name);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The members of `set`, each after every member of the set it reads; members that read none of each other in name order. */
|
|
196
|
+
export function readOrder(set: readonly string[], deps: ReadonlyMap<string, ReadonlySet<string>>): string[] {
|
|
197
|
+
const left = new Set(set);
|
|
198
|
+
const out: string[] = [];
|
|
199
|
+
while (left.size > 0) {
|
|
200
|
+
const ready = [...left].filter((r) => [...(deps.get(r) ?? [])].every((u) => !left.has(u))).sort();
|
|
201
|
+
// A cycle the waves would refuse: take the rest in name order rather than stop.
|
|
202
|
+
const take = ready.length > 0 ? ready : [...left].sort();
|
|
203
|
+
for (const r of take) {
|
|
204
|
+
out.push(r);
|
|
205
|
+
left.delete(r);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The members a composed run covers: those of `all` the change reaches and every one of `all` they read, followed upstream. */
|
|
212
|
+
export function composeSet(all: readonly string[], selected: ReadonlySet<string> | undefined, deps: ReadonlyMap<string, ReadonlySet<string>>): string[] {
|
|
213
|
+
const set = new Set(selected ? [...selected].filter((r) => all.includes(r)) : all);
|
|
214
|
+
const queue = [...set];
|
|
215
|
+
while (queue.length > 0) {
|
|
216
|
+
const r = queue.pop()!;
|
|
217
|
+
for (const u of deps.get(r) ?? []) {
|
|
218
|
+
if (!set.has(u) && all.includes(u)) {
|
|
219
|
+
set.add(u);
|
|
220
|
+
queue.push(u);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [...set].sort();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── components ────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/** Every leaf step of a deploy, gates included, in order. */
|
|
230
|
+
function leafSteps(phases: readonly DriverPhase[]): DriverStep[] {
|
|
231
|
+
const out: DriverStep[] = [];
|
|
232
|
+
const walk = (steps: DriverPhase["steps"]): void => {
|
|
233
|
+
for (const s of steps) {
|
|
234
|
+
if (typeof (s as DriverPhase).phase === "string" && Array.isArray((s as DriverPhase).steps)) walk((s as DriverPhase).steps);
|
|
235
|
+
else out.push(s as DriverStep);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
for (const p of phases) walk(p.steps);
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A component that deploys a root: its first `terraform-apply` step and how many steps its deploy has. */
|
|
243
|
+
interface RootComponent {
|
|
244
|
+
name: string;
|
|
245
|
+
step: DriverStep;
|
|
246
|
+
steps: number;
|
|
247
|
+
root?: string;
|
|
248
|
+
config?: TerraformRootConfig;
|
|
249
|
+
/** Absolute. */
|
|
250
|
+
dir?: string;
|
|
251
|
+
/** Relative to the project. */
|
|
252
|
+
rel?: string;
|
|
253
|
+
state?: RootState;
|
|
254
|
+
estate?: string;
|
|
255
|
+
live: boolean;
|
|
256
|
+
/** `terraform.binary`, which says which of the root's files it reads (chant#4457). */
|
|
257
|
+
binary?: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A component's kind, for a reason: its first step's kind. */
|
|
261
|
+
const kindOf = (c: DriverComponent): string => leafSteps(c.deploy)[0]?.kind ?? "empty";
|
|
262
|
+
|
|
263
|
+
/** The top-level vars of a step and the members' outputs each reads through `stackOutput()` or `@<component>.publish.*`. */
|
|
264
|
+
function varsReads(step: DriverStep): { variable: string; upstream: string; output: string }[] {
|
|
265
|
+
const vars = step.vars && typeof step.vars === "object" && !Array.isArray(step.vars) ? (step.vars as Record<string, unknown>) : {};
|
|
266
|
+
const out: { variable: string; upstream: string; output: string }[] = [];
|
|
267
|
+
for (const [variable, value] of Object.entries(vars)) {
|
|
268
|
+
for (const r of wiringReads({ [variable]: value }, {})) out.push({ variable, upstream: r.source, output: r.ref.slice(r.source.length + 1) });
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Every component another reads or depends on, from any step's wiring and its `dependsOn`, plus the HCL reads between roots. */
|
|
274
|
+
function dependencyGraph(components: readonly DriverComponent[], hcl: ReadonlyMap<string, readonly ComposedRead[]>): Map<string, Set<string>> {
|
|
275
|
+
const names = new Set(components.map((c) => c.name));
|
|
276
|
+
const out = new Map<string, Set<string>>();
|
|
277
|
+
for (const c of components) {
|
|
278
|
+
const deps = new Set((c.dependsOn ?? []).filter((d) => names.has(d)));
|
|
279
|
+
for (const step of leafSteps(c.deploy)) {
|
|
280
|
+
for (const r of wiringReads(step as Record<string, unknown>, {})) if (names.has(r.source) && r.source !== c.name) deps.add(r.source);
|
|
281
|
+
}
|
|
282
|
+
for (const r of hcl.get(c.name) ?? []) deps.add(r.upstream);
|
|
283
|
+
out.set(c.name, deps);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Each root component's HCL reads of another root component. */
|
|
289
|
+
function hclReads(rcs: readonly RootComponent[]): Map<string, ComposedRead[]> {
|
|
290
|
+
const out = new Map<string, ComposedRead[]>();
|
|
291
|
+
for (const r of rcs) {
|
|
292
|
+
const reads: ComposedRead[] = [];
|
|
293
|
+
if (r.state && r.dir) {
|
|
294
|
+
for (const read of r.state.reads) {
|
|
295
|
+
for (const o of rcs) if (o.name !== r.name && o.state?.own && sameState(o.state.own, read)) reads.push({ via: "remote-state", upstream: o.name, name: read.name });
|
|
296
|
+
}
|
|
297
|
+
for (const read of estateOutputReads(r.dir, r.binary)) {
|
|
298
|
+
for (const o of rcs) if (o.name !== r.name && o.estate === read.estate) reads.push({ via: "estate-outputs", upstream: o.name, name: read.name });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
out.set(r.name, reads);
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Every component of `input` that deploys a root, its files read once. */
|
|
307
|
+
function rootComponents(input: Pick<CompositionInput, "projectRoot" | "terraform" | "components"> & Partial<Pick<CompositionInput, "env">>): RootComponent[] {
|
|
308
|
+
const tf = input.terraform;
|
|
309
|
+
const projectRoot = resolve(input.projectRoot);
|
|
310
|
+
const rcs: RootComponent[] = [];
|
|
311
|
+
for (const c of input.components) {
|
|
312
|
+
const steps = leafSteps(c.deploy);
|
|
313
|
+
const step = steps.find((s) => s.kind === COMPOSE_STEP_KIND);
|
|
314
|
+
if (!step) continue;
|
|
315
|
+
const root = typeof step.root === "string" ? step.root : undefined;
|
|
316
|
+
const config = root !== undefined ? tf.roots[root] : undefined;
|
|
317
|
+
const rc: RootComponent = { name: c.name, step, steps: steps.length, live: false, ...(root !== undefined ? { root } : {}) };
|
|
318
|
+
if (config && step.cwd === undefined) {
|
|
319
|
+
const dir = isAbsolute(config.dir) ? config.dir : resolve(projectRoot, config.dir);
|
|
320
|
+
const rel = relPath(projectRoot, dir);
|
|
321
|
+
const binary = tf.binary;
|
|
322
|
+
const live = liveEstate(dir, binary ?? "terraform");
|
|
323
|
+
const estate = config.estate ?? live.estate;
|
|
324
|
+
// The state address from the one backend verdict (chant#4478): the code, TF_CLI_ARGS*, the backend-config files and pairs, and what init recorded.
|
|
325
|
+
const state = rootState(dir, rel, {
|
|
326
|
+
...(input.env ? { env: input.env } : {}),
|
|
327
|
+
...(binary ? { binary } : {}),
|
|
328
|
+
...(config.backendConfig ? { backendConfig: config.backendConfig } : {}),
|
|
329
|
+
...(config.backendConfigFiles ? { backendConfigFiles: config.backendConfigFiles } : {}),
|
|
330
|
+
});
|
|
331
|
+
Object.assign(rc, { config, dir, rel, state, live: live.live, ...(estate ? { estate } : {}), ...(binary ? { binary } : {}) });
|
|
332
|
+
}
|
|
333
|
+
rcs.push(rc);
|
|
334
|
+
}
|
|
335
|
+
return rcs;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Each component's `terraform_remote_state` and `terraform_estate_outputs`
|
|
340
|
+
* reads of another component of `input` that deploys a root, as the
|
|
341
|
+
* composition finds them (#4447): what `terraform-apply`'s `groupReads`
|
|
342
|
+
* reports to core. Components with no such read are left out.
|
|
343
|
+
*/
|
|
344
|
+
export function rootHclReads(input: Pick<CompositionInput, "projectRoot" | "terraform" | "components"> & Partial<Pick<CompositionInput, "env">>): Map<string, ComposedRead[]> {
|
|
345
|
+
return new Map([...hclReads(rootComponents(input))].filter(([, reads]) => reads.length > 0));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const readKey = (r: ComposedRead): string => `${r.upstream}\0${r.via}\0${r.via === "vars" ? `${r.variable}\0${r.output}` : r.name}`;
|
|
349
|
+
|
|
350
|
+
// ── the composition ───────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** `TF_CLI_ARGS*` flags a composed run cannot take: their addresses name a root's resources without the module in front, and `-var` names no root. */
|
|
353
|
+
const CLI_REFUSED = /(^|\s)-{1,2}(target|exclude|replace|var)(=|\s|$)/;
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The `off` fallback when `terraform.compose` is not enabled, else undefined:
|
|
358
|
+
* what `terraform-apply`'s `groupFallback` answers first (ws-107).
|
|
359
|
+
*/
|
|
360
|
+
export function composeOff(terraform: TerraformConfig): ComposeFallback | undefined {
|
|
361
|
+
return terraform.compose?.enabled === true ? undefined : { kind: "off", reason: "`terraform.compose` is not enabled" };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The composition when `terraform.compose.enabled` is set, else undefined
|
|
366
|
+
* without reading anything. The only caller of {@link compositionFor} a run
|
|
367
|
+
* should use.
|
|
368
|
+
*/
|
|
369
|
+
export function compositionIfEnabled(input: CompositionInput): Composition | undefined {
|
|
370
|
+
if (input.terraform.compose?.enabled !== true) return undefined;
|
|
371
|
+
return compositionFor(input);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* What `terraform.compose` comes to for a change: the members, their order,
|
|
376
|
+
* modules and reads, each candidate left out with its reason, or why nothing
|
|
377
|
+
* composes. Per-member checks run first (root, steps, targets, binary,
|
|
378
|
+
* backend, identity, provider lock, reads, imports and paths, order, split);
|
|
379
|
+
* a member left out gets the first that applies to it. `cli-args`,
|
|
380
|
+
* `too-many-roots` and `one-root` then judge what is left as a whole.
|
|
381
|
+
*/
|
|
382
|
+
export function compositionFor(input: CompositionInput): Composition {
|
|
383
|
+
const tf = input.terraform;
|
|
384
|
+
const max = tf.compose?.max ?? DEFAULT_COMPOSE_MAX;
|
|
385
|
+
const projectRoot = resolve(input.projectRoot);
|
|
386
|
+
const byName = new Map(input.components.map((c) => [c.name, c]));
|
|
387
|
+
const rcs = rootComponents(input);
|
|
388
|
+
const rcOf = new Map(rcs.map((r) => [r.name, r]));
|
|
389
|
+
const hcl = hclReads(rcs);
|
|
390
|
+
const graph = dependencyGraph(input.components, hcl);
|
|
391
|
+
const candidates = composeSet(
|
|
392
|
+
rcs.map((r) => r.name),
|
|
393
|
+
input.selected,
|
|
394
|
+
graph,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const roots = new Map(candidates.flatMap((m) => (rcOf.get(m)!.root !== undefined ? [[m, rcOf.get(m)!.root!] as const] : [])));
|
|
398
|
+
const dirs = new Map(candidates.flatMap((m) => (rcOf.get(m)!.dir ? [[m, rcOf.get(m)!.dir!] as const] : [])));
|
|
399
|
+
|
|
400
|
+
// Each member's reads of every other candidate; narrowed to members at the end.
|
|
401
|
+
const allReads = new Map<string, ComposedRead[]>();
|
|
402
|
+
for (const m of candidates) {
|
|
403
|
+
const rc = rcOf.get(m)!;
|
|
404
|
+
const reads: ComposedRead[] = [...varsReads(rc.step).map((r) => ({ via: "vars" as const, ...r })), ...(hcl.get(m) ?? [])];
|
|
405
|
+
allReads.set(m, reads.filter((r) => r.upstream !== m && rcOf.has(r.upstream)));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const left = new Map<string, ComposeFallback>();
|
|
409
|
+
let members = [...candidates];
|
|
410
|
+
const drop = (m: string, kind: ComposeFallbackKind, reason: string): void => {
|
|
411
|
+
left.set(m, { kind, member: m, reason });
|
|
412
|
+
members = members.filter((x) => x !== m);
|
|
413
|
+
};
|
|
414
|
+
const each = (check: (m: string, rc: RootComponent) => [ComposeFallbackKind, string] | undefined): void => {
|
|
415
|
+
for (const m of [...members]) {
|
|
416
|
+
const hit = check(m, rcOf.get(m)!);
|
|
417
|
+
if (hit) drop(m, hit[0], hit[1]);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Where the root is, and that its deploy is that one step.
|
|
422
|
+
each((m, rc) => {
|
|
423
|
+
if (rc.root === undefined) return ["path", `\`${m}\`'s terraform-apply step names no \`root\``];
|
|
424
|
+
if (rc.step.cwd !== undefined) return ["path", `\`${m}\`'s terraform-apply step sets \`cwd\`, so its root is read from another chant.config`];
|
|
425
|
+
if (!rc.config) return ["path", `\`${m}\` deploys root \`${rc.root}\`, which \`terraform.roots\` does not list`];
|
|
426
|
+
return undefined;
|
|
427
|
+
});
|
|
428
|
+
const rootTaken = new Map<string, string>();
|
|
429
|
+
each((m, rc) => {
|
|
430
|
+
const had = rootTaken.get(rc.root!);
|
|
431
|
+
if (had) return ["same-root", `\`${m}\` deploys root \`${rc.root}\`, which \`${had}\` deploys too, and a composed run calls each root once`];
|
|
432
|
+
rootTaken.set(rc.root!, m);
|
|
433
|
+
return undefined;
|
|
434
|
+
});
|
|
435
|
+
each((m, rc) =>
|
|
436
|
+
rc.steps !== 1 ? ["steps", `\`${m}\`'s deploy has ${plural(rc.steps, "step")}, and only a component whose deploy is one terraform-apply step composes`] : undefined,
|
|
437
|
+
);
|
|
438
|
+
each((m) => {
|
|
439
|
+
const t = input.targets?.[m] ?? [];
|
|
440
|
+
return t.length > 0 ? ["targets", `\`${m}\` is given ${plural(t.length, "target")} (\`--target\`), and a composed run plans each member whole`] : undefined;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// One binary process: one binary at one version.
|
|
444
|
+
const binaryOf = (m: string): ExpectedBinary => input.binary?.(m) ?? { name: tf.binary ?? "terraform" };
|
|
445
|
+
const binName = (b: ExpectedBinary): string => `${b.name}${b.version ? ` ${b.version}` : ""}`;
|
|
446
|
+
const binRef = majority(members, (m) => binName(binaryOf(m)));
|
|
447
|
+
each((m) => {
|
|
448
|
+
if (!binRef || binName(binaryOf(m)) === binRef.key) return undefined;
|
|
449
|
+
return ["binary", `\`${m}\` runs ${binName(binaryOf(m))} and \`${binRef.first}\` ${binRef.key}, and one run has one binary`];
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Every member runs choudoufu and one has a `live` block: the members compose as estates (choudoufu#2059).
|
|
453
|
+
const estate = members.length > 1 && members.every((m) => binaryOf(m).name === "choudoufu") && members.some((m) => rcOf.get(m)!.live);
|
|
454
|
+
each((m, rc) => {
|
|
455
|
+
if (estate) return rc.live ? undefined : ["backend", `\`${m}\` has no \`live\` block, and a composed run of estates calls only directories that carry one`];
|
|
456
|
+
const ws = rc.config!.workspace;
|
|
457
|
+
if (ws && ws !== "default") return ["workspace", `\`${m}\` plans in workspace ${ws}, and a composed run plans in default`];
|
|
458
|
+
// The one backend verdict (chant#4478): the code and flags before init, compared with what init recorded after.
|
|
459
|
+
const refusal = lockRefusal(
|
|
460
|
+
readBackend(rc.dir!, {
|
|
461
|
+
env: input.env,
|
|
462
|
+
...(rc.binary ? { binary: rc.binary } : {}),
|
|
463
|
+
...(rc.config!.backendConfig ? { backendConfig: rc.config!.backendConfig } : {}),
|
|
464
|
+
...(rc.config!.backendConfigFiles ? { backendConfigFiles: rc.config!.backendConfigFiles } : {}),
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
if (refusal) return ["backend", `\`${m}\`: ${refusal.reason}`];
|
|
468
|
+
return undefined;
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// One binary process has one set of credentials. Estates take each one's role from its own provider blocks.
|
|
472
|
+
if (!estate && input.identity) {
|
|
473
|
+
const idOf = (m: string): string => input.identity!(m) ?? "";
|
|
474
|
+
const idRef = majority(members, idOf);
|
|
475
|
+
each((m) => {
|
|
476
|
+
if (!idRef || idOf(m) === idRef.key) return undefined;
|
|
477
|
+
const same = members.filter((x) => idOf(x) === idRef.key);
|
|
478
|
+
return ["identity", `\`${m}\` applies under another cloud identity (\`ci.identity.cloud\`) than ${list(same)}, and one run has one set of credentials`];
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// One run installs one version of each provider: the first member in name order to lock it sets it.
|
|
483
|
+
const locked = new Map<string, { version: string; member: string }>();
|
|
484
|
+
each((m, rc) => {
|
|
485
|
+
const mine = lockedProviders(rc.dir!);
|
|
486
|
+
for (const [p, v] of mine) {
|
|
487
|
+
const had = locked.get(p);
|
|
488
|
+
if (had && had.version !== v) return ["provider-lock", `\`${m}\` locks ${p} at ${v} and \`${had.member}\` at ${had.version}, and one run installs one version`];
|
|
489
|
+
}
|
|
490
|
+
for (const [p, v] of mine) if (!locked.has(p)) locked.set(p, { version: v, member: m });
|
|
491
|
+
return undefined;
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Reads a composed root cannot rewrite.
|
|
495
|
+
each((m, rc) => {
|
|
496
|
+
for (const r of allReads.get(m) ?? []) {
|
|
497
|
+
if (r.via !== "remote-state" || !members.includes(r.upstream)) continue;
|
|
498
|
+
if (rc.state!.reads.find((x) => x.name === r.name)?.repeated) {
|
|
499
|
+
return ["read", `\`${m}\` reads \`${r.upstream}\` through a block with count or for_each (\`data.terraform_remote_state.${r.name}\`)`];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const mod of localModules(rc.dir!, rc.binary)) {
|
|
503
|
+
const modRel = relPath(projectRoot, mod);
|
|
504
|
+
for (const read of rootState(mod, modRel, { recorded: null, env: {}, ...(rc.binary ? { binary: rc.binary } : {}) }).reads) {
|
|
505
|
+
const up = members.find((o) => o !== m && rcOf.get(o)!.state?.own && sameState(rcOf.get(o)!.state!.own!, read));
|
|
506
|
+
if (up) return ["read", `\`${m}\` reads \`${up}\` from its module \`${modRel}\`, and a composed run rewrites only a root's own reads`];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// What a module call cannot carry.
|
|
513
|
+
each((m, rc) => {
|
|
514
|
+
for (const t of rootTexts(rc.dir!, rc.binary)) {
|
|
515
|
+
if (t.json) {
|
|
516
|
+
if (jsonImportRefused(t.text)) return ["import", `an \`import\` block of \`${m}\` takes no literal string \`id\`, so it cannot be lifted into the composed root`];
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
for (const im of t.text.matchAll(/(^|\n)\s*import\s*\{/g)) {
|
|
520
|
+
if (!importLiteral(blockBody(t.text, im.index! + im[1]!.length))) {
|
|
521
|
+
return ["import", `an \`import\` block of \`${m}\` takes no literal string \`id\`, so it cannot be lifted into the composed root`];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (/\bpath\.(root|cwd)\b/.test(t.text)) return ["path", `\`${m}\` uses \`path.root\` or \`path.cwd\`, which would name the composed root instead of \`${rc.rel}\``];
|
|
525
|
+
}
|
|
526
|
+
return undefined;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// An order with no read: a module with its own providers takes no depends_on.
|
|
530
|
+
each((m) => {
|
|
531
|
+
const reads = new Set((allReads.get(m) ?? []).map((r) => r.upstream));
|
|
532
|
+
const u = (byName.get(m)?.dependsOn ?? []).find((d) => members.includes(d) && !reads.has(d));
|
|
533
|
+
return u ? ["order-only", `\`${m}\` depends on \`${u}\` (\`dependsOn\`) with no read between them, and a module with its own providers takes no depends_on`] : undefined;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// A component outside the run on a path between two members splits them (#4419 ruling 2): the one after it leaves.
|
|
537
|
+
for (let moved = true; moved; ) {
|
|
538
|
+
moved = false;
|
|
539
|
+
for (const m of [...members]) {
|
|
540
|
+
const hit = pathOutside(m, members, graph);
|
|
541
|
+
if (!hit) continue;
|
|
542
|
+
const via = byName.get(hit.via);
|
|
543
|
+
const what = rcOf.has(hit.via) ? "which is left out of the composed run and applies between them" : `a ${via ? kindOf(via) : "other"} component that applies between them`;
|
|
544
|
+
drop(m, "split", `\`${m}\` depends on \`${hit.member}\` through \`${hit.via}\`, ${what}`);
|
|
545
|
+
moved = true;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const fallbacks = candidates.flatMap((m) => (left.has(m) ? [left.get(m)!] : []));
|
|
551
|
+
const base = { candidates, max, roots, dirs, fallbacks, ...(tf.binary ? { binary: tf.binary } : {}) };
|
|
552
|
+
const none = (fallback: ComposeFallback): Composition => ({ ...base, members: [], order: [], modules: new Map(), reads: new Map(), fallback });
|
|
553
|
+
|
|
554
|
+
if (members.length >= 2) {
|
|
555
|
+
for (const k of ["TF_CLI_ARGS", "TF_CLI_ARGS_plan", "TF_CLI_ARGS_apply"]) {
|
|
556
|
+
const cli = (input.env[k] ?? "").match(CLI_REFUSED);
|
|
557
|
+
if (cli) return none({ kind: "cli-args", reason: `\`${k}\` carries \`-${cli[2]}\`, which names a root's own addresses or variables, not the composed run's` });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (members.length > max) return none({ kind: "too-many-roots", reason: `this change composes ${plural(members.length, "root")}, more than \`terraform.compose.max\` (${max})` });
|
|
561
|
+
if (members.length <= 1) {
|
|
562
|
+
if (candidates.length === 0) return none({ kind: "one-root", reason: "this change reaches no root" });
|
|
563
|
+
if (candidates.length === 1) return none({ kind: "one-root", member: candidates[0]!, reason: `this change reaches one root, \`${candidates[0]}\`, which applies in its own run` });
|
|
564
|
+
return none({
|
|
565
|
+
kind: "one-root",
|
|
566
|
+
...(members[0] ? { member: members[0] } : {}),
|
|
567
|
+
reason: members.length === 0 ? "no root of this change can compose" : `only \`${members[0]}\` of this change can compose, and one root applies in its own run`,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const inGroup = new Map(members.map((m) => [m, new Set([...(graph.get(m) ?? [])].filter((u) => members.includes(u)))] as const));
|
|
572
|
+
const order = readOrder(members, inGroup);
|
|
573
|
+
const names = moduleNames(members.map((m) => roots.get(m)!));
|
|
574
|
+
const modules = new Map(members.map((m) => [m, names.get(roots.get(m)!)!] as const));
|
|
575
|
+
const reads = new Map(
|
|
576
|
+
members.map((m) => {
|
|
577
|
+
const seen = new Map<string, ComposedRead>();
|
|
578
|
+
for (const r of allReads.get(m) ?? []) if (members.includes(r.upstream)) seen.set(readKey(r), r);
|
|
579
|
+
return [m, [...seen.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, r]) => r)] as const;
|
|
580
|
+
}),
|
|
581
|
+
);
|
|
582
|
+
return { ...base, members, order, modules, reads, ...(estate ? { estate } : {}) };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The value most members share, and the first member in name order that has it; a tie goes to the value the earliest member has. */
|
|
586
|
+
function majority(members: readonly string[], key: (m: string) => string): { key: string; first: string } | undefined {
|
|
587
|
+
const groups = new Map<string, string[]>();
|
|
588
|
+
for (const m of [...members].sort()) groups.set(key(m), [...(groups.get(key(m)) ?? []), m]);
|
|
589
|
+
let best: { key: string; first: string; n: number } | undefined;
|
|
590
|
+
for (const [k, ms] of groups) if (!best || ms.length > best.n) best = { key: k, first: ms[0]!, n: ms.length };
|
|
591
|
+
return best ? { key: best.key, first: best.first } : undefined;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A member that `m` depends on through a component that is not a member,
|
|
596
|
+
* and the first such component on the way, or undefined. Paths through
|
|
597
|
+
* another member stop there: that member is checked on its own.
|
|
598
|
+
*/
|
|
599
|
+
function pathOutside(m: string, members: readonly string[], graph: ReadonlyMap<string, ReadonlySet<string>>): { member: string; via: string } | undefined {
|
|
600
|
+
const seen = new Set<string>();
|
|
601
|
+
const stack: { at: string; via?: string }[] = [...(graph.get(m) ?? [])].sort().map((at) => ({ at }));
|
|
602
|
+
while (stack.length > 0) {
|
|
603
|
+
const { at, via } = stack.shift()!;
|
|
604
|
+
const key = `${at}\0${via ?? ""}`;
|
|
605
|
+
if (seen.has(key) || at === m) continue;
|
|
606
|
+
seen.add(key);
|
|
607
|
+
if (members.includes(at)) {
|
|
608
|
+
if (via) return { member: at, via };
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
for (const u of [...(graph.get(at) ?? [])].sort()) stack.push({ at: u, via: via ?? at });
|
|
612
|
+
}
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** The composition with its plan failed: nothing composes, and each member plans wave by wave. */
|
|
617
|
+
export function withPlanFailed(c: Composition, message: string): Composition {
|
|
618
|
+
const first = message.trim().split("\n")[0] ?? "";
|
|
619
|
+
return { ...c, members: [], order: [], modules: new Map(), reads: new Map(), fallback: { kind: "plan-failed", reason: `the composed plan failed${first ? `: ${first}` : ""}` } };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** The one line a note, a log and the report say for a fallback: the whole change's, or one member's. */
|
|
623
|
+
export function fallbackLine(f: ComposeFallback): string {
|
|
624
|
+
return f.member !== undefined && f.kind !== "one-root"
|
|
625
|
+
? `**\`${f.member}\` applies in waves** (\`terraform.compose\`): ${f.reason}.`
|
|
626
|
+
: `**Applies in waves** (\`terraform.compose\`): ${f.reason}.`;
|
|
627
|
+
}
|
|
@@ -119,6 +119,15 @@ describe("TerraformApplyOp gate (#2086)", () => {
|
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
describe("TerraformApplyOp root path outcome (#4482)", () => {
|
|
123
|
+
test("Init publishes the root's workspace path as TerraformRootPath", () => {
|
|
124
|
+
const op = props({ name: "app-apply", root: "app", gate: "never" });
|
|
125
|
+
const init = op.phases.find((p) => p.name === "Init")!.steps[0] as ActivityStep;
|
|
126
|
+
expect(init.fn).toBe("terraformInit");
|
|
127
|
+
expect(init.outcomeAttribute).toEqual({ name: "TerraformRootPath", from: "rootPath" });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
122
131
|
describe("TerraformApplyOp plan-to-apply reference (#2086)", () => {
|
|
123
132
|
test("Plan surfaces `changed`, and Apply applies that step's own plan file", () => {
|
|
124
133
|
const op = props({ name: "app-apply", root: "app", gate: "never" });
|
|
@@ -225,9 +225,13 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
// The Init step names the root's directory in the workspace (chant#4482),
|
|
229
|
+
// so a read of the run ledger can tell which member's root this run applied
|
|
230
|
+
// when the Op is declared in another member: `chant ledger runs`'s
|
|
231
|
+
// `root_member` column comes from this outcome.
|
|
232
|
+
const init = initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) });
|
|
233
|
+
init.outcomeAttribute = { name: "TerraformRootPath", from: "rootPath" };
|
|
234
|
+
const phases = [phase("Init", [init])];
|
|
231
235
|
|
|
232
236
|
// `id` is what makes `plan.out` legal, and `plan.out.planFile` is how the
|
|
233
237
|
// Apply step below names this step's saved plan. On a live root that file
|