@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,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Previewing a root whose `vars` read outputs not known yet (#4463).
|
|
3
|
+
*
|
|
4
|
+
* terraform takes no unknown variable, so such a root used to get no plan at
|
|
5
|
+
* pull-request time, only a hole. Now it is planned with a stand-in for each
|
|
6
|
+
* variable that holds no value yet, typed from the root's own `variable`
|
|
7
|
+
* block ({@link terraformPlaceholder}). Every attribute and output the
|
|
8
|
+
* stand-ins drive is then marked unknown after apply in the plan JSON
|
|
9
|
+
* ({@link markPlaceholderDriven}) before the change set, the shape and the
|
|
10
|
+
* planned outputs are read from it, so no stand-in value reaches the note or
|
|
11
|
+
* the digest. The member's change-set part lists the variables under
|
|
12
|
+
* `placeholders`.
|
|
13
|
+
*
|
|
14
|
+
* What a stand-in drives is read from the plan's own `configuration`: an
|
|
15
|
+
* attribute whose expression references `var.<name>`, through module inputs
|
|
16
|
+
* too, plus any string attribute or output whose planned value carries
|
|
17
|
+
* {@link TERRAFORM_UNKNOWN_MARK}. A value that reaches an attribute another
|
|
18
|
+
* way (a number through a `local`) is not marked; the fresh plan at apply
|
|
19
|
+
* then differs from the preview and asks for an approval of its own, which
|
|
20
|
+
* costs a round and never applies a change nobody approved.
|
|
21
|
+
*
|
|
22
|
+
* A preview is not possible when a variable that holds the unknown read is
|
|
23
|
+
* not declared, when the unknown sits inside a larger value, when a `count`
|
|
24
|
+
* or `for_each` reads a stand-in (the preview would show a count the real
|
|
25
|
+
* value does not have), or when the placeholder plan fails, say on a
|
|
26
|
+
* `validation` block. The member then stays a hole, as before, and the
|
|
27
|
+
* hole's reason says why (the 2026-10-25 ruling on #4463).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ChangeSetHole } from "@intentius/chant/change-set";
|
|
31
|
+
|
|
32
|
+
/** The marker every string stand-in carries, so a reader can tell it from a real value. */
|
|
33
|
+
export const TERRAFORM_UNKNOWN_MARK = "known-after-apply";
|
|
34
|
+
|
|
35
|
+
type Json = Record<string, unknown>;
|
|
36
|
+
const isObject = (v: unknown): v is Json => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
37
|
+
|
|
38
|
+
/** Split `text` on `sep` where no bracket is open. */
|
|
39
|
+
function splitTop(text: string, sep: string | RegExp): string[] {
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let quoted = false;
|
|
43
|
+
let start = 0;
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const c = text[i]!;
|
|
46
|
+
if (c === '"' && text[i - 1] !== "\\") quoted = !quoted;
|
|
47
|
+
if (quoted) continue;
|
|
48
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
49
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
50
|
+
else if (depth === 0 && (typeof sep === "string" ? c === sep : sep.test(c))) {
|
|
51
|
+
out.push(text.slice(start, i));
|
|
52
|
+
start = i + 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
out.push(text.slice(start));
|
|
56
|
+
return out.map((s) => s.trim()).filter((s) => s !== "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `name(inner)` read off a type expression, or `undefined` when it is not one. */
|
|
60
|
+
function call(type: string, name: string): string | undefined {
|
|
61
|
+
const m = type.match(new RegExp(`^${name}\\s*\\(([\\s\\S]*)\\)$`));
|
|
62
|
+
return m ? m[1]!.trim() : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A stand-in for a variable of `type`, a Terraform type expression as the
|
|
67
|
+
* `variable` block writes it (`string`, `list(string)`, `object({ id = string })`).
|
|
68
|
+
*
|
|
69
|
+
* - `string`, `any` or no type: {@link TERRAFORM_UNKNOWN_MARK}.
|
|
70
|
+
* - `number`: 0. `bool`: false.
|
|
71
|
+
* - `list(...)`, `set(...)`: an empty list. `map(...)`: an empty map.
|
|
72
|
+
* - `tuple([a, b])`: one stand-in per element type.
|
|
73
|
+
* - `object({ a = t })`: one stand-in per attribute, `optional(t)` read as `t`.
|
|
74
|
+
*
|
|
75
|
+
* An empty collection plans no element, so a resource that iterates one
|
|
76
|
+
* plans none; the fresh plan at apply then asks for its own approval.
|
|
77
|
+
*/
|
|
78
|
+
export function terraformPlaceholder(type: string | undefined): unknown {
|
|
79
|
+
const t = (type ?? "").trim().replace(/^\$\{([\s\S]*)\}$/, "$1").trim();
|
|
80
|
+
if (t === "" || t === "any" || t === "string") return TERRAFORM_UNKNOWN_MARK;
|
|
81
|
+
if (t === "number") return 0;
|
|
82
|
+
if (t === "bool") return false;
|
|
83
|
+
const optional = call(t, "optional");
|
|
84
|
+
if (optional !== undefined) return terraformPlaceholder(splitTop(optional, ",")[0]);
|
|
85
|
+
if (call(t, "list") !== undefined || call(t, "set") !== undefined) return [];
|
|
86
|
+
if (call(t, "map") !== undefined) return {};
|
|
87
|
+
const tuple = call(t, "tuple");
|
|
88
|
+
if (tuple !== undefined) {
|
|
89
|
+
const inner = tuple.replace(/^\[([\s\S]*)\]$/, "$1");
|
|
90
|
+
return splitTop(inner, ",").map((e) => terraformPlaceholder(e));
|
|
91
|
+
}
|
|
92
|
+
const object = call(t, "object");
|
|
93
|
+
if (object !== undefined) {
|
|
94
|
+
const inner = object.replace(/^\{([\s\S]*)\}$/, "$1");
|
|
95
|
+
const out: Record<string, unknown> = {};
|
|
96
|
+
for (const attr of splitTop(inner, /[,\n]/)) {
|
|
97
|
+
const at = attr.indexOf("=");
|
|
98
|
+
if (at < 0) continue;
|
|
99
|
+
const name = attr.slice(0, at).trim().replace(/^"(.*)"$/, "$1");
|
|
100
|
+
out[name] = terraformPlaceholder(attr.slice(at + 1));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
return TERRAFORM_UNKNOWN_MARK;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const containsUndefined = (v: unknown): boolean =>
|
|
108
|
+
v === undefined || (Array.isArray(v) ? v.some(containsUndefined) : isObject(v) ? Object.values(v).some(containsUndefined) : false);
|
|
109
|
+
|
|
110
|
+
/** The variables a preview gives stand-ins, and the `vars` it plans with. */
|
|
111
|
+
export interface PlaceholderVars {
|
|
112
|
+
/** The variable names given a stand-in, sorted. */
|
|
113
|
+
placeholders: string[];
|
|
114
|
+
/** The step's `vars` with each of them replaced by its stand-in. */
|
|
115
|
+
vars: Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The stand-ins for a step's `vars` (#4463): each variable whose value is
|
|
120
|
+
* `undefined` (a `stackOutput()` read with nothing to read yet) gets
|
|
121
|
+
* {@link terraformPlaceholder} of its declared type. Returns why instead when
|
|
122
|
+
* no preview can be made: no variable holds the unknown read, one that does
|
|
123
|
+
* is not declared in the root, or the unknown sits inside a larger value.
|
|
124
|
+
*/
|
|
125
|
+
export function placeholderVars(vars: Record<string, unknown> | undefined, declared: ReadonlyMap<string, { type?: string }>): PlaceholderVars | string {
|
|
126
|
+
const given = vars ?? {};
|
|
127
|
+
const placeholders: string[] = [];
|
|
128
|
+
const out: Record<string, unknown> = {};
|
|
129
|
+
for (const [name, value] of Object.entries(given)) {
|
|
130
|
+
if (value === undefined) {
|
|
131
|
+
const decl = declared.get(name);
|
|
132
|
+
if (!decl) return `var.${name} holds the unknown read and the root declares no variable "${name}" to type a stand-in from`;
|
|
133
|
+
placeholders.push(name);
|
|
134
|
+
out[name] = terraformPlaceholder(decl.type);
|
|
135
|
+
} else if (containsUndefined(value)) {
|
|
136
|
+
return `the unknown read sits inside var.${name}, which a stand-in cannot fill in part`;
|
|
137
|
+
} else out[name] = value;
|
|
138
|
+
}
|
|
139
|
+
if (placeholders.length === 0) return "no variable of the step holds the unknown read";
|
|
140
|
+
return { placeholders: placeholders.sort(), vars: out };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every `references` entry under an expression, at any depth (nested blocks are arrays of expression objects). */
|
|
144
|
+
function referencesIn(expr: unknown, out: string[] = []): string[] {
|
|
145
|
+
if (Array.isArray(expr)) for (const e of expr) referencesIn(e, out);
|
|
146
|
+
else if (isObject(expr)) {
|
|
147
|
+
if (Array.isArray(expr.references)) for (const r of expr.references) if (typeof r === "string") out.push(r);
|
|
148
|
+
for (const [k, v] of Object.entries(expr)) if (k !== "references" && k !== "constant_value") referencesIn(v, out);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const readsVar = (expr: unknown, vars: ReadonlySet<string>): boolean =>
|
|
154
|
+
referencesIn(expr).some((r) => {
|
|
155
|
+
const m = r.match(/^var\.([^.[]+)/);
|
|
156
|
+
return m !== null && vars.has(m[1]!);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** What the stand-ins drive, from the plan's configuration. */
|
|
160
|
+
interface Driven {
|
|
161
|
+
/** `<module path>|<mode>.<type>.<name>` to the top-level attributes a stand-in drives. */
|
|
162
|
+
attributes: Map<string, Set<string>>;
|
|
163
|
+
/** Root outputs whose expression reads a stand-in. */
|
|
164
|
+
outputs: Set<string>;
|
|
165
|
+
/** A `count` or `for_each` that reads a stand-in, by address: the preview cannot say how many. */
|
|
166
|
+
expansions: string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const resourceKey = (modulePath: string, mode: string, type: string, name: string): string => `${modulePath}|${mode === "data" ? "data." : ""}${type}.${name}`;
|
|
170
|
+
|
|
171
|
+
function walkModule(mod: unknown, path: string, vars: ReadonlySet<string>, driven: Driven): void {
|
|
172
|
+
if (!isObject(mod) || vars.size === 0) return;
|
|
173
|
+
const at = (address: string) => (path ? `${path}.${address}` : address);
|
|
174
|
+
for (const r of Array.isArray(mod.resources) ? mod.resources : []) {
|
|
175
|
+
if (!isObject(r)) continue;
|
|
176
|
+
const mode = String(r.mode ?? "managed");
|
|
177
|
+
const address = String(r.address ?? `${r.type}.${r.name}`);
|
|
178
|
+
if (readsVar(r.count_expression, vars) || readsVar(r.for_each_expression, vars)) driven.expansions.push(at(address));
|
|
179
|
+
const exprs = isObject(r.expressions) ? r.expressions : {};
|
|
180
|
+
const attrs = new Set(Object.entries(exprs).filter(([, e]) => readsVar(e, vars)).map(([k]) => k));
|
|
181
|
+
if (attrs.size > 0) driven.attributes.set(resourceKey(path, mode, String(r.type), String(r.name)), attrs);
|
|
182
|
+
}
|
|
183
|
+
for (const [name, c] of Object.entries(isObject(mod.module_calls) ? mod.module_calls : {})) {
|
|
184
|
+
if (!isObject(c)) continue;
|
|
185
|
+
if (readsVar(c.count_expression, vars) || readsVar(c.for_each_expression, vars)) driven.expansions.push(at(`module.${name}`));
|
|
186
|
+
const exprs = isObject(c.expressions) ? c.expressions : {};
|
|
187
|
+
const inputs = new Set(Object.entries(exprs).filter(([, e]) => readsVar(e, vars)).map(([k]) => k));
|
|
188
|
+
walkModule(c.module, at(`module.${name}`), inputs, driven);
|
|
189
|
+
}
|
|
190
|
+
if (path === "") {
|
|
191
|
+
for (const [name, o] of Object.entries(isObject(mod.outputs) ? mod.outputs : {})) {
|
|
192
|
+
if (isObject(o) && readsVar(o.expression, vars)) driven.outputs.add(name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const carriesMark = (v: unknown): boolean =>
|
|
198
|
+
typeof v === "string" ? v.includes(TERRAFORM_UNKNOWN_MARK) : Array.isArray(v) ? v.some(carriesMark) : isObject(v) ? Object.values(v).some(carriesMark) : false;
|
|
199
|
+
|
|
200
|
+
/** A `resource_changes` item's key in {@link Driven.attributes}: its module path without instance keys. */
|
|
201
|
+
function changeKey(rc: Json): string {
|
|
202
|
+
const modulePath = typeof rc.module_address === "string" ? rc.module_address.replace(/\[[^\]]*\]/g, "") : "";
|
|
203
|
+
return resourceKey(modulePath, String(rc.mode ?? "managed"), String(rc.type), String(rc.name));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What {@link markPlaceholderDriven} returns. */
|
|
207
|
+
export type MarkedPlan = { plan: unknown } | { refused: string };
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `planJson` (`show -json` of the placeholder plan) with every attribute and
|
|
211
|
+
* root output the stand-ins for `placeholders` drive marked unknown after
|
|
212
|
+
* apply, their planned values dropped. Returns why instead when a `count` or
|
|
213
|
+
* `for_each` reads a stand-in. The input is not changed.
|
|
214
|
+
*/
|
|
215
|
+
export function markPlaceholderDriven(planJson: unknown, placeholders: readonly string[]): MarkedPlan {
|
|
216
|
+
const plan = structuredClone(isObject(planJson) ? planJson : {}) as Json;
|
|
217
|
+
const driven: Driven = { attributes: new Map(), outputs: new Set(), expansions: [] };
|
|
218
|
+
const config = isObject(plan.configuration) ? plan.configuration : {};
|
|
219
|
+
walkModule(config.root_module, "", new Set(placeholders), driven);
|
|
220
|
+
if (driven.expansions.length > 0) {
|
|
221
|
+
return { refused: `the count or for_each of ${[...new Set(driven.expansions)].sort().join(", ")} reads a stand-in, so the preview cannot say how many instances there are` };
|
|
222
|
+
}
|
|
223
|
+
for (const r of Array.isArray(plan.resource_changes) ? plan.resource_changes : []) {
|
|
224
|
+
if (!isObject(r) || !isObject(r.change)) continue;
|
|
225
|
+
const change = r.change;
|
|
226
|
+
const after = isObject(change.after) ? change.after : undefined;
|
|
227
|
+
const attrs = new Set(driven.attributes.get(changeKey(r)) ?? []);
|
|
228
|
+
for (const [k, v] of Object.entries(after ?? {})) if (carriesMark(v)) attrs.add(k);
|
|
229
|
+
if (attrs.size === 0) continue;
|
|
230
|
+
const afterUnknown = isObject(change.after_unknown) ? change.after_unknown : {};
|
|
231
|
+
for (const k of attrs) {
|
|
232
|
+
afterUnknown[k] = true;
|
|
233
|
+
if (after) delete after[k];
|
|
234
|
+
}
|
|
235
|
+
change.after_unknown = afterUnknown;
|
|
236
|
+
}
|
|
237
|
+
const outputChanges = isObject(plan.output_changes) ? plan.output_changes : {};
|
|
238
|
+
const plannedOutputs = isObject(plan.planned_values) && isObject(plan.planned_values.outputs) ? plan.planned_values.outputs : {};
|
|
239
|
+
for (const [name, o] of Object.entries(outputChanges)) if (isObject(o) && carriesMark(o.after)) driven.outputs.add(name);
|
|
240
|
+
for (const [name, o] of Object.entries(plannedOutputs)) if (isObject(o) && carriesMark(o.value)) driven.outputs.add(name);
|
|
241
|
+
for (const name of driven.outputs) {
|
|
242
|
+
const o = isObject(outputChanges[name]) ? (outputChanges[name] as Json) : (outputChanges[name] = {} as Json);
|
|
243
|
+
delete o.after;
|
|
244
|
+
o.after_unknown = true;
|
|
245
|
+
delete plannedOutputs[name];
|
|
246
|
+
}
|
|
247
|
+
return { plan };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The hole a root keeps when its preview is not possible: the old reason, and why there is no preview. */
|
|
251
|
+
export function waitingHole(root: string, unresolved: readonly string[], why?: string): ChangeSetHole {
|
|
252
|
+
return {
|
|
253
|
+
address: root,
|
|
254
|
+
type: "terraform_root",
|
|
255
|
+
reason: `inputs from ${unresolved.join(", ")}; planned again once they apply${why ? `; no preview: ${why}` : ""}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composed runs (#4447) through the pull-request loop over a chain of three
|
|
3
|
+
* real Terraform roots, with and without `terraform.compose`: `chant
|
|
4
|
+
* components pr-plan` on the pull request and `chant components pr-apply` on
|
|
5
|
+
* the merge, each talking to a fake GitHub, as in `pr-loop.tofu.test.ts`.
|
|
6
|
+
* Every case runs tofu or terraform several times, so they sit in the e2e
|
|
7
|
+
* project. Skipped when neither binary is on the PATH.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
15
|
+
import type { ParsedArgs } from "@intentius/chant/cli/registry";
|
|
16
|
+
import type { PrReport } from "@intentius/chant/pr-loop";
|
|
17
|
+
import { buildGraphIr } from "@intentius/chant/graph-ir";
|
|
18
|
+
import { FakeGithub } from "@intentius/chant-test-utils/pr-loop-harness";
|
|
19
|
+
import { terraformPlugin } from "../plugin";
|
|
20
|
+
import { renderTerraformRoots } from "../hcl/roots";
|
|
21
|
+
|
|
22
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
function which(bin: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
execFileSync(bin, ["version"], { stdio: "ignore" });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const BINARY = which("tofu") ? "tofu" : which("terraform") ? "terraform" : undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The estate's graph node ids, read in process with the pair `chant graph
|
|
36
|
+
* --format ir` runs for terraform roots (`renderTerraformRoots`, then
|
|
37
|
+
* `buildGraphIr`), so pr-apply maps its stored change set without starting a
|
|
38
|
+
* chant child process. `change-set-graph.e2e.test.ts` holds these ids equal
|
|
39
|
+
* to the spawned `chant graph --format ir`'s.
|
|
40
|
+
*/
|
|
41
|
+
async function estateGraphIds(projectRoot: string): Promise<{ ids: string[] }> {
|
|
42
|
+
const config = JSON.parse(readFileSync(join(projectRoot, "chant.config.json"), "utf8")) as { terraform: { binary?: string; roots: Record<string, { dir: string }> } };
|
|
43
|
+
const rendered = await renderTerraformRoots({ projectRoot, roots: config.terraform.roots, ...(config.terraform.binary ? { binary: config.terraform.binary } : {}) });
|
|
44
|
+
return { ids: buildGraphIr(rendered.entities).nodes.map((n) => n.id) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const args = (overrides: Partial<ParsedArgs>): ParsedArgs => ({
|
|
48
|
+
command: "components",
|
|
49
|
+
path: ".",
|
|
50
|
+
format: "",
|
|
51
|
+
fix: false,
|
|
52
|
+
watch: false,
|
|
53
|
+
verbose: false,
|
|
54
|
+
help: false,
|
|
55
|
+
live: false,
|
|
56
|
+
env: "local",
|
|
57
|
+
noReleaseRecord: true,
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── Composed runs (#4447) ─────────────────────────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// Three roots in a chain: b reads a's id and c reads b's, through
|
|
64
|
+
// `stackOutput()` in `vars`. With `terraform.compose.enabled` the pull
|
|
65
|
+
// request plans them as one group (ws-107), so b and c are not holes, and the
|
|
66
|
+
// merge applies the three in one apply with no round in between. Without the
|
|
67
|
+
// setting the loop behaves as above: b is a hole and its round waits on an
|
|
68
|
+
// approval of its own.
|
|
69
|
+
|
|
70
|
+
const CHAIN: Record<string, string> = {
|
|
71
|
+
a: 'resource "terraform_data" "subnet" {\n input = "a-v1"\n}\n\noutput "id" {\n value = terraform_data.subnet.output\n}\n',
|
|
72
|
+
b: 'variable "a_id" {\n type = string\n}\n\nresource "terraform_data" "link" {\n input = "${var.a_id}/b"\n}\n\noutput "id" {\n value = terraform_data.link.output\n}\n',
|
|
73
|
+
c: 'variable "b_id" {\n type = string\n}\n\nresource "terraform_data" "edge" {\n input = "${var.b_id}/c"\n}\n\noutput "id" {\n value = terraform_data.edge.output\n}\n',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The chain's components; with `reader`, d, a shell step outside the group, writes c's id to `d.out`. */
|
|
77
|
+
const chainComponents = (reader: boolean) => `import { phase, stackOutput, type Component } from "@intentius/chant/components";
|
|
78
|
+
|
|
79
|
+
const apply = (root: string, vars?: Record<string, unknown>) =>
|
|
80
|
+
[phase("Apply", [{ kind: "terraform-apply", root, noRollback: "test roots with local state", vars: vars ?? {} }])];
|
|
81
|
+
|
|
82
|
+
export const a: Component = { name: "a", dependsOn: [], deploy: apply("a") };
|
|
83
|
+
export const b: Component = { name: "b", dependsOn: ["a"], deploy: apply("b", { a_id: stackOutput("a", "id") }) };
|
|
84
|
+
export const c: Component = { name: "c", dependsOn: ["b"], deploy: apply("c", { b_id: stackOutput("b", "id") }) };
|
|
85
|
+
${
|
|
86
|
+
reader
|
|
87
|
+
? `export const d: Component = {
|
|
88
|
+
name: "d",
|
|
89
|
+
dependsOn: ["c"],
|
|
90
|
+
deploy: [phase("Apply", [{ kind: "shell", cmd: "printenv C_ID > d.out", env: { C_ID: stackOutput("c", "id") }, reason: "reads c's applied id from outside the group" }])],
|
|
91
|
+
};
|
|
92
|
+
`
|
|
93
|
+
: ""
|
|
94
|
+
}`;
|
|
95
|
+
|
|
96
|
+
/** What a composed suite's tests reach. */
|
|
97
|
+
interface ChainLoop {
|
|
98
|
+
dir: string;
|
|
99
|
+
github: FakeGithub;
|
|
100
|
+
report(stage: "plan" | "apply"): PrReport;
|
|
101
|
+
state(root: string): string;
|
|
102
|
+
git(...a: string[]): string;
|
|
103
|
+
/** A pull request that rewrites root a's input to `version`. */
|
|
104
|
+
open(pr: number, version: string): Promise<PrReport>;
|
|
105
|
+
/** Merge it and run pr-apply. */
|
|
106
|
+
merge(pr: number): Promise<number>;
|
|
107
|
+
/** Run pr-apply again from its resume record, as after a round's approval. */
|
|
108
|
+
again(): Promise<number>;
|
|
109
|
+
approve(pr: number, gate: string, digest: string): Promise<void>;
|
|
110
|
+
/** Lock files, kept states and composed-root files left in the checkout. */
|
|
111
|
+
leftovers(): string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every path under `dir` (outside `node_modules` and `.git`) a composed run must not leave behind. */
|
|
115
|
+
function leftoversIn(dir: string): string[] {
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
const walk = (at: string): void => {
|
|
118
|
+
for (const e of readdirSync(at, { withFileTypes: true })) {
|
|
119
|
+
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
120
|
+
const path = join(at, e.name);
|
|
121
|
+
if (e.name === ".chant-compose" || e.name === "chant_compose.tf" || e.name.endsWith(".tflock") || e.name.endsWith(".chant-unwritten")) out.push(path.slice(dir.length + 1));
|
|
122
|
+
if (e.isDirectory() && e.name !== ".terraform") walk(path);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
walk(dir);
|
|
126
|
+
return out.sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function chainSuite(title: string, opts: { compose: boolean; files?: Record<string, string>; reader?: boolean }, tests: (loop: ChainLoop) => void): void {
|
|
130
|
+
describe.skipIf(!BINARY)(`${title} (${BINARY ?? "terraform"})`, () => {
|
|
131
|
+
const loop = {} as ChainLoop;
|
|
132
|
+
let dir = "";
|
|
133
|
+
let cwd = "";
|
|
134
|
+
let mergedFrom = "";
|
|
135
|
+
const env = { ...process.env };
|
|
136
|
+
const github = new FakeGithub();
|
|
137
|
+
const RESUME = ".chant/pr-resume/pr-apply.json";
|
|
138
|
+
const ctx = (overrides: Partial<ParsedArgs>) => ({ args: args(overrides), plugins: [terraformPlugin], serializers: [terraformPlugin.serializer] });
|
|
139
|
+
let handlers: typeof import("@intentius/chant/cli/handlers/pr");
|
|
140
|
+
let recordGateApproval: typeof import("@intentius/chant/cli/handlers/operator").recordGateApproval;
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-pr-compose-")));
|
|
144
|
+
const git = (...a: string[]) => execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim();
|
|
145
|
+
const write = (path: string, text: string) => {
|
|
146
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
147
|
+
writeFileSync(join(dir, path), text);
|
|
148
|
+
};
|
|
149
|
+
symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"));
|
|
150
|
+
for (const [name, text] of Object.entries(CHAIN)) write(`roots/${name}/main.tf`, text);
|
|
151
|
+
for (const [path, text] of Object.entries(opts.files ?? {})) write(path, text);
|
|
152
|
+
const terraform = {
|
|
153
|
+
binary: BINARY,
|
|
154
|
+
...(opts.compose ? { compose: { enabled: true } } : {}),
|
|
155
|
+
roots: Object.fromEntries(Object.keys(CHAIN).map((r) => [r, { dir: `roots/${r}` }])),
|
|
156
|
+
};
|
|
157
|
+
write("chant.config.json", JSON.stringify({ lexicons: ["terraform"], terraform }, null, 2));
|
|
158
|
+
write("chain.component.ts", chainComponents(opts.reader === true));
|
|
159
|
+
write(".gitignore", "node_modules\n.terraform\n.terraform.lock.hcl\n*.tfstate*\nchant.tfplan\n.chant\nd.out\n");
|
|
160
|
+
git("init", "-q", "-b", "main");
|
|
161
|
+
git("config", "user.email", "ci@example.test");
|
|
162
|
+
git("config", "user.name", "ci");
|
|
163
|
+
git("add", "-A");
|
|
164
|
+
git("commit", "-qm", "three roots");
|
|
165
|
+
|
|
166
|
+
cwd = process.cwd();
|
|
167
|
+
process.chdir(dir);
|
|
168
|
+
Object.assign(process.env, {
|
|
169
|
+
GITHUB_REPOSITORY: "acme/infra",
|
|
170
|
+
GITHUB_TOKEN: "t0k",
|
|
171
|
+
GITHUB_API_URL: "https://api.github.com",
|
|
172
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
173
|
+
GITHUB_RUN_ID: "1",
|
|
174
|
+
});
|
|
175
|
+
vi.stubGlobal("fetch", github.fetch);
|
|
176
|
+
handlers = await import("@intentius/chant/cli/handlers/pr");
|
|
177
|
+
(await import("@intentius/chant/lifecycle/gate-change-set")).setGraphIdReader(estateGraphIds);
|
|
178
|
+
({ recordGateApproval } = await import("@intentius/chant/cli/handlers/operator"));
|
|
179
|
+
|
|
180
|
+
// Every root applied once, root by root (a fan-out never composes), so each has state and outputs.
|
|
181
|
+
const { runComponentsFanOut } = await import("@intentius/chant/cli/handlers/fan-out");
|
|
182
|
+
write("all.json", JSON.stringify({ changed: ["a"] }));
|
|
183
|
+
expect(await runComponentsFanOut(ctx({ fromAffected: "all.json" }) as never)).toBe(0);
|
|
184
|
+
rmSync(join(dir, "all.json"));
|
|
185
|
+
|
|
186
|
+
Object.assign(loop, {
|
|
187
|
+
dir,
|
|
188
|
+
github,
|
|
189
|
+
git,
|
|
190
|
+
report: (stage: "plan" | "apply"): PrReport => JSON.parse(readFileSync(join(dir, ".chant", "pr", `pr-${stage}.json`), "utf8")),
|
|
191
|
+
state: (root: string) => readFileSync(join(dir, "roots", root, "terraform.tfstate"), "utf8"),
|
|
192
|
+
async open(pr: number, version: string) {
|
|
193
|
+
git("checkout", "-qb", `pr-${pr}`);
|
|
194
|
+
write("roots/a/main.tf", CHAIN.a!.replace("a-v1", version));
|
|
195
|
+
git("commit", "-qam", `a ${version}`);
|
|
196
|
+
expect(await handlers.runComponentsPrPlan(ctx({ base: "main", pr, forge: "github" }) as never)).toBe(0);
|
|
197
|
+
return loop.report("plan");
|
|
198
|
+
},
|
|
199
|
+
async merge(pr: number) {
|
|
200
|
+
git("checkout", "-q", "main");
|
|
201
|
+
mergedFrom = git("rev-parse", "HEAD");
|
|
202
|
+
git("merge", "-q", "--no-ff", `pr-${pr}`, "-m", `Merge pull request #${pr}`);
|
|
203
|
+
github.mergedBy[git("rev-parse", "HEAD")] = pr;
|
|
204
|
+
return handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never);
|
|
205
|
+
},
|
|
206
|
+
again: () => handlers.runComponentsPrApply(ctx({ base: mergedFrom, forge: "github", requireReview: true, resume: RESUME }) as never),
|
|
207
|
+
async approve(pr: number, gate: string, digest: string) {
|
|
208
|
+
github.reviews[pr] = [{ user: { login: "alice" }, state: "APPROVED" }];
|
|
209
|
+
expect((await recordGateApproval(`pr-${pr}`, gate, { actor: "github:alice", plan: digest })).ok).toBe(true);
|
|
210
|
+
},
|
|
211
|
+
leftovers: () => leftoversIn(dir),
|
|
212
|
+
} satisfies ChainLoop);
|
|
213
|
+
}, 240_000);
|
|
214
|
+
|
|
215
|
+
afterAll(async () => {
|
|
216
|
+
(await import("@intentius/chant/lifecycle/gate-change-set")).setGraphIdReader(undefined);
|
|
217
|
+
vi.unstubAllGlobals();
|
|
218
|
+
if (cwd) process.chdir(cwd);
|
|
219
|
+
for (const key of Object.keys(process.env)) if (!(key in env)) delete process.env[key];
|
|
220
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
tests(loop);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
chainSuite("with terraform.compose, three roots plan and apply as one group", { compose: true }, (loop) => {
|
|
228
|
+
test("one plan: b's read of a's new id is no hole, one digest binds the three, and the plan leaves nothing behind", async () => {
|
|
229
|
+
const plan = await loop.open(1, "a-v2");
|
|
230
|
+
|
|
231
|
+
expect(plan.status).toBe("planned");
|
|
232
|
+
expect(plan.selection).toMatchObject({ changed: ["a"], dependents: ["b", "c"] });
|
|
233
|
+
// The group is one unit in a's wave; the waves it emptied stay, so wave numbers keep their meaning.
|
|
234
|
+
expect(plan.selection.waves.filter((w) => w.length > 0)).toEqual([["a", "b", "c"]]);
|
|
235
|
+
expect(plan.groups).toEqual([expect.objectContaining({ capability: "terraform-apply", components: ["a", "b", "c"], wave: 1 })]);
|
|
236
|
+
expect(plan.groups![0]!.digest).toEqual(expect.any(String));
|
|
237
|
+
expect(plan.fallbacks ?? []).toEqual([]);
|
|
238
|
+
for (const m of ["b", "c"]) {
|
|
239
|
+
const member = plan.changeSet.members.find((x) => x.member === m)!;
|
|
240
|
+
expect(member.holes).toEqual([]);
|
|
241
|
+
// Ruling 4: the reads the group's apply computes are marked, so the change set never passes as empty.
|
|
242
|
+
expect(member.group?.digest).toBe(plan.groups![0]!.digest);
|
|
243
|
+
expect(member.group?.reads).toEqual([m === "b" ? "a.id" : "b.id"]);
|
|
244
|
+
}
|
|
245
|
+
expect(plan.members.map((m) => [m.member, m.status])).toEqual([
|
|
246
|
+
["a", "planned"],
|
|
247
|
+
["b", "planned"],
|
|
248
|
+
["c", "planned"],
|
|
249
|
+
]);
|
|
250
|
+
expect(plan.changeSet.digest).toBe(plan.digest);
|
|
251
|
+
expect(plan.approval).toEqual({ status: "pending" });
|
|
252
|
+
|
|
253
|
+
// The plan waits on its approval: no lock is held, and the composed root and every rewritten file are gone.
|
|
254
|
+
expect(loop.leftovers()).toEqual([]);
|
|
255
|
+
expect(loop.git("status", "--porcelain")).toBe("");
|
|
256
|
+
}, 240_000);
|
|
257
|
+
|
|
258
|
+
test("one approval and one apply: pr-apply exits 0 and no round gate is asked", async () => {
|
|
259
|
+
const plan = loop.report("plan");
|
|
260
|
+
await loop.approve(1, "pr-apply", plan.digest);
|
|
261
|
+
expect(await loop.merge(1)).toBe(0);
|
|
262
|
+
|
|
263
|
+
const applied = loop.report("apply");
|
|
264
|
+
expect(applied.digest).toBe(plan.digest);
|
|
265
|
+
expect(applied.status).toBe("applied");
|
|
266
|
+
expect(applied.rounds ?? []).toEqual([]);
|
|
267
|
+
expect(applied.members.map((m) => [m.member, m.status, m.groupOutcome])).toEqual([
|
|
268
|
+
["a", "applied", "applied"],
|
|
269
|
+
["b", "applied", "applied"],
|
|
270
|
+
["c", "applied", "applied"],
|
|
271
|
+
]);
|
|
272
|
+
// a's new id reached b, and b's reached c, inside the one apply.
|
|
273
|
+
expect(loop.state("a")).toContain('"a-v2"');
|
|
274
|
+
expect(loop.state("b")).toContain('"a-v2/b"');
|
|
275
|
+
expect(loop.state("c")).toContain('"a-v2/b/c"');
|
|
276
|
+
const note = loop.github.comments.find((c) => c.pr === 1)!.body;
|
|
277
|
+
expect(note).not.toContain("pr-apply-round-2");
|
|
278
|
+
expect(loop.github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "success", sha: applied.head });
|
|
279
|
+
expect(loop.leftovers()).toEqual([]);
|
|
280
|
+
expect(loop.git("status", "--porcelain")).toBe("");
|
|
281
|
+
}, 240_000);
|
|
282
|
+
|
|
283
|
+
test("a plan that changed after review is refused, nothing applies, and no lock or file is left", async () => {
|
|
284
|
+
const reviewed = await loop.open(2, "a-v3");
|
|
285
|
+
await loop.approve(2, "pr-apply", reviewed.digest);
|
|
286
|
+
// Pushed after the review.
|
|
287
|
+
writeFileSync(join(loop.dir, "roots/a/main.tf"), CHAIN.a!.replace("a-v1", "a-v4"));
|
|
288
|
+
loop.git("commit", "-qam", "a v4");
|
|
289
|
+
|
|
290
|
+
expect(await loop.merge(2)).toBe(3);
|
|
291
|
+
const refused = loop.report("apply");
|
|
292
|
+
expect(refused.status).toBe("refused");
|
|
293
|
+
expect(refused.refusal).toBe("plan-changed");
|
|
294
|
+
expect(loop.state("a")).toContain('"a-v2"');
|
|
295
|
+
expect(loop.state("c")).toContain('"a-v2/b/c"');
|
|
296
|
+
expect(loop.leftovers()).toEqual([]);
|
|
297
|
+
expect(loop.git("status", "--porcelain")).toBe("");
|
|
298
|
+
}, 240_000);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
chainSuite("without terraform.compose, the same three roots plan as on main", { compose: false }, (loop) => {
|
|
302
|
+
test("b is a hole, the setting's fallback is recorded and not printed, and b's round waits on its own approval", async () => {
|
|
303
|
+
const plan = await loop.open(1, "a-v2");
|
|
304
|
+
expect(plan.groups ?? []).toEqual([]);
|
|
305
|
+
expect((plan.fallbacks ?? []).map((f) => [f.component, f.kind])).toEqual([
|
|
306
|
+
["a", "off"],
|
|
307
|
+
["b", "off"],
|
|
308
|
+
["c", "off"],
|
|
309
|
+
]);
|
|
310
|
+
expect(plan.changeSet.members.find((m) => m.member === "b")?.holes).toEqual([
|
|
311
|
+
{ address: "b", type: "terraform_root", reason: "inputs from a; planned again once they apply" },
|
|
312
|
+
]);
|
|
313
|
+
expect(plan.members.find((m) => m.member === "b")?.unknownInputs).toEqual(["a.id"]);
|
|
314
|
+
const note = loop.github.comments.find((c) => c.pr === 1)!.body;
|
|
315
|
+
expect(note).not.toContain("terraform.compose");
|
|
316
|
+
|
|
317
|
+
await loop.approve(1, "pr-apply", plan.digest);
|
|
318
|
+
expect(await loop.merge(1)).toBe(3);
|
|
319
|
+
const waiting = loop.report("apply");
|
|
320
|
+
expect(waiting.status).toBe("waiting");
|
|
321
|
+
expect(waiting.members.find((m) => m.member === "a")?.status).toBe("applied");
|
|
322
|
+
const round = waiting.rounds![0]!;
|
|
323
|
+
expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", status: "waiting" });
|
|
324
|
+
expect(round.components).toContain("b");
|
|
325
|
+
expect(round.covered).toBeUndefined();
|
|
326
|
+
expect(loop.state("a")).toContain('"a-v2"');
|
|
327
|
+
expect(loop.state("b")).not.toContain("a-v2");
|
|
328
|
+
}, 240_000);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
chainSuite(
|
|
332
|
+
"with terraform.compose, a root #4438 refuses falls back and the rest group (#4419 ruling 1)",
|
|
333
|
+
{ compose: true, files: { "roots/c/paths.tf": 'locals {\n here = path.root\n}\n' } },
|
|
334
|
+
(loop) => {
|
|
335
|
+
test("a and b group; the report's fallbacks name c with its kind, and c plans in its own wave", async () => {
|
|
336
|
+
const plan = await loop.open(1, "a-v2");
|
|
337
|
+
expect(plan.groups?.map((g) => g.components)).toEqual([["a", "b"]]);
|
|
338
|
+
expect((plan.fallbacks ?? []).map((f) => [f.component, f.capability, f.kind])).toEqual([["c", "terraform-apply", "path"]]);
|
|
339
|
+
expect(plan.fallbacks![0]!.reason).toContain("`c` uses `path.root` or `path.cwd`");
|
|
340
|
+
expect(plan.changeSet.members.find((m) => m.member === "b")?.holes ?? []).toHaveLength(0);
|
|
341
|
+
expect(plan.selection.waves).toEqual([["a", "b"], [], ["c"]]);
|
|
342
|
+
expect(loop.leftovers()).toEqual([]);
|
|
343
|
+
}, 240_000);
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
chainSuite("with terraform.compose, a component outside the group reads c", { compose: true, reader: true }, (loop) => {
|
|
348
|
+
test("d gets c's applied output", async () => {
|
|
349
|
+
const plan = await loop.open(1, "a-v2");
|
|
350
|
+
expect(plan.groups?.map((g) => g.components)).toEqual([["a", "b", "c"]]);
|
|
351
|
+
await loop.approve(1, "pr-apply", plan.digest);
|
|
352
|
+
let code = await loop.merge(1);
|
|
353
|
+
// d read c's id before c applied: a round plans it once the group applied, and waits on an approval unless the first covers it.
|
|
354
|
+
if (code === 3) {
|
|
355
|
+
const round = loop.report("apply").rounds!.find((r) => r.status === "waiting")!;
|
|
356
|
+
await loop.approve(1, round.gate, round.digest);
|
|
357
|
+
code = await loop.again();
|
|
358
|
+
}
|
|
359
|
+
expect(code).toBe(0);
|
|
360
|
+
expect(loop.report("apply").members.find((m) => m.component === "d")?.status).toBe("applied");
|
|
361
|
+
expect(readFileSync(join(loop.dir, "d.out"), "utf8").trim()).toBe("a-v2/b/c");
|
|
362
|
+
}, 240_000);
|
|
363
|
+
});
|