@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed plan cut back into one plan per member (#4442), on recorded
|
|
3
|
+
* `show -json` fixtures in `../__fixtures__/compose`: a composed plan of
|
|
4
|
+
* three modules beside each root's plan alone on the same state, and a
|
|
5
|
+
* composed plan where one member reads an output another creates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import type { Composition, ComposedRead } from "../compose";
|
|
13
|
+
import { terraformChangeSetPart } from "../change-set";
|
|
14
|
+
import { terraformPlannedOutputs } from "../components/capability-plugin";
|
|
15
|
+
import { terraformPlanShape } from "../plan-shape";
|
|
16
|
+
import { slicePlan as reportSlicePlan } from "../report";
|
|
17
|
+
import { moduleChanges, stripChange, stripModule, topModule } from "./module-address";
|
|
18
|
+
import { composedMemberPlans, composedReads, planCounts, slicePlan, sliceText } from "./slice";
|
|
19
|
+
|
|
20
|
+
const FIX = join(import.meta.dirname, "..", "__fixtures__", "compose");
|
|
21
|
+
const fixture = (name: string): unknown => JSON.parse(readFileSync(join(FIX, name), "utf-8"));
|
|
22
|
+
|
|
23
|
+
const made: string[] = [];
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
for (const d of made) rmSync(d, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A composition of `members` in that order, each its own module and root, reading as `reads` says. */
|
|
29
|
+
function composition(members: string[], reads: Record<string, ComposedRead[]> = {}, dirs: Record<string, string> = {}): Composition {
|
|
30
|
+
return {
|
|
31
|
+
candidates: [...members].sort(),
|
|
32
|
+
members: [...members].sort(),
|
|
33
|
+
order: members,
|
|
34
|
+
max: 10,
|
|
35
|
+
roots: new Map(members.map((m) => [m, m])),
|
|
36
|
+
dirs: new Map(Object.entries(dirs)),
|
|
37
|
+
modules: new Map(members.map((m) => [m, m])),
|
|
38
|
+
reads: new Map(Object.entries(reads)),
|
|
39
|
+
fallbacks: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("one change-set part per member", () => {
|
|
44
|
+
const composed = fixture("three-composed.plan.json");
|
|
45
|
+
const c = composition(["net", "app", "edge"], { app: [{ via: "vars", upstream: "net", variable: "vpc_id", output: "vpc_id" }] });
|
|
46
|
+
const plans = composedMemberPlans(c, composed);
|
|
47
|
+
|
|
48
|
+
test.each(["net", "app", "edge"])("%s's part of the composed plan is the part its root planned alone gives", (m) => {
|
|
49
|
+
const alone = fixture(`three-${m}.plan.json`);
|
|
50
|
+
expect(plans[m]!.changeSet).toEqual(terraformChangeSetPart({ member: m, plan: alone }));
|
|
51
|
+
expect(plans[m]!.planDigest).toBe(terraformChangeSetPart({ member: m, plan: alone }).member.planDigest);
|
|
52
|
+
expect(plans[m]!.outputs).toEqual(terraformPlannedOutputs(alone));
|
|
53
|
+
expect(plans[m]!.shape).toEqual(terraformPlanShape(alone, m));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a slice reads as the root alone: addresses, module addresses, outputs, values and configuration unprefixed", () => {
|
|
57
|
+
const app = slicePlan(composed, "app") as any;
|
|
58
|
+
const alone = fixture("three-app.plan.json") as any;
|
|
59
|
+
expect(app.resource_changes).toEqual(alone.resource_changes);
|
|
60
|
+
expect(app.output_changes).toEqual(alone.output_changes);
|
|
61
|
+
expect(app.planned_values.root_module).toEqual(alone.planned_values.root_module);
|
|
62
|
+
expect(app.planned_values.outputs).toEqual(alone.planned_values.outputs);
|
|
63
|
+
expect(app.configuration.root_module).toEqual(alone.configuration.root_module);
|
|
64
|
+
const net = slicePlan(composed, "net") as any;
|
|
65
|
+
expect(net.prior_state.values.root_module).toEqual((fixture("three-net.plan.json") as any).prior_state.values.root_module);
|
|
66
|
+
expect(net.relevant_attributes).toEqual([{ resource: "terraform_data.vpc", attribute: ["id"] }]);
|
|
67
|
+
expect(net.checks).toBeUndefined();
|
|
68
|
+
expect(net.variables).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the counts are each member's own, a no-op not counted", () => {
|
|
72
|
+
expect(plans.net!.counts).toEqual({ add: 0, change: 1, destroy: 0, changes: 1, destroys: 0 });
|
|
73
|
+
expect(plans.app!.counts).toEqual({ add: 2, change: 0, destroy: 0, changes: 2, destroys: 0 });
|
|
74
|
+
expect(plans.edge!.counts).toEqual(planCounts(fixture("three-edge.plan.json")));
|
|
75
|
+
expect(plans.edge!.counts.changes).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a known read is a read with nothing unknown, and the reader's shape lists no unknown inputs", () => {
|
|
79
|
+
expect(plans.app!.reads).toEqual([{ upstream: "net", via: "vars", name: "vpc_id", outputs: ["vpc_id"], unknown: [] }]);
|
|
80
|
+
expect(plans.app!.shape.unknownInputs).toEqual([]);
|
|
81
|
+
expect(plans.net!.reads).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("an output one member creates and another reads", () => {
|
|
86
|
+
const composed = fixture("unknown-composed.plan.json");
|
|
87
|
+
|
|
88
|
+
test("is unknown in the upstream's planned outputs and in the reader's reads and shape", () => {
|
|
89
|
+
const c = composition(["queue", "worker"], { worker: [{ via: "vars", upstream: "queue", variable: "queue_id", output: "queue_id" }] });
|
|
90
|
+
const plans = composedMemberPlans(c, composed);
|
|
91
|
+
expect(plans.queue!.outputs).toEqual({ known: { queue_name: "jobs" }, unknown: ["queue_id"] });
|
|
92
|
+
expect(plans.worker!.reads).toEqual([{ upstream: "queue", via: "vars", name: "queue_id", outputs: ["queue_id"], unknown: ["queue_id"] }]);
|
|
93
|
+
expect(plans.worker!.shape.unknownInputs).toEqual(["queue.queue_id"]);
|
|
94
|
+
expect(plans.worker!.shape.entries).toEqual([expect.objectContaining({ logicalId: "terraform_data.consumer", action: "create", unknown: expect.arrayContaining(["input"]) })]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a read of a known output of the same upstream is not unknown", () => {
|
|
98
|
+
const c = composition(["queue", "worker"], { worker: [{ via: "vars", upstream: "queue", variable: "name", output: "queue_name" }] });
|
|
99
|
+
expect(composedMemberPlans(c, composed).worker!.reads).toEqual([{ upstream: "queue", via: "vars", name: "name", outputs: ["queue_name"], unknown: [] }]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a terraform_remote_state block names the outputs its root's files read; reading the whole object counts every output", () => {
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-compose-slice-"));
|
|
104
|
+
made.push(dir);
|
|
105
|
+
mkdirSync(join(dir, "worker"));
|
|
106
|
+
mkdirSync(join(dir, "whole"));
|
|
107
|
+
writeFileSync(join(dir, "worker", "main.tf"), 'data "terraform_remote_state" "q" {}\nresource "terraform_data" "consumer" { input = data.terraform_remote_state.q.outputs.queue_id }\n');
|
|
108
|
+
writeFileSync(join(dir, "whole", "main.tf"), 'data "terraform_remote_state" "q" {}\nresource "terraform_data" "consumer" { input = data.terraform_remote_state.q.outputs }\n');
|
|
109
|
+
const outputs = new Map([["queue", terraformPlannedOutputs(slicePlan(composed, "queue"))]]);
|
|
110
|
+
const read: ComposedRead = { via: "remote-state", upstream: "queue", name: "q" };
|
|
111
|
+
const reads = composedReads({ members: ["whole", "worker"], reads: new Map([["worker", [read]], ["whole", [read]]]), dirs: new Map([["worker", join(dir, "worker")], ["whole", join(dir, "whole")]]) }, outputs);
|
|
112
|
+
expect(reads.get("worker")).toEqual([{ upstream: "queue", via: "remote-state", name: "q", outputs: ["queue_id"], unknown: ["queue_id"] }]);
|
|
113
|
+
expect(reads.get("whole")).toEqual([{ upstream: "queue", via: "remote-state", name: "q", unknown: ["queue_id"] }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a remote state read the composed root rewrote to var.chant_read_<label> names the same outputs", () => {
|
|
117
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-compose-slice-"));
|
|
118
|
+
made.push(dir);
|
|
119
|
+
writeFileSync(join(dir, "main.tf"), 'resource "terraform_data" "consumer" { input = var.chant_read_q.outputs.queue_id }\n');
|
|
120
|
+
writeFileSync(join(dir, "chant_compose.tf"), 'variable "chant_read_q" {\n type = any\n}\n');
|
|
121
|
+
const outputs = new Map([["queue", terraformPlannedOutputs(slicePlan(composed, "queue"))]]);
|
|
122
|
+
const reads = composedReads({ members: ["worker"], reads: new Map([["worker", [{ via: "remote-state", upstream: "queue", name: "q" } as ComposedRead]]]), dirs: new Map([["worker", dir]]) }, outputs);
|
|
123
|
+
expect(reads.get("worker")).toEqual([{ upstream: "queue", via: "remote-state", name: "q", outputs: ["queue_id"], unknown: ["queue_id"] }]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("one strip function for the report and the slicer", () => {
|
|
128
|
+
test("a top module, with or without an index, is taken off; an address in no module is as it was", () => {
|
|
129
|
+
expect(topModule("module.app.module.logs.terraform_data.group")).toBe("app");
|
|
130
|
+
expect(stripModule("module.app.module.logs.terraform_data.group")).toBe("module.logs.terraform_data.group");
|
|
131
|
+
expect(stripModule('module.net["a"].terraform_data.vpc')).toBe("terraform_data.vpc");
|
|
132
|
+
expect(stripModule("module.net")).toBe("");
|
|
133
|
+
expect(stripModule("terraform_data.vpc")).toBe("terraform_data.vpc");
|
|
134
|
+
expect(stripChange({ address: "module.app.terraform_data.service", module_address: "module.app" })).toEqual({ address: "terraform_data.service" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("report.ts's slice and the composed slice give the same resource changes", () => {
|
|
138
|
+
const composed = fixture("three-composed.plan.json") as any;
|
|
139
|
+
for (const m of ["net", "app", "edge"]) {
|
|
140
|
+
const fromReport = reportSlicePlan(composed, m).resource_changes;
|
|
141
|
+
expect(fromReport).toEqual((slicePlan(composed, m) as any).resource_changes);
|
|
142
|
+
expect(fromReport).toEqual(moduleChanges(composed.resource_changes, m));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("sliceText", () => {
|
|
148
|
+
const text = [
|
|
149
|
+
"Terraform used the selected providers to generate the following execution",
|
|
150
|
+
"plan. Resource actions are indicated with the following symbols:",
|
|
151
|
+
" + create",
|
|
152
|
+
" ~ update in-place",
|
|
153
|
+
"",
|
|
154
|
+
"Terraform will perform the following actions:",
|
|
155
|
+
"",
|
|
156
|
+
" # module.app.terraform_data.service will be created",
|
|
157
|
+
' + resource "terraform_data" "service" {',
|
|
158
|
+
" + id = (known after apply)",
|
|
159
|
+
" }",
|
|
160
|
+
"",
|
|
161
|
+
" # module.net.terraform_data.vpc will be updated in-place",
|
|
162
|
+
' ~ resource "terraform_data" "vpc" {',
|
|
163
|
+
' id = "6f2b6c1e"',
|
|
164
|
+
" }",
|
|
165
|
+
"",
|
|
166
|
+
"Plan: 1 to add, 1 to change, 0 to destroy.",
|
|
167
|
+
"",
|
|
168
|
+
"Changes to Outputs:",
|
|
169
|
+
" + app__service_id = (known after apply)",
|
|
170
|
+
"",
|
|
171
|
+
].join("\n");
|
|
172
|
+
|
|
173
|
+
test("a member's resource blocks unprefixed, its counts and its outputs by their own names", () => {
|
|
174
|
+
const s = sliceText(text, "app", { add: 1, change: 0, destroy: 0 });
|
|
175
|
+
expect(s).toContain(" # terraform_data.service will be created");
|
|
176
|
+
expect(s).not.toContain("terraform_data.vpc");
|
|
177
|
+
expect(s).toContain("Plan: 1 to add, 0 to change, 0 to destroy.");
|
|
178
|
+
expect(s).toContain("Changes to Outputs:\n + service_id = (known after apply)");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a member with nothing in the text says it has no changes", () => {
|
|
182
|
+
expect(sliceText(text, "edge", { add: 0, change: 0, destroy: 0 })).toContain("No changes. Its part of the composed run matches the configuration.");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed plan cut back into one plan per member (chant#4442, epic
|
|
3
|
+
* #4419): each member's part of the composed root's `show -json`, as if the
|
|
4
|
+
* member's root had planned alone, and from it the member's change-set part,
|
|
5
|
+
* planned outputs, plan shape and reads of other members, so the change set,
|
|
6
|
+
* the report, policy and behold see each component and not one composed
|
|
7
|
+
* root.
|
|
8
|
+
*
|
|
9
|
+
* Lifted from terragucci's `packages/terragucci/src/compose.ts` at c493e0e
|
|
10
|
+
* (`slicePlan`, `sliceText`, `planCounts`, `composedReportReads`). Addresses
|
|
11
|
+
* are stripped by `./module-address`, which `chant terraform report` uses
|
|
12
|
+
* too. The outputs are `<m>__<name>` in the composed root, as #4441 writes
|
|
13
|
+
* them and terragucci did.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ChangeSetPlanner, ChangeSetPart } from "@intentius/chant/change-set";
|
|
17
|
+
import type { CapabilityPlan, PlannedOutputs } from "@intentius/chant/components/capability";
|
|
18
|
+
import type { PlanShape } from "@intentius/chant/components/covered-round";
|
|
19
|
+
import type { Composition, ComposedRead } from "../compose";
|
|
20
|
+
import { terraformChangeSetPart } from "../change-set";
|
|
21
|
+
import { terraformPlannedOutputs } from "../components/capability-plugin";
|
|
22
|
+
import { terraformPlanShape } from "../plan-shape";
|
|
23
|
+
import { rootTexts } from "./hcl";
|
|
24
|
+
import { inModule, moduleChanges, stripModule, topModule } from "./module-address";
|
|
25
|
+
import { READ_VAR_PREFIX } from "./root";
|
|
26
|
+
|
|
27
|
+
type Json = Record<string, unknown>;
|
|
28
|
+
const isObj = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
/** The outputs of module `m` among a composed root's, by their own names. */
|
|
31
|
+
export function moduleOutputs(outputs: unknown, m: string): Json {
|
|
32
|
+
if (!isObj(outputs)) return {};
|
|
33
|
+
return Object.fromEntries(Object.entries(outputs).filter(([k]) => k.startsWith(`${m}__`)).map(([k, v]) => [k.slice(m.length + 2), v]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A module's part of `planned_values` or `prior_state.values`: its child module as the root module, addresses unprefixed. */
|
|
37
|
+
export function moduleValues(values: unknown, m: string, outputs: Json): Json | undefined {
|
|
38
|
+
if (!isObj(values)) return undefined;
|
|
39
|
+
const root = isObj(values.root_module) ? values.root_module : {};
|
|
40
|
+
const child = (Array.isArray(root.child_modules) ? root.child_modules : []).find((x) => isObj(x) && x.address === `module.${m}`) as Json | undefined;
|
|
41
|
+
const strip = (mod: Json): Json => ({
|
|
42
|
+
...mod,
|
|
43
|
+
...(Array.isArray(mod.resources) ? { resources: mod.resources.map((x) => (isObj(x) ? { ...x, address: stripModule(String(x.address)) } : x)) } : {}),
|
|
44
|
+
...(Array.isArray(mod.child_modules) ? { child_modules: mod.child_modules.map((x) => (isObj(x) ? strip({ ...x, address: stripModule(String(x.address)) }) : x)) } : {}),
|
|
45
|
+
});
|
|
46
|
+
const { address: _a, ...rest } = child ? strip(child) : { address: undefined };
|
|
47
|
+
return { ...values, outputs, root_module: rest };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One member's part of the composed plan's `show -json`, as if it had
|
|
52
|
+
* planned alone: its resource changes and drift with `module.<m>.` taken off,
|
|
53
|
+
* its outputs by their own names, its planned values, prior state and
|
|
54
|
+
* configuration as the root module. `checks` and `variables` (the composed
|
|
55
|
+
* root's) are left out.
|
|
56
|
+
*/
|
|
57
|
+
export function slicePlan(plan: unknown, m: string): unknown {
|
|
58
|
+
if (!isObj(plan)) return plan;
|
|
59
|
+
const out: Json = { ...plan };
|
|
60
|
+
if (Array.isArray(plan.resource_changes)) out.resource_changes = moduleChanges(plan.resource_changes, m);
|
|
61
|
+
if (Array.isArray(plan.resource_drift)) out.resource_drift = moduleChanges(plan.resource_drift, m);
|
|
62
|
+
if (isObj(plan.output_changes)) out.output_changes = moduleOutputs(plan.output_changes, m);
|
|
63
|
+
if (isObj(plan.planned_values)) out.planned_values = moduleValues(plan.planned_values, m, moduleOutputs(plan.planned_values.outputs, m));
|
|
64
|
+
if (isObj(plan.prior_state)) {
|
|
65
|
+
const values = isObj(plan.prior_state.values) ? plan.prior_state.values : undefined;
|
|
66
|
+
out.prior_state = { ...plan.prior_state, ...(values ? { values: moduleValues(values, m, moduleOutputs(values.outputs, m)) } : {}) };
|
|
67
|
+
}
|
|
68
|
+
if (isObj(plan.configuration)) {
|
|
69
|
+
const root = isObj(plan.configuration.root_module) ? plan.configuration.root_module : {};
|
|
70
|
+
const call = isObj(root.module_calls) && isObj(root.module_calls[m]) ? (root.module_calls[m] as Json) : undefined;
|
|
71
|
+
const pc = isObj(plan.configuration.provider_config) ? plan.configuration.provider_config : {};
|
|
72
|
+
out.configuration = {
|
|
73
|
+
...plan.configuration,
|
|
74
|
+
provider_config: Object.fromEntries(
|
|
75
|
+
Object.entries(pc).flatMap(([k, v]) =>
|
|
76
|
+
k.startsWith(`module.${m}:`) ? [[k.slice(`module.${m}:`.length), isObj(v) ? { ...v, module_address: undefined } : v]] : k.includes(":") ? [] : [[k, v]],
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
root_module: isObj(call?.module) ? call!.module : {},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(plan.relevant_attributes)) {
|
|
83
|
+
out.relevant_attributes = plan.relevant_attributes
|
|
84
|
+
.filter((x) => isObj(x) && inModule(String(x.resource), m) && String(x.resource) !== `module.${m}`)
|
|
85
|
+
.map((x) => ({ ...(x as Json), resource: stripModule(String((x as Json).resource)) }));
|
|
86
|
+
}
|
|
87
|
+
delete out.checks;
|
|
88
|
+
delete out.variables;
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A plan's counts as the binary's `Plan:` line gives them, and how many changes and destroys it holds. */
|
|
93
|
+
export interface PlanCounts {
|
|
94
|
+
add: number;
|
|
95
|
+
change: number;
|
|
96
|
+
destroy: number;
|
|
97
|
+
changes: number;
|
|
98
|
+
destroys: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A plan's counts as the binary's `Plan:` line gives them. A no-op or read is not a change. */
|
|
102
|
+
export function planCounts(plan: unknown): PlanCounts {
|
|
103
|
+
const rc = isObj(plan) && Array.isArray(plan.resource_changes) ? plan.resource_changes : [];
|
|
104
|
+
let add = 0;
|
|
105
|
+
let change = 0;
|
|
106
|
+
let destroy = 0;
|
|
107
|
+
let changes = 0;
|
|
108
|
+
for (const r of rc) {
|
|
109
|
+
const a: string[] = isObj(r) && isObj(r.change) && Array.isArray(r.change.actions) ? (r.change.actions as string[]) : [];
|
|
110
|
+
if (a.every((x) => x === "no-op" || x === "read")) continue;
|
|
111
|
+
changes++;
|
|
112
|
+
if (a.includes("create")) add++;
|
|
113
|
+
if (a.includes("delete")) destroy++;
|
|
114
|
+
if (a.includes("update")) change++;
|
|
115
|
+
}
|
|
116
|
+
return { add, change, destroy, changes, destroys: destroy };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One member's part of the composed plan's `show` text: the text before the
|
|
121
|
+
* actions, each resource block of its module with `module.<m>.` taken off,
|
|
122
|
+
* its line of counts, and its outputs by their own names.
|
|
123
|
+
*/
|
|
124
|
+
export function sliceText(text: string, m: string, counts: { add: number; change: number; destroy: number }): string {
|
|
125
|
+
const lines = text.split("\n");
|
|
126
|
+
const head: string[] = [];
|
|
127
|
+
const blocks: string[][] = [];
|
|
128
|
+
const outs: string[] = [];
|
|
129
|
+
let i = 0;
|
|
130
|
+
for (; i < lines.length && !/^\s{2}# /.test(lines[i]!); i++) {
|
|
131
|
+
if (/^(Plan:|No changes\.|Changes to Outputs:)/.test(lines[i]!)) break;
|
|
132
|
+
head.push(lines[i]!);
|
|
133
|
+
}
|
|
134
|
+
let cur: string[] | undefined;
|
|
135
|
+
for (; i < lines.length; i++) {
|
|
136
|
+
const l = lines[i]!;
|
|
137
|
+
if (/^\s{2}# /.test(l)) {
|
|
138
|
+
cur = [l];
|
|
139
|
+
blocks.push(cur);
|
|
140
|
+
} else if (/^(Plan:|Changes to Outputs:|─|Note:|Saved the plan)/.test(l)) {
|
|
141
|
+
cur = undefined;
|
|
142
|
+
if (l.startsWith("Changes to Outputs:")) {
|
|
143
|
+
for (i++; i < lines.length && /^\s/.test(lines[i]!); i++) if (new RegExp(`^\\s+[-+~] ${m}__`).test(lines[i]!)) outs.push(lines[i]!.replace(`${m}__`, ""));
|
|
144
|
+
i--;
|
|
145
|
+
}
|
|
146
|
+
} else if (cur) cur.push(l);
|
|
147
|
+
}
|
|
148
|
+
const mine = blocks
|
|
149
|
+
.filter((b) => topModule(b[0]!.replace(/^\s{2}# /, "")) === m)
|
|
150
|
+
.map((b) => [b[0]!.replace(`module.${m}.`, ""), ...b.slice(1)].join("\n").trimEnd());
|
|
151
|
+
const parts = [head.join("\n").trimEnd()];
|
|
152
|
+
if (mine.length === 0 && outs.length === 0) parts.push("No changes. Its part of the composed run matches the configuration.");
|
|
153
|
+
else {
|
|
154
|
+
if (mine.length) parts.push(mine.join("\n\n"));
|
|
155
|
+
parts.push(`Plan: ${counts.add} to add, ${counts.change} to change, ${counts.destroy} to destroy.`);
|
|
156
|
+
if (outs.length) parts.push(`Changes to Outputs:\n${outs.join("\n")}`);
|
|
157
|
+
}
|
|
158
|
+
return parts.filter((p) => p !== "").join("\n\n") + "\n";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── reads between members ─────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The names a root's files read off `data.<type>.<name>.<field>`, or
|
|
167
|
+
* undefined when they read the whole object. While the root is composed
|
|
168
|
+
* (./root.ts) a `terraform_remote_state` read is `var.chant_read_<name>`,
|
|
169
|
+
* which counts the same.
|
|
170
|
+
*/
|
|
171
|
+
function fieldsRead(dir: string | undefined, binary: string | undefined, type: string, name: string, field: string): Set<string> | undefined {
|
|
172
|
+
const ref = type === "terraform_remote_state" ? `(?:data\\.${type}\\.|var\\.${READ_VAR_PREFIX})` : `data\\.${type}\\.`;
|
|
173
|
+
const re = new RegExp(`\\b${ref}${escapeRe(name)}(?![\\w-])(\\.${field}(?:\\.([A-Za-z_][\\w-]*)|\\[\\s*"([^"]+)"\\s*\\])?)?`, "g");
|
|
174
|
+
const read = new Set<string>();
|
|
175
|
+
for (const t of dir ? rootTexts(dir, binary) : []) {
|
|
176
|
+
for (const hit of t.text.matchAll(re)) {
|
|
177
|
+
const out = hit[2] ?? hit[3];
|
|
178
|
+
if (!out) return undefined;
|
|
179
|
+
read.add(out);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return read;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One member's read of another member's outputs, planned on the upstream's part of the composed plan. */
|
|
186
|
+
export interface ComposedMemberRead {
|
|
187
|
+
/** The member read. */
|
|
188
|
+
upstream: string;
|
|
189
|
+
via: ComposedRead["via"];
|
|
190
|
+
/** The `vars` key, or the data block's label. */
|
|
191
|
+
name: string;
|
|
192
|
+
/** The outputs read, sorted. Undefined when the reader reads the whole object, so every output counts. */
|
|
193
|
+
outputs?: string[];
|
|
194
|
+
/** The outputs read that are known only once the upstream's part applies, sorted. */
|
|
195
|
+
unknown: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Each member's reads of other members, planned on the upstream's part of
|
|
200
|
+
* the composed plan: an output read that the upstream's planned outputs
|
|
201
|
+
* ({@link terraformPlannedOutputs} of its slice) leave unknown is named
|
|
202
|
+
* unknown. A `vars` read names its output; a `terraform_remote_state` or
|
|
203
|
+
* `terraform_estate_outputs` block's outputs are read from the member's own
|
|
204
|
+
* files, as terragucci's `outputsRead` and `estateValuesRead` did.
|
|
205
|
+
*/
|
|
206
|
+
export function composedReads(c: Pick<Composition, "members" | "reads" | "dirs"> & Partial<Pick<Composition, "binary">>, outputs: ReadonlyMap<string, PlannedOutputs>): Map<string, ComposedMemberRead[]> {
|
|
207
|
+
const out = new Map<string, ComposedMemberRead[]>();
|
|
208
|
+
for (const member of c.members) {
|
|
209
|
+
const reads: ComposedMemberRead[] = [];
|
|
210
|
+
// Several `vars` reads of one upstream through one variable are one read.
|
|
211
|
+
const byKey = new Map<string, { r: ComposedRead; outputs?: Set<string> }>();
|
|
212
|
+
for (const r of c.reads.get(member) ?? []) {
|
|
213
|
+
const planned = outputs.get(r.upstream);
|
|
214
|
+
if (!planned) continue;
|
|
215
|
+
if (r.via === "vars") {
|
|
216
|
+
const key = `vars\u0000${r.upstream}\u0000${r.variable}`;
|
|
217
|
+
const had = byKey.get(key);
|
|
218
|
+
if (had) had.outputs!.add(r.output);
|
|
219
|
+
else byKey.set(key, { r, outputs: new Set([r.output]) });
|
|
220
|
+
} else {
|
|
221
|
+
const read = r.via === "remote-state" ? fieldsRead(c.dirs.get(member), c.binary, "terraform_remote_state", r.name, "outputs") : fieldsRead(c.dirs.get(member), c.binary, "terraform_estate_outputs", r.name, "values");
|
|
222
|
+
byKey.set(`${r.via}\u0000${r.upstream}\u0000${r.name}`, { r, ...(read ? { outputs: read } : {}) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const { r, outputs: read } of byKey.values()) {
|
|
226
|
+
const planned = outputs.get(r.upstream)!;
|
|
227
|
+
const unknown = [...new Set(planned.unknown)].filter((o) => read === undefined || read.has(o)).sort();
|
|
228
|
+
reads.push({ upstream: r.upstream, via: r.via, name: r.via === "vars" ? r.variable : r.name, ...(read ? { outputs: [...read].sort() } : {}), unknown });
|
|
229
|
+
}
|
|
230
|
+
if (reads.length) out.set(member, reads);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── each member's plan ────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/** One member's part of a composed plan, in the form a capability's `plan` answers it. */
|
|
238
|
+
export interface ComposedMemberPlan extends CapabilityPlan {
|
|
239
|
+
/** The member's root, as `terraform.roots` names it: the change set's member. */
|
|
240
|
+
member: string;
|
|
241
|
+
changeSet: ChangeSetPart;
|
|
242
|
+
outputs: PlannedOutputs;
|
|
243
|
+
/** `unknownInputs` are this member's reads of other members that the composed plan leaves unknown, as `<component>.<output>`. */
|
|
244
|
+
shape: PlanShape;
|
|
245
|
+
/** The member's part of `show -json`, as if it had planned alone. */
|
|
246
|
+
slice: unknown;
|
|
247
|
+
counts: PlanCounts;
|
|
248
|
+
/** Its reads of other members. */
|
|
249
|
+
reads: ComposedMemberRead[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ComposedMemberPlansOptions {
|
|
253
|
+
/** The binary that planned. Default `terraform`. */
|
|
254
|
+
planner?: ChangeSetPlanner;
|
|
255
|
+
/** The estate or workspace each member plans into, by component. */
|
|
256
|
+
scope?: (member: string) => string | undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Every member's plan out of the composed plan's `show -json`, by component:
|
|
261
|
+
* {@link terraformChangeSetPart}, {@link terraformPlannedOutputs} and
|
|
262
|
+
* {@link terraformPlanShape} of its slice, so each member's part is the part
|
|
263
|
+
* a plan of its root alone gives on the same state. The member is the root's
|
|
264
|
+
* name (`Composition.roots`), as a single `terraform-apply` plan names it.
|
|
265
|
+
*/
|
|
266
|
+
export function composedMemberPlans(c: Pick<Composition, "members" | "order" | "modules" | "reads" | "dirs" | "roots"> & Partial<Pick<Composition, "binary">>, plan: unknown, options: ComposedMemberPlansOptions = {}): Record<string, ComposedMemberPlan> {
|
|
267
|
+
const slices = new Map(c.order.map((member) => [member, slicePlan(plan, c.modules.get(member)!)] as const));
|
|
268
|
+
const outputs = new Map(c.order.map((member) => [member, terraformPlannedOutputs(slices.get(member))] as const));
|
|
269
|
+
const reads = composedReads(c, outputs);
|
|
270
|
+
const result: Record<string, ComposedMemberPlan> = {};
|
|
271
|
+
for (const member of c.order) {
|
|
272
|
+
const slice = slices.get(member);
|
|
273
|
+
const root = c.roots.get(member) ?? member;
|
|
274
|
+
const scope = options.scope?.(member);
|
|
275
|
+
const changeSet = terraformChangeSetPart({ member: root, plan: slice, ...(options.planner ? { planner: options.planner } : {}), ...(scope ? { scope } : {}) });
|
|
276
|
+
const mine = reads.get(member) ?? [];
|
|
277
|
+
const unknownInputs = [...new Set(mine.flatMap((r) => r.unknown.map((o) => `${r.upstream}.${o}`)))].sort();
|
|
278
|
+
result[member] = {
|
|
279
|
+
member: root,
|
|
280
|
+
planDigest: changeSet.member.planDigest ?? "",
|
|
281
|
+
changeSet,
|
|
282
|
+
outputs: outputs.get(member)!,
|
|
283
|
+
shape: terraformPlanShape(slice, root, { unknownInputs }),
|
|
284
|
+
slice,
|
|
285
|
+
counts: planCounts(slice),
|
|
286
|
+
reads: mine,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifted from terragucci's `packages/terragucci/src/state-address.ts` at c493e0e
|
|
3
|
+
* (chant#4438), unchanged but for this note.
|
|
4
|
+
*
|
|
5
|
+
* Where a state is, for each backend type, as a root's code names it: in the
|
|
6
|
+
* root's own backend block (or `cloud` block), and in a
|
|
7
|
+
* `terraform_remote_state` block that reads it. Two roots line up when the
|
|
8
|
+
* address one's backend names is the address the other's read names.
|
|
9
|
+
*
|
|
10
|
+
* s3, gcs, azurerm and any type not named here keep the address they always
|
|
11
|
+
* had: the bucket, when one is named, and the key (or `prefix`). The others
|
|
12
|
+
* get a key of their own, prefixed with the type so that no two types share
|
|
13
|
+
* one:
|
|
14
|
+
*
|
|
15
|
+
* - `local:<path>`: the state file, resolved against the root's directory and
|
|
16
|
+
* written relative to the repo.
|
|
17
|
+
* - `pg:<host>/<database>/<schema>.<table>`: from `conn_str`, credentials left out.
|
|
18
|
+
* - `http:<address>`: the URL, credentials left out.
|
|
19
|
+
* - `consul:<address>/<path>`.
|
|
20
|
+
* - `kubernetes:<namespace>/<secret_suffix>`.
|
|
21
|
+
* - `remote:<hostname>/<organization>/<workspace>`: a `remote` backend or a `cloud` block.
|
|
22
|
+
*
|
|
23
|
+
* An address is unresolved when a part of it is not a plain string in the
|
|
24
|
+
* code: an expression such as `var.env`, or a value the backend takes from
|
|
25
|
+
* the environment or a `-backend-config` file (`PG_CONN_STR`,
|
|
26
|
+
* `TF_HTTP_ADDRESS`, `CONSUL_HTTP_ADDR`, `KUBE_NAMESPACE`,
|
|
27
|
+
* `TF_CLOUD_ORGANIZATION`, a workspace picked by tags or `TF_WORKSPACE`).
|
|
28
|
+
* Nothing can line it up with another root, so it is named instead.
|
|
29
|
+
*/
|
|
30
|
+
import { posix, sep } from "node:path";
|
|
31
|
+
|
|
32
|
+
/** Where one state is: its bucket, when the backend names one, and its key. */
|
|
33
|
+
export interface StateKey {
|
|
34
|
+
bucket?: string;
|
|
35
|
+
key: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An address, or why the code does not say it. */
|
|
39
|
+
export type Addressed = StateKey | { unresolved: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One attribute as the code writes it: the string, `null` when it is written
|
|
43
|
+
* but is not a plain string (an expression, or a string with a template in
|
|
44
|
+
* it), undefined when it is not written.
|
|
45
|
+
*/
|
|
46
|
+
export type Attr = (name: string) => string | null | undefined;
|
|
47
|
+
|
|
48
|
+
/** The types this module gives an address of their own. */
|
|
49
|
+
export const ADDRESSED_TYPES = ["local", "pg", "http", "consul", "kubernetes", "remote"] as const;
|
|
50
|
+
|
|
51
|
+
const unresolved = (why: string): Addressed => ({ unresolved: why });
|
|
52
|
+
|
|
53
|
+
/** A plain string with no template in it, else null. */
|
|
54
|
+
export function literal(v: unknown): string | null | undefined {
|
|
55
|
+
if (v === undefined) return undefined;
|
|
56
|
+
return typeof v === "string" && !/\$\{|%\{/.test(v) ? v : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `scheme://user:pass@Host:port/path?q` as `host:port/path`, credentials and query left out. */
|
|
60
|
+
function urlAddress(raw: string, keepScheme: boolean): string | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(raw.includes("://") ? raw : `http://${raw}`);
|
|
63
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
64
|
+
return `${keepScheme ? `${u.protocol}//` : ""}${u.host.toLowerCase()}${path}`;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The host and database of a Postgres connection string, in URL or `key=value` form. */
|
|
71
|
+
export function pgDatabase(conn: string): string | undefined {
|
|
72
|
+
if (/^postgres(ql)?:\/\//i.test(conn)) {
|
|
73
|
+
try {
|
|
74
|
+
const u = new URL(conn);
|
|
75
|
+
const db = u.pathname.replace(/^\/+/, "") || u.searchParams.get("dbname");
|
|
76
|
+
const host = u.host.toLowerCase() || u.searchParams.get("host");
|
|
77
|
+
return host && db ? `${host}/${db}` : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const kv = new Map([...conn.matchAll(/(\w+)\s*=\s*('(?:[^'\\]|\\.)*'|\S+)/g)].map((m) => [m[1], m[2].replace(/^'|'$/g, "")]));
|
|
83
|
+
const host = kv.get("host")?.toLowerCase();
|
|
84
|
+
const db = kv.get("dbname");
|
|
85
|
+
if (!host || !db) return undefined;
|
|
86
|
+
return `${host}${kv.get("port") ? `:${kv.get("port")}` : ""}/${db}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The address of a state of backend `type`, from its attributes. `dir` is the
|
|
91
|
+
* directory the binary runs in for the block (the root's, relative to the
|
|
92
|
+
* repo), which a local path is resolved against.
|
|
93
|
+
*/
|
|
94
|
+
export function stateAddress(type: string, get: Attr, dir: string): Addressed {
|
|
95
|
+
const missing = (name: string, from?: string): Addressed =>
|
|
96
|
+
unresolved(get(name) === null ? `its ${type} ${name} is an expression, not a plain string` : `its ${type} backend names no ${name} in the code (${from ? `${from} or ` : ""}a -backend-config file supplies it)`);
|
|
97
|
+
switch (type) {
|
|
98
|
+
case "local": {
|
|
99
|
+
const path = get("path");
|
|
100
|
+
if (path === null) return missing("path");
|
|
101
|
+
const p = (path ?? "terraform.tfstate").split(sep).join(posix.sep);
|
|
102
|
+
// Relative to the repo, `/` between parts; a path outside the repo keeps its `..`, an absolute one stays as it is.
|
|
103
|
+
return { key: `local:${posix.isAbsolute(p) ? posix.normalize(p) : posix.normalize(posix.join(dir.split(sep).join(posix.sep) || ".", p))}` };
|
|
104
|
+
}
|
|
105
|
+
case "pg": {
|
|
106
|
+
const conn = get("conn_str");
|
|
107
|
+
if (!conn) return missing("conn_str", "PG_CONN_STR");
|
|
108
|
+
const db = pgDatabase(conn);
|
|
109
|
+
if (!db) return unresolved("its pg conn_str names no host and database");
|
|
110
|
+
const schema = get("schema_name");
|
|
111
|
+
const table = get("table_name");
|
|
112
|
+
if (schema === null) return missing("schema_name");
|
|
113
|
+
if (table === null) return missing("table_name");
|
|
114
|
+
return { key: `pg:${db}/${schema ?? "terraform_remote_state"}.${table ?? "states"}` };
|
|
115
|
+
}
|
|
116
|
+
case "http": {
|
|
117
|
+
const address = get("address");
|
|
118
|
+
if (!address) return missing("address", "TF_HTTP_ADDRESS");
|
|
119
|
+
const url = urlAddress(address, true);
|
|
120
|
+
return url ? { key: `http:${url}` } : unresolved(`its http address is not a URL`);
|
|
121
|
+
}
|
|
122
|
+
case "consul": {
|
|
123
|
+
const address = get("address");
|
|
124
|
+
const path = get("path");
|
|
125
|
+
if (!path) return missing("path");
|
|
126
|
+
if (!address) return missing("address", "CONSUL_HTTP_ADDR");
|
|
127
|
+
const host = urlAddress(address, false);
|
|
128
|
+
return host ? { key: `consul:${host}/${path.replace(/^\/+/, "")}` } : unresolved("its consul address is not a host");
|
|
129
|
+
}
|
|
130
|
+
case "kubernetes": {
|
|
131
|
+
const suffix = get("secret_suffix");
|
|
132
|
+
const namespace = get("namespace");
|
|
133
|
+
if (!suffix) return missing("secret_suffix");
|
|
134
|
+
if (!namespace) return missing("namespace", "KUBE_NAMESPACE");
|
|
135
|
+
return { key: `kubernetes:${namespace}/${suffix}` };
|
|
136
|
+
}
|
|
137
|
+
case "remote":
|
|
138
|
+
case "cloud": {
|
|
139
|
+
const host = get("hostname");
|
|
140
|
+
const org = get("organization");
|
|
141
|
+
const name = get("name");
|
|
142
|
+
if (host === null) return missing("hostname");
|
|
143
|
+
if (!org) return missing("organization", type === "cloud" ? "TF_CLOUD_ORGANIZATION" : undefined);
|
|
144
|
+
if (name === null) return unresolved(`its ${type} workspace name is an expression, not a plain string`);
|
|
145
|
+
if (name === undefined) return unresolved(`its ${type} workspaces block names no workspace by name (one picked by tags, prefix or TF_WORKSPACE cannot be read from the code)`);
|
|
146
|
+
return { key: `remote:${(host ?? "app.terraform.io").toLowerCase()}/${org}/${name}` };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// s3, gcs, azurerm, and the types not named above: the bucket and key, as before.
|
|
150
|
+
const k = get("key");
|
|
151
|
+
const p = get("prefix");
|
|
152
|
+
const key = k || p;
|
|
153
|
+
if (!key) return missing(k === null ? "key" : p === null ? "prefix" : type === "gcs" ? "prefix" : "key");
|
|
154
|
+
const bucket = get("bucket");
|
|
155
|
+
if (bucket === null) return missing("bucket");
|
|
156
|
+
return { ...(bucket ? { bucket } : {}), key };
|
|
157
|
+
}
|