@intentius/chant-lexicon-terraform 0.124.51 → 0.124.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/__fixtures__/pr-loop/estate.component.d.ts +7 -0
- package/dist/__fixtures__/pr-loop/estate.component.d.ts.map +1 -0
- package/dist/__fixtures__/pr-loop/estate.component.js +7 -0
- package/dist/__fixtures__/report/generate.d.ts +26 -0
- package/dist/__fixtures__/report/generate.d.ts.map +1 -0
- package/dist/__fixtures__/report/generate.js +58 -0
- package/dist/applied-state.d.ts +27 -0
- package/dist/applied-state.d.ts.map +1 -0
- package/dist/applied-state.js +44 -0
- package/dist/backend-config.d.ts +53 -0
- package/dist/backend-config.d.ts.map +1 -0
- package/dist/backend-config.js +136 -0
- package/dist/behaviour/kinds.d.ts.map +1 -1
- package/dist/behaviour/kinds.js +4 -0
- package/dist/behaviour/predict.d.ts.map +1 -1
- package/dist/behaviour/predict.js +6 -2
- package/dist/behaviour/reference-catalog.d.ts +16 -5
- package/dist/behaviour/reference-catalog.d.ts.map +1 -1
- package/dist/behaviour/reference-catalog.js +16 -5
- package/dist/behaviour/request.d.ts.map +1 -1
- package/dist/behaviour/request.js +3 -2
- package/dist/changed-roots.d.ts +6 -4
- package/dist/changed-roots.d.ts.map +1 -1
- package/dist/changed-roots.js +14 -18
- package/dist/commands.d.ts +5 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +87 -4
- package/dist/components/capability-plugin.d.ts +65 -3
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +197 -13
- package/dist/components/compose-group.d.ts +145 -0
- package/dist/components/compose-group.d.ts.map +1 -0
- package/dist/components/compose-group.js +407 -0
- package/dist/components/placeholder-preview.d.ts +78 -0
- package/dist/components/placeholder-preview.d.ts.map +1 -0
- package/dist/components/placeholder-preview.js +254 -0
- package/dist/compose/apply-events.d.ts +46 -0
- package/dist/compose/apply-events.d.ts.map +1 -0
- package/dist/compose/apply-events.js +77 -0
- package/dist/compose/estate.d.ts +194 -0
- package/dist/compose/estate.d.ts.map +1 -0
- package/dist/compose/estate.js +363 -0
- package/dist/compose/hcl.d.ts +99 -0
- package/dist/compose/hcl.d.ts.map +1 -0
- package/dist/compose/hcl.js +302 -0
- package/dist/compose/module-address.d.ts +24 -0
- package/dist/compose/module-address.d.ts.map +1 -0
- package/dist/compose/module-address.js +43 -0
- package/dist/compose/root.d.ts +127 -0
- package/dist/compose/root.d.ts.map +1 -0
- package/dist/compose/root.js +586 -0
- package/dist/compose/run.d.ts +183 -0
- package/dist/compose/run.d.ts.map +1 -0
- package/dist/compose/run.js +447 -0
- package/dist/compose/slice.d.ts +102 -0
- package/dist/compose/slice.d.ts.map +1 -0
- package/dist/compose/slice.js +249 -0
- package/dist/compose/state-address.d.ts +28 -0
- package/dist/compose/state-address.d.ts.map +1 -0
- package/dist/compose/state-address.js +154 -0
- package/dist/compose/state-merge.d.ts +52 -0
- package/dist/compose/state-merge.d.ts.map +1 -0
- package/dist/compose/state-merge.js +135 -0
- package/dist/compose.d.ts +179 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +482 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.js +7 -3
- package/dist/config.d.ts +59 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -9
- package/dist/describe-resources.d.ts +74 -12
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +374 -66
- package/dist/drift-read.d.ts +33 -0
- package/dist/drift-read.d.ts.map +1 -0
- package/dist/drift-read.js +243 -0
- package/dist/drift-record.d.ts +147 -0
- package/dist/drift-record.d.ts.map +1 -0
- package/dist/drift-record.js +383 -0
- package/dist/hcl/edges.d.ts.map +1 -1
- package/dist/hcl/edges.js +24 -1
- package/dist/hcl/files.d.ts +61 -0
- package/dist/hcl/files.d.ts.map +1 -0
- package/dist/hcl/files.js +221 -0
- package/dist/hcl/instances.d.ts +82 -0
- package/dist/hcl/instances.d.ts.map +1 -0
- package/dist/hcl/instances.js +390 -0
- package/dist/hcl/parse.d.ts +81 -9
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/parse.js +114 -16
- package/dist/hcl/roots.d.ts.map +1 -1
- package/dist/hcl/roots.js +5 -0
- package/dist/hcl/suppressions.d.ts.map +1 -1
- package/dist/hcl/suppressions.js +8 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -2
- package/dist/integrity.json +3 -3
- package/dist/live-plan-reasons.d.ts +21 -0
- package/dist/live-plan-reasons.d.ts.map +1 -0
- package/dist/live-plan-reasons.js +47 -0
- package/dist/lsp/option-keys.d.ts.map +1 -1
- package/dist/lsp/option-keys.js +2 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +6 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +5 -1
- package/dist/op/activities/live-detect.d.ts +4 -3
- package/dist/op/activities/live-detect.d.ts.map +1 -1
- package/dist/op/activities/live-detect.js +57 -32
- package/dist/op/activities/pin-rollout.d.ts +2 -0
- package/dist/op/activities/pin-rollout.d.ts.map +1 -1
- package/dist/op/activities/pin-rollout.js +1 -0
- package/dist/op/activities/terraform.d.ts +153 -5
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activities/terraform.js +205 -20
- package/dist/op/activity-contracts.d.ts +27 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/activity-contracts.js +19 -1
- package/dist/op/builders.d.ts +7 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +9 -0
- package/dist/pin/edit.d.ts.map +1 -1
- package/dist/pin/edit.js +35 -0
- package/dist/pin/rollout.d.ts +2 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +14 -11
- package/dist/plan-shape.d.ts +30 -0
- package/dist/plan-shape.d.ts.map +1 -0
- package/dist/plan-shape.js +125 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +32 -18
- package/dist/report.d.ts +216 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +666 -0
- package/dist/skills/chant-terraform.md +1 -1
- package/dist/state/azure-blob.d.ts +68 -0
- package/dist/state/azure-blob.d.ts.map +1 -0
- package/dist/state/azure-blob.js +201 -0
- package/dist/state/backend.d.ts +158 -0
- package/dist/state/backend.d.ts.map +1 -0
- package/dist/state/backend.js +406 -0
- package/dist/state/exec.d.ts +39 -0
- package/dist/state/exec.d.ts.map +1 -0
- package/dist/state/exec.js +80 -0
- package/dist/state/gcs.d.ts +62 -0
- package/dist/state/gcs.d.ts.map +1 -0
- package/dist/state/gcs.js +224 -0
- package/dist/state/index.d.ts +15 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +14 -0
- package/dist/state/locks.d.ts +105 -0
- package/dist/state/locks.d.ts.map +1 -0
- package/dist/state/locks.js +130 -0
- package/dist/state/object-store.d.ts +37 -0
- package/dist/state/object-store.d.ts.map +1 -0
- package/dist/state/object-store.js +20 -0
- package/dist/state/root-states.d.ts +131 -0
- package/dist/state/root-states.d.ts.map +1 -0
- package/dist/state/root-states.js +241 -0
- package/dist/state/s3.d.ts +68 -0
- package/dist/state/s3.d.ts.map +1 -0
- package/dist/state/s3.js +170 -0
- package/dist/state/store.d.ts +51 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +185 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +2 -1
- package/package.json +7 -3
- package/src/__fixtures__/ACCEPTANCE.md +69 -7
- package/src/__fixtures__/compose/apply-failed-in-app.jsonl +12 -0
- package/src/__fixtures__/compose/state-app.json +66 -0
- package/src/__fixtures__/compose/state-edge.json +40 -0
- package/src/__fixtures__/compose/state-net.json +58 -0
- package/src/__fixtures__/compose/three-app.plan.json +184 -0
- package/src/__fixtures__/compose/three-composed.plan.json +509 -0
- package/src/__fixtures__/compose/three-edge.plan.json +144 -0
- package/src/__fixtures__/compose/three-net.plan.json +174 -0
- package/src/__fixtures__/compose/unknown-composed.plan.json +228 -0
- package/src/__fixtures__/compose/working-after-apply.json +145 -0
- package/src/__fixtures__/config-blocks/app/main.tf +61 -0
- package/src/__fixtures__/config-blocks/app/modules/network/main.tf +4 -0
- package/src/__fixtures__/config-blocks/app-json/main.tf.json +39 -0
- package/src/__fixtures__/config-files/shadow/main.tf +3 -0
- package/src/__fixtures__/config-files/shadow/main.tofu +3 -0
- package/src/__fixtures__/config-files/shadow/other.tf +3 -0
- package/src/__fixtures__/config-files/tf/main.tf +11 -0
- package/src/__fixtures__/config-files/tf-json/main.tf.json +18 -0
- package/src/__fixtures__/config-files/tofu/main.tofu +11 -0
- package/src/__fixtures__/config-files/tofu-json/main.tofu.json +18 -0
- package/src/__fixtures__/config-files/tofu-only/main.tofu +3 -0
- package/src/__fixtures__/drift-record/README.md +11 -0
- package/src/__fixtures__/drift-record/live-plan-sections.json +27 -0
- package/src/__fixtures__/instance-forms/app/main.tf +110 -0
- package/src/__fixtures__/instance-forms/app/modules/svc/main.tf +3 -0
- package/src/__fixtures__/instance-vars/app/main.tf +52 -0
- package/src/__fixtures__/instance-vars/app/modules/app/main.tf +12 -0
- package/src/__fixtures__/instance-vars/app/modules/edge/main.tf +3 -0
- package/src/__fixtures__/instance-vars/app/terraform.tfvars +1 -0
- package/src/__fixtures__/live-module-keyed/live-graph.json +259 -0
- package/src/__fixtures__/live-module-keyed/live-plan.json +87 -0
- package/src/__fixtures__/live-module-keyed/root/estate.chdf.hcl +1 -0
- package/src/__fixtures__/live-module-keyed/root/main.tf +21 -0
- package/src/__fixtures__/live-module-keyed/root/modules/app/main.tf +17 -0
- package/src/__fixtures__/live-module-keyed/root/modules/edge/main.tf +7 -0
- package/src/__fixtures__/live-module-keyed/root/versions.tf +30 -0
- package/src/__fixtures__/live-module-keyed/show-state.json +146 -0
- package/src/__fixtures__/pr-loop/README.md +50 -0
- package/src/__fixtures__/pr-loop/chant.config.json +13 -0
- package/src/__fixtures__/pr-loop/estate.component.ts +10 -0
- package/src/__fixtures__/pr-loop/roots/a/main.tf +11 -0
- package/src/__fixtures__/pr-loop/roots/app/main.tf +19 -0
- package/src/__fixtures__/pr-loop/roots/b/main.tf +7 -0
- package/src/__fixtures__/pr-loop/roots/dns/main.tf +3 -0
- package/src/__fixtures__/pr-loop/roots/net/main.tf +7 -0
- package/src/__fixtures__/report/applied.show.json +51 -0
- package/src/__fixtures__/report/generate.ts +63 -0
- package/src/__fixtures__/report/plan.show.json +200 -0
- package/src/__fixtures__/report/tf-apply/change-set.json +170 -0
- package/src/__fixtures__/report/tf-apply/report.json +338 -0
- package/src/__fixtures__/report/tf-plan/change-set.json +170 -0
- package/src/__fixtures__/report/tf-plan/report.json +320 -0
- package/src/__fixtures__/three-cloud-azure.graph.json +290 -0
- package/src/applied-state.ts +52 -0
- package/src/backend-config.test.ts +105 -0
- package/src/backend-config.ts +133 -0
- package/src/behaviour/kinds.ts +4 -0
- package/src/behaviour/predict.ts +5 -2
- package/src/behaviour/reference-catalog.plugin.test.ts +65 -0
- package/src/behaviour/reference-catalog.ts +16 -5
- package/src/behaviour/request.ts +3 -2
- package/src/change-set-instances.test.ts +98 -0
- package/src/changed-roots.ts +14 -16
- package/src/commands.ts +84 -4
- package/src/components/capability-plugin.test.ts +172 -5
- package/src/components/capability-plugin.ts +212 -11
- package/src/components/change-set-graph.e2e.test.ts +11 -5
- package/src/components/compose-group.test.ts +486 -0
- package/src/components/compose-group.ts +489 -0
- package/src/components/demo-composed-roots.e2e.test.ts +440 -0
- package/src/components/member-outputs.test.ts +76 -0
- package/src/components/placeholder-preview.test.ts +147 -0
- package/src/components/placeholder-preview.ts +257 -0
- package/src/components/pr-loop-compose.tofu.e2e.test.ts +363 -0
- package/src/components/pr-loop.tofu.test.ts +112 -56
- package/src/compose/apply-events.test.ts +62 -0
- package/src/compose/apply-events.ts +100 -0
- package/src/compose/estate.choudoufu.e2e.test.ts +112 -0
- package/src/compose/estate.test.ts +421 -0
- package/src/compose/estate.ts +443 -0
- package/src/compose/hcl.ts +311 -0
- package/src/compose/module-address.ts +49 -0
- package/src/compose/root.test.ts +416 -0
- package/src/compose/root.ts +592 -0
- package/src/compose/run.test.ts +440 -0
- package/src/compose/run.tofu.test.ts +176 -0
- package/src/compose/run.ts +527 -0
- package/src/compose/slice.test.ts +184 -0
- package/src/compose/slice.ts +290 -0
- package/src/compose/state-address.ts +157 -0
- package/src/compose/state-merge.test.ts +131 -0
- package/src/compose/state-merge.ts +148 -0
- package/src/compose.test.ts +248 -0
- package/src/compose.ts +627 -0
- package/src/composites/terraform-apply-op.test.ts +9 -0
- package/src/composites/terraform-apply-op.ts +7 -3
- package/src/composites/terraform-pin-rollout-op.test.ts +2 -2
- package/src/config-file-listers.test.ts +265 -0
- package/src/config.ts +44 -9
- package/src/describe-resources.engine-facts.test.ts +412 -0
- package/src/describe-resources.live-module-keyed.test.ts +290 -0
- package/src/describe-resources.ts +416 -70
- package/src/drift-read.ts +284 -0
- package/src/drift-record.test.ts +223 -0
- package/src/drift-record.ts +455 -0
- package/src/hcl/config-blocks.test.ts +174 -0
- package/src/hcl/edges.ts +21 -1
- package/src/hcl/files.ts +234 -0
- package/src/hcl/instance-forms.test.ts +94 -0
- package/src/hcl/instances.test.ts +137 -0
- package/src/hcl/instances.ts +422 -0
- package/src/hcl/parse.test.ts +139 -1
- package/src/hcl/parse.ts +149 -16
- package/src/hcl/roots.ts +6 -0
- package/src/hcl/suppressions.ts +8 -2
- package/src/index.ts +76 -0
- package/src/live-plan-reasons.ts +54 -0
- package/src/lsp/completions.test.ts +1 -1
- package/src/lsp/option-keys.ts +2 -0
- package/src/op/activities/choudoufu-plan-set-op.test.ts +111 -0
- package/src/op/activities/index.ts +9 -0
- package/src/op/activities/live-detect.ts +55 -26
- package/src/op/activities/pin-rollout.ts +3 -0
- package/src/op/activities/registry.test.ts +2 -1
- package/src/op/activities/terraform.test.ts +55 -0
- package/src/op/activities/terraform.ts +266 -22
- package/src/op/activity-contracts.test.ts +1 -0
- package/src/op/activity-contracts.ts +24 -1
- package/src/op/builders.ts +14 -0
- package/src/pin/bundle.test.ts +2 -0
- package/src/pin/edit.ts +30 -0
- package/src/pin/rollout.ts +16 -11
- package/src/plan-shape.test.ts +148 -0
- package/src/plan-shape.ts +128 -0
- package/src/plugin.ts +33 -19
- package/src/report.test.ts +423 -0
- package/src/report.ts +752 -0
- package/src/signal-only-root.test.ts +72 -0
- package/src/skills/chant-terraform.md +1 -1
- package/src/state/azure-blob.ts +223 -0
- package/src/state/backend-verdict.test.ts +187 -0
- package/src/state/backend.test.ts +127 -0
- package/src/state/backend.ts +488 -0
- package/src/state/exec.ts +111 -0
- package/src/state/gcs.ts +254 -0
- package/src/state/index.ts +15 -0
- package/src/state/locks.test.ts +84 -0
- package/src/state/locks.ts +201 -0
- package/src/state/object-store.ts +35 -0
- package/src/state/root-states-floci.e2e.test.ts +66 -0
- package/src/state/root-states.test.ts +462 -0
- package/src/state/root-states.ts +309 -0
- package/src/state/s3.ts +193 -0
- package/src/state/store.ts +225 -0
- package/src/terragrunt/affected.ts +2 -1
- package/src/workspace-kinds.test.ts +4 -17
- package/workspace-kinds.json +1 -24
- package/dist/workspace-member.d.ts +0 -30
- package/dist/workspace-member.d.ts.map +0 -1
- package/dist/workspace-member.js +0 -91
- package/schemas/pulumi.schema.json +0 -34
- package/src/testdata/pulumi-member/Pulumi.yaml +0 -2
- package/src/testdata/pulumi-member/stack-export.json +0 -53
- package/src/testdata/pulumi-unexported/Pulumi.yaml +0 -2
- package/src/workspace-member.test.ts +0 -89
- package/src/workspace-member.ts +0 -126
package/src/report.ts
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant terraform report`: a run report from `show -json` of a saved plan, in
|
|
3
|
+
* the `terragucci.report/v1` shape that behold's kiosk reads, for pipelines
|
|
4
|
+
* that run plain `terraform`, `terragrunt`, `atmos` or `choudoufu` and have no
|
|
5
|
+
* terragucci in them.
|
|
6
|
+
*
|
|
7
|
+
* It covers one composed run: a root whose only content is `module` calls
|
|
8
|
+
* (choudoufu's composed root, chant#4419), planned and applied as one. Each
|
|
9
|
+
* top-level module becomes one root of the report, with its part of the plan
|
|
10
|
+
* taken as if it had planned alone (`module.<m>.` off every address), the way
|
|
11
|
+
* terragucci's own composed run reports it.
|
|
12
|
+
*
|
|
13
|
+
* What it writes is the part of the v1 contract a reader needs to draw the
|
|
14
|
+
* run: roots, their changes with before and after values, "known after
|
|
15
|
+
* apply" for values the plan cannot know, one composed wave, the totals,
|
|
16
|
+
* groups and named destroys, the index row, and each root's `reads`: the
|
|
17
|
+
* `data "terraform_estate_outputs"` blocks that read another module's estate,
|
|
18
|
+
* and, in an Atmos repo, the `!terraform.output` calls in its stack manifests,
|
|
19
|
+
* which behold draws as the lines between roots. The estate of each module
|
|
20
|
+
* comes from the `live` block in its directory (`<repo>/<root-dir>/<module>`),
|
|
21
|
+
* since the plan holds none. After an apply, a second
|
|
22
|
+
* `show -json` (of a plan made once the apply finished, whose `prior_state`
|
|
23
|
+
* holds the state) fills in the values the plan left unknown. It writes no
|
|
24
|
+
* report.html, no plan files, no policy, cost or approval fields.
|
|
25
|
+
*
|
|
26
|
+
* The change set is {@link terraformChangeSetPart}'s, and the grouping is
|
|
27
|
+
* chant's `groupChangeSet`, so a report here and one from terragucci agree on
|
|
28
|
+
* what changed.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { changeSetDigest, composeChangeSet, type ChangeSetAction, type ChangeSetDocument, type ChangeSetAttribute, type ChangeSetEntry, type ChangeSetPlanner } from "@intentius/chant/change-set";
|
|
34
|
+
import { groupChangeSet } from "@intentius/chant/plan-summary";
|
|
35
|
+
import { plannerForBinary, terraformChangeSetPart } from "./change-set";
|
|
36
|
+
import { moduleChanges, stripModule, topModule } from "./compose/module-address";
|
|
37
|
+
import { stateResources } from "./applied-state";
|
|
38
|
+
import { isJsonConfigFile, jsonLiveBlocks, listConfigFiles } from "./hcl/files";
|
|
39
|
+
|
|
40
|
+
export { topModule } from "./compose/module-address";
|
|
41
|
+
|
|
42
|
+
type Json = Record<string, unknown>;
|
|
43
|
+
const isObject = (v: unknown): v is Json => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
44
|
+
|
|
45
|
+
export const REPORT_SCHEMA = "terragucci.report/v1";
|
|
46
|
+
export const INDEX_SCHEMA = "terragucci.report-index/v1";
|
|
47
|
+
/** The minor of the v1 contract the report is written to: `compose.estate` is minor 48. */
|
|
48
|
+
export const REPORT_MINOR = 48;
|
|
49
|
+
/** What terragucci puts in place of a redacted value; the report carries no values it would need to redact. */
|
|
50
|
+
export const REDACTED = "(sensitive, redacted by terragucci)";
|
|
51
|
+
|
|
52
|
+
export type ReportStage = "tf-plan" | "tf-apply";
|
|
53
|
+
|
|
54
|
+
export interface RunFacts {
|
|
55
|
+
/** `<host>/<path>` of the repo, as terragucci names a project. */
|
|
56
|
+
project: string;
|
|
57
|
+
commit: string;
|
|
58
|
+
job_url?: string;
|
|
59
|
+
commit_url?: string;
|
|
60
|
+
pull_request?: string;
|
|
61
|
+
pull_request_url?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The run's facts from a Forgejo, Gitea or GitHub Actions environment
|
|
66
|
+
* (`GITHUB_REPOSITORY`, `GITHUB_SERVER_URL`, `GITHUB_SHA`, `GITHUB_RUN_ID`,
|
|
67
|
+
* which Forgejo sets too). `project` and `commit` override it; `pullRequest`
|
|
68
|
+
* is the number, when the run has one.
|
|
69
|
+
*/
|
|
70
|
+
export function runFactsFromEnv(env: NodeJS.ProcessEnv, over: { pullRequest?: string; project?: string; commit?: string } = {}): RunFacts {
|
|
71
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
72
|
+
const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/+$/, "");
|
|
73
|
+
const commit = over.commit ?? env.GITHUB_SHA;
|
|
74
|
+
const project = over.project ?? (repo ? `${new URL(server).host}/${repo}` : undefined);
|
|
75
|
+
if (!project || !commit) throw new Error("GITHUB_REPOSITORY and GITHUB_SHA are not set; pass --project and --commit");
|
|
76
|
+
const web = repo ? `${server}/${repo}` : undefined;
|
|
77
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
78
|
+
const number = over.pullRequest && /^\d+$/.test(over.pullRequest.trim()) ? over.pullRequest.trim() : undefined;
|
|
79
|
+
return {
|
|
80
|
+
project,
|
|
81
|
+
commit,
|
|
82
|
+
...(web && env.GITHUB_RUN_ID ? { job_url: `${web}/actions/runs/${env.GITHUB_RUN_ID}` } : {}),
|
|
83
|
+
...(web ? { commit_url: `${web}/commit/${commit}` } : {}),
|
|
84
|
+
...(web && number ? { pull_request: number, pull_request_url: `${web}/${forgejo ? "pulls" : "pull"}/${number}` } : number ? { pull_request: number } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TerraformReportInput {
|
|
89
|
+
stage: ReportStage;
|
|
90
|
+
/** `show -json` of the saved plan. */
|
|
91
|
+
plan: unknown;
|
|
92
|
+
/** tf-apply: `show -json` of a plan made after the apply. Its `prior_state` holds the applied values. */
|
|
93
|
+
applied?: unknown;
|
|
94
|
+
run: RunFacts;
|
|
95
|
+
/** The binary that planned, by name or path. Default `terraform`. */
|
|
96
|
+
binary?: string;
|
|
97
|
+
/** The binary's version, when known. */
|
|
98
|
+
binaryVersion?: string;
|
|
99
|
+
/** Where each root lives in the repo: the root of module `network` is `<rootDir>/network`. Default: the module's name. */
|
|
100
|
+
rootDir?: string;
|
|
101
|
+
/** The path of the composed root itself, which holds any resource declared outside a module. Default: `rootDir`, else `.`. */
|
|
102
|
+
selfRoot?: string;
|
|
103
|
+
/**
|
|
104
|
+
* The estate each module's directory names in its `live` block, by module:
|
|
105
|
+
* what ties a `terraform_estate_outputs` read to the module that owns the
|
|
106
|
+
* estate. The plan carries no `live` block, so {@link moduleEstates} reads it
|
|
107
|
+
* from the directories. A read of an estate none of these names has no edge.
|
|
108
|
+
*/
|
|
109
|
+
estates?: Record<string, string>;
|
|
110
|
+
/**
|
|
111
|
+
* What each module reads off the others' outputs through the stack that
|
|
112
|
+
* wires them, by module: Atmos's `!terraform.output` ({@link atmosStackReads}).
|
|
113
|
+
* Atmos resolves those into plain variables before the plan, so the plan
|
|
114
|
+
* holds no trace of them. A module already read through an estate data
|
|
115
|
+
* block is not read twice.
|
|
116
|
+
*/
|
|
117
|
+
componentReads?: ComponentReads;
|
|
118
|
+
started?: string;
|
|
119
|
+
finished: string;
|
|
120
|
+
runtime?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface IndexRow {
|
|
124
|
+
project: string;
|
|
125
|
+
commit: string;
|
|
126
|
+
stage: ReportStage;
|
|
127
|
+
finished: string;
|
|
128
|
+
path: string;
|
|
129
|
+
roots: number;
|
|
130
|
+
groups: number;
|
|
131
|
+
totals: { create: number; update: number; replace: number; delete: number };
|
|
132
|
+
refused: number;
|
|
133
|
+
failed: number;
|
|
134
|
+
changed: number;
|
|
135
|
+
destroys: string[];
|
|
136
|
+
applied?: string;
|
|
137
|
+
commit_url?: string;
|
|
138
|
+
pull_request?: string;
|
|
139
|
+
pull_request_url?: string;
|
|
140
|
+
job_url?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Slicing the composed plan into one plan per module.
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** The plan's `resource_changes` that belong to module `m` (undefined: the root's own), each with the module taken off its address. */
|
|
148
|
+
export function slicePlan(plan: unknown, m: string | undefined): Json {
|
|
149
|
+
const p = isObject(plan) ? plan : {};
|
|
150
|
+
return { ...(p.errored === true ? { errored: true } : {}), resource_changes: moduleChanges(p.resource_changes, m) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Applied values: the state after the apply gives the values the plan left unknown,
|
|
155
|
+
// read through `./applied-state`, as the composed estate run reads them.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/** Whether a sensitivity map marks anything under it `true`. */
|
|
159
|
+
function marks(v: unknown): boolean {
|
|
160
|
+
if (v === true) return true;
|
|
161
|
+
if (Array.isArray(v)) return v.some(marks);
|
|
162
|
+
if (isObject(v)) return Object.values(v).some(marks);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Each unknown attribute given the value the state holds. One the state lacks stays unknown; a sensitive one stays `sensitive`. */
|
|
167
|
+
function resolveUnknowns(attributes: ChangeSetAttribute[], res: { values: Json; sensitive: Json } | undefined): ChangeSetAttribute[] {
|
|
168
|
+
if (!res || !attributes.some((a) => a.unknown)) return attributes;
|
|
169
|
+
return attributes.map((a) => {
|
|
170
|
+
if (!a.unknown) return a;
|
|
171
|
+
const { unknown: _u, before: _b, after: _a, ...rest } = a;
|
|
172
|
+
if (a.sensitive || marks(res.sensitive[a.path])) return { ...rest, sensitive: true as const };
|
|
173
|
+
const value = res.values[a.path];
|
|
174
|
+
if (value === undefined) return a;
|
|
175
|
+
return { ...rest, ...(a.before !== undefined ? { before: a.before } : {}), after: value };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Reads: `data "terraform_estate_outputs"` blocks between the composed modules.
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** One `read` of the report schema's `reads`, as a composed run writes it. */
|
|
184
|
+
export interface ReportRead {
|
|
185
|
+
upstream: string;
|
|
186
|
+
data: string;
|
|
187
|
+
outputs: "planned";
|
|
188
|
+
unknown?: string[];
|
|
189
|
+
/** Why the read is there, when no data block names it: Atmos's `!terraform.output` and the outputs it reads. */
|
|
190
|
+
why?: string;
|
|
191
|
+
composed: true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The estate named by the first `live { estate = "..." }` block in a
|
|
196
|
+
* directory's configuration files, in filename order, or undefined. A `live`
|
|
197
|
+
* block takes effect only under choudoufu, so the files are the ones
|
|
198
|
+
* choudoufu reads (`.tf`, `.tf.json`, `.tofu` and `.tofu.json`, chant#4457)
|
|
199
|
+
* unless `binary` names another. A JSON-syntax file's `live` block is the one
|
|
200
|
+
* nested in its `terraform` block (chant#4504).
|
|
201
|
+
*/
|
|
202
|
+
export function estateOfDir(dir: string, binary = "choudoufu"): string | undefined {
|
|
203
|
+
let files: string[];
|
|
204
|
+
try {
|
|
205
|
+
files = listConfigFiles(dir, binary);
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
for (const f of files) {
|
|
210
|
+
if (isJsonConfigFile(f)) {
|
|
211
|
+
const estate = jsonLiveBlocks(readFileSync(join(dir, f), "utf8")).map((b) => b.estate).find((e): e is string => typeof e === "string" && e !== "");
|
|
212
|
+
if (estate) return estate;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const text = readFileSync(join(dir, f), "utf8").replace(/^\s*(#|\/\/).*$/gm, "");
|
|
216
|
+
const live = /\blive\s*\{/.exec(text);
|
|
217
|
+
if (!live) continue;
|
|
218
|
+
const m = /\bestate\s*=\s*"([^"]+)"/.exec(text.slice(live.index + live[0].length));
|
|
219
|
+
if (m) return m[1];
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Each module's estate, read from `<repo>/<rootDir>/<module>`. A module whose directory has no `live` block is left out. */
|
|
225
|
+
export function moduleEstates(repo: string, rootDir: string, modules: readonly string[]): Record<string, string> {
|
|
226
|
+
const out: Record<string, string> = {};
|
|
227
|
+
for (const m of modules) {
|
|
228
|
+
const dir = join(repo, rootDir, m);
|
|
229
|
+
const estate = existsSync(dir) ? estateOfDir(dir) : undefined;
|
|
230
|
+
if (estate) out[m] = estate;
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function anyUnknown(v: unknown): boolean {
|
|
236
|
+
if (v === true) return true;
|
|
237
|
+
if (Array.isArray(v)) return v.some(anyUnknown);
|
|
238
|
+
if (isObject(v)) return Object.values(v).some(anyUnknown);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The module's own configuration (`configuration.root_module.module_calls.<m>.module`), or undefined. */
|
|
243
|
+
function moduleConfig(plan: Json, m: string): Json | undefined {
|
|
244
|
+
const root = isObject(plan.configuration) && isObject(plan.configuration.root_module) ? plan.configuration.root_module : {};
|
|
245
|
+
const call = isObject(root.module_calls) ? root.module_calls[m] : undefined;
|
|
246
|
+
return isObject(call) && isObject(call.module) ? call.module : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every `references` string under an expression or a configuration. */
|
|
250
|
+
function references(v: unknown, out: string[] = []): string[] {
|
|
251
|
+
if (Array.isArray(v)) for (const x of v) references(x, out);
|
|
252
|
+
else if (isObject(v)) {
|
|
253
|
+
for (const [k, x] of Object.entries(v)) {
|
|
254
|
+
if (k === "references" && Array.isArray(x)) {
|
|
255
|
+
for (const r of x) if (typeof r === "string") out.push(r);
|
|
256
|
+
} else references(x, out);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The outputs of module `u` that the plan cannot know. Two sources, joined:
|
|
264
|
+
* the composed root's own planned outputs (`<u>__<name>`, as terragucci's
|
|
265
|
+
* composed root writes them), and each output of `u`'s configuration whose
|
|
266
|
+
* expression refers to an attribute of a resource the plan leaves unknown.
|
|
267
|
+
*/
|
|
268
|
+
function unknownOutputsOf(plan: Json, u: string): Set<string> {
|
|
269
|
+
const out = new Set<string>();
|
|
270
|
+
const prefix = `${u}__`;
|
|
271
|
+
if (isObject(plan.output_changes)) {
|
|
272
|
+
for (const [k, c] of Object.entries(plan.output_changes)) if (k.startsWith(prefix) && isObject(c) && anyUnknown(c.after_unknown)) out.add(k.slice(prefix.length));
|
|
273
|
+
} else if (isObject(plan.planned_values) && isObject(plan.planned_values.outputs)) {
|
|
274
|
+
for (const [k, o] of Object.entries(plan.planned_values.outputs)) if (k.startsWith(prefix) && isObject(o) && !("value" in o)) out.add(k.slice(prefix.length));
|
|
275
|
+
}
|
|
276
|
+
const cfg = moduleConfig(plan, u);
|
|
277
|
+
const outputs = cfg && isObject(cfg.outputs) ? cfg.outputs : {};
|
|
278
|
+
const changes = (Array.isArray(plan.resource_changes) ? plan.resource_changes : []).filter((r): r is Json => isObject(r) && topModule(String(r.module_address ?? r.address ?? "")) === u);
|
|
279
|
+
for (const [name, o] of Object.entries(outputs)) {
|
|
280
|
+
for (const ref of references(o)) {
|
|
281
|
+
const parts = ref.split(".");
|
|
282
|
+
const data = parts[0] === "data";
|
|
283
|
+
const [type, rname, attr] = data ? [parts[1], parts[2], parts[3]] : [parts[0], parts[1], parts[2]];
|
|
284
|
+
if (!type || !rname) continue;
|
|
285
|
+
const unprefixed = (c: Json): string => stripModule(String(c.address)).replace(/\[[^\]]*\]$/, "");
|
|
286
|
+
for (const c of changes) {
|
|
287
|
+
if (unprefixed(c) !== `${data ? "data." : ""}${type}.${rname}`) continue;
|
|
288
|
+
const after = isObject(c.change) ? c.change.after_unknown : undefined;
|
|
289
|
+
if (attr === undefined ? anyUnknown(after) : isObject(after) && anyUnknown(after[attr])) out.add(name);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ESTATE_READ_TYPE = "terraform_estate_outputs";
|
|
297
|
+
|
|
298
|
+
/** The names a module reads off `data.terraform_estate_outputs.<name>.values`, or undefined when it reads the whole object. */
|
|
299
|
+
function valuesRead(refs: readonly string[], name: string): Set<string> | undefined {
|
|
300
|
+
const re = new RegExp(`^data\\.${ESTATE_READ_TYPE}\\.${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.values\\.([A-Za-z_][\\w-]*)`);
|
|
301
|
+
const read = new Set<string>();
|
|
302
|
+
for (const r of refs) {
|
|
303
|
+
const m = re.exec(r);
|
|
304
|
+
if (m) read.add(m[1]!);
|
|
305
|
+
}
|
|
306
|
+
return read.size > 0 ? read : undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Module `m`'s reads of another module's estate, as terragucci writes them for
|
|
311
|
+
* a composed run: `{ upstream, data, outputs: "planned", unknown?, composed }`.
|
|
312
|
+
* `unknown` names the outputs it reads that the plan cannot know. A read of an
|
|
313
|
+
* estate no other module owns has no edge and is left out.
|
|
314
|
+
*/
|
|
315
|
+
export function estateReads(plan: Json, m: string, estates: Readonly<Record<string, string>>, rootOf: (m: string | undefined) => string): ReportRead[] {
|
|
316
|
+
const cfg = moduleConfig(plan, m);
|
|
317
|
+
if (!cfg) return [];
|
|
318
|
+
const refs = references(cfg);
|
|
319
|
+
const reads: ReportRead[] = [];
|
|
320
|
+
for (const r of Array.isArray(cfg.resources) ? cfg.resources : []) {
|
|
321
|
+
if (!isObject(r) || r.mode !== "data" || r.type !== ESTATE_READ_TYPE || typeof r.name !== "string") continue;
|
|
322
|
+
const expr = isObject(r.expressions) && isObject(r.expressions.estate) ? r.expressions.estate : undefined;
|
|
323
|
+
const estate = expr && typeof expr.constant_value === "string" ? expr.constant_value : undefined;
|
|
324
|
+
if (estate === undefined) continue;
|
|
325
|
+
const upstream = Object.keys(estates).sort().find((o) => o !== m && estates[o] === estate);
|
|
326
|
+
if (upstream === undefined) continue;
|
|
327
|
+
const read = valuesRead(refs, r.name);
|
|
328
|
+
const unknown = [...unknownOutputsOf(plan, upstream)].filter((n) => read === undefined || read.has(n)).sort();
|
|
329
|
+
reads.push({ upstream: rootOf(upstream), data: r.name, outputs: "planned", ...(unknown.length ? { unknown } : {}), composed: true });
|
|
330
|
+
}
|
|
331
|
+
return reads;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Reads: Atmos `!terraform.output` between the stack's components.
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/** What each component reads off another with `!terraform.output`: reader -> upstream -> output names. */
|
|
339
|
+
export type ComponentReads = Record<string, Record<string, string[]>>;
|
|
340
|
+
|
|
341
|
+
const unquote = (s: string): string => s.replace(/^(["'])(.*)\1$/, "$2");
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Each component's `!terraform.output <component> [<stack>] <output>` reads in
|
|
345
|
+
* one Atmos stack manifest. The components are the keys under
|
|
346
|
+
* `components.terraform`, named by their `metadata.component` when they set
|
|
347
|
+
* one. A line scan, not a YAML parser: Atmos writes the function as a tagged
|
|
348
|
+
* scalar, one per line, which is all this reads.
|
|
349
|
+
*/
|
|
350
|
+
export function atmosOutputReads(yaml: string): ComponentReads {
|
|
351
|
+
const path: { indent: number; key: string }[] = [];
|
|
352
|
+
const found: { component: string; upstream: string; output: string }[] = [];
|
|
353
|
+
const dirOf = new Map<string, string>();
|
|
354
|
+
for (const raw of yaml.split(/\r?\n/)) {
|
|
355
|
+
const line = raw.replace(/\s+#.*$/, "");
|
|
356
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
357
|
+
const indent = line.length - line.trimStart().length;
|
|
358
|
+
const key = /^\s*(?:-\s+)?(["']?)([^"':#\s][^"':#]*?)\1\s*:(?:\s+(.*))?$/.exec(line);
|
|
359
|
+
while (path.length && path[path.length - 1]!.indent >= indent) path.pop();
|
|
360
|
+
const keys = path.map((p) => p.key);
|
|
361
|
+
const at = keys.findIndex((k, i) => k === "components" && keys[i + 1] === "terraform");
|
|
362
|
+
const component = at >= 0 ? keys[at + 2] : undefined;
|
|
363
|
+
const value = key ? (key[3] ?? "") : line.trim().replace(/^-\s+/, "");
|
|
364
|
+
if (key) {
|
|
365
|
+
if (component !== undefined && keys.length === at + 4 && keys[at + 3] === "metadata" && key[2] === "component" && value) {
|
|
366
|
+
dirOf.set(component, unquote(value.trim()));
|
|
367
|
+
}
|
|
368
|
+
path.push({ indent, key: key[2]! });
|
|
369
|
+
}
|
|
370
|
+
const call = /!terraform\.output\s+(.+)$/.exec(value);
|
|
371
|
+
if (!call || component === undefined) continue;
|
|
372
|
+
const args = call[1]!.trim().split(/\s+/).map(unquote);
|
|
373
|
+
if (args.length < 2) continue;
|
|
374
|
+
const output = /^\.?([A-Za-z_][\w-]*)/.exec(args[args.length - 1]!)?.[1];
|
|
375
|
+
if (output) found.push({ component, upstream: args[0]!, output });
|
|
376
|
+
}
|
|
377
|
+
const out: ComponentReads = {};
|
|
378
|
+
for (const f of found) {
|
|
379
|
+
const reader = dirOf.get(f.component) ?? f.component;
|
|
380
|
+
const upstream = dirOf.get(f.upstream) ?? f.upstream;
|
|
381
|
+
if (reader === upstream) continue;
|
|
382
|
+
const names = ((out[reader] ??= {})[upstream] ??= []);
|
|
383
|
+
if (!names.includes(f.output)) names.push(f.output);
|
|
384
|
+
}
|
|
385
|
+
for (const ups of Object.values(out)) for (const names of Object.values(ups)) names.sort();
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The `!terraform.output` reads of every stack manifest in an Atmos repo: the
|
|
391
|
+
* `.yaml`/`.yml` files under `stacks.base_path` of `<repo>/atmos.yaml`
|
|
392
|
+
* (`stacks` when it names none), joined. Empty when the repo has no atmos.yaml.
|
|
393
|
+
*/
|
|
394
|
+
export function atmosStackReads(repo: string): ComponentReads {
|
|
395
|
+
const config = join(repo, "atmos.yaml");
|
|
396
|
+
if (!existsSync(config)) return {};
|
|
397
|
+
let base = "stacks";
|
|
398
|
+
const text = readFileSync(config, "utf8");
|
|
399
|
+
const stacks = /^stacks\s*:\s*$([\s\S]*?)(?=^\S|(?![\s\S]))/m.exec(text);
|
|
400
|
+
const named = stacks ? /^\s+base_path\s*:\s*(["']?)([^"'#\s]+)\1/m.exec(stacks[1]!) : null;
|
|
401
|
+
if (named) base = named[2]!;
|
|
402
|
+
const out: ComponentReads = {};
|
|
403
|
+
const walk = (dir: string): void => {
|
|
404
|
+
let entries: import("node:fs").Dirent[];
|
|
405
|
+
try {
|
|
406
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
407
|
+
} catch {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
411
|
+
const p = join(dir, e.name);
|
|
412
|
+
if (e.isDirectory()) walk(p);
|
|
413
|
+
else if (/\.ya?ml$/.test(e.name)) {
|
|
414
|
+
for (const [reader, ups] of Object.entries(atmosOutputReads(readFileSync(p, "utf8")))) {
|
|
415
|
+
for (const [up, names] of Object.entries(ups)) {
|
|
416
|
+
const into = ((out[reader] ??= {})[up] ??= []);
|
|
417
|
+
for (const n of names) if (!into.includes(n)) into.push(n);
|
|
418
|
+
into.sort();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
walk(join(repo, base));
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Module `m`'s reads of the other modules' outputs that the stack wires in
|
|
430
|
+
* (`componentReads`, from Atmos's `!terraform.output`): one per upstream that
|
|
431
|
+
* is a module of this run, planned and composed, as {@link estateReads} writes
|
|
432
|
+
* them. Under choudoufu the reader is planned on the upstream's planned
|
|
433
|
+
* outputs, so `unknown` names the ones read that the plan cannot know.
|
|
434
|
+
*/
|
|
435
|
+
export function componentOutputReads(plan: Json, m: string, componentReads: ComponentReads, modules: readonly string[], rootOf: (m: string | undefined) => string): ReportRead[] {
|
|
436
|
+
const reads: ReportRead[] = [];
|
|
437
|
+
for (const [upstream, names] of Object.entries(componentReads[m] ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
438
|
+
if (upstream === m || !modules.includes(upstream)) continue;
|
|
439
|
+
const unknown = [...unknownOutputsOf(plan, upstream)].filter((n) => names.includes(n)).sort();
|
|
440
|
+
reads.push({ upstream: rootOf(upstream), data: upstream, outputs: "planned", ...(unknown.length ? { unknown } : {}), why: `!terraform.output ${upstream}: ${names.join(", ")}`, composed: true });
|
|
441
|
+
}
|
|
442
|
+
return reads;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// The report.
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
const COUNTED: ReadonlySet<ChangeSetAction> = new Set(["create", "update", "replace", "delete", "forget"]);
|
|
450
|
+
|
|
451
|
+
interface RawChange {
|
|
452
|
+
replace_paths?: (string | number)[][];
|
|
453
|
+
importing?: string;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function rawChanges(plan: Json): Map<string, RawChange> {
|
|
457
|
+
const out = new Map<string, RawChange>();
|
|
458
|
+
for (const r of Array.isArray(plan.resource_changes) ? plan.resource_changes : []) {
|
|
459
|
+
if (!isObject(r)) continue;
|
|
460
|
+
const change = isObject(r.change) ? r.change : {};
|
|
461
|
+
const paths = Array.isArray(change.replace_paths) ? (change.replace_paths.filter(Array.isArray) as (string | number)[][]) : [];
|
|
462
|
+
const importing = isObject(change.importing) ? String(change.importing.id ?? change.importing.identity ?? "") : undefined;
|
|
463
|
+
out.set(`${r.address}\u0000${r.deposed ?? ""}`, { ...(paths.length > 0 ? { replace_paths: paths } : {}), ...(importing !== undefined ? { importing } : {}) });
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface TerraformReport {
|
|
469
|
+
report: Json;
|
|
470
|
+
/** The change set whose digest `report.change_set` names: what behold reads beside the report. */
|
|
471
|
+
changeSet: ChangeSetDocument;
|
|
472
|
+
/** The run's directory, relative to the project's own index: `yyyy/mm/<commit>/<stage>`. */
|
|
473
|
+
path: string;
|
|
474
|
+
row: IndexRow;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Where a run's report lives under its project: `yyyy/mm/<commit>/<stage>`, by the day it finished. */
|
|
478
|
+
export function runPath(commit: string, stage: ReportStage, finished: string): string {
|
|
479
|
+
const at = new Date(finished);
|
|
480
|
+
return `${at.getUTCFullYear()}/${String(at.getUTCMonth() + 1).padStart(2, "0")}/${commit}/${stage}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The report for one composed run, and its index row. Pure. */
|
|
484
|
+
export function terraformReport(input: TerraformReportInput): TerraformReport {
|
|
485
|
+
const plan = isObject(input.plan) ? input.plan : {};
|
|
486
|
+
const planner: ChangeSetPlanner = plannerForBinary(input.binary);
|
|
487
|
+
const rootDir = (input.rootDir ?? "").replace(/\/+$/, "");
|
|
488
|
+
const rootOf = (m: string | undefined): string => (m === undefined ? input.selfRoot ?? (rootDir || ".") : rootDir ? `${rootDir}/${m}` : m);
|
|
489
|
+
|
|
490
|
+
const changes = Array.isArray(plan.resource_changes) ? plan.resource_changes : [];
|
|
491
|
+
const modules = [...new Set(changes.flatMap((r) => (isObject(r) ? [topModule(String(r.module_address ?? r.address ?? ""))] : [])))];
|
|
492
|
+
// Roots in module order, the composed root's own resources (if any) last.
|
|
493
|
+
const keys = [...modules.filter((m): m is string => m !== undefined).sort(), ...(modules.includes(undefined) ? [undefined] : [])];
|
|
494
|
+
if (keys.length === 0) throw new Error("the plan has no resource changes, so there is nothing to report");
|
|
495
|
+
|
|
496
|
+
const applied = input.applied !== undefined ? stateResources(input.applied) : undefined;
|
|
497
|
+
if (input.stage === "tf-apply" && !applied) throw new Error("a tf-apply report needs the post-apply plan (applied) for the values the plan left unknown");
|
|
498
|
+
|
|
499
|
+
const slices = new Map(keys.map((m) => [m, slicePlan(plan, m)] as const));
|
|
500
|
+
const parts = keys.map((m) => terraformChangeSetPart({ member: rootOf(m), plan: slices.get(m), planner }));
|
|
501
|
+
const doc = composeChangeSet(parts);
|
|
502
|
+
const summary = groupChangeSet(doc);
|
|
503
|
+
const memberGroup = new Map<string, string>();
|
|
504
|
+
if (summary.unit === "member") for (const g of summary.groups) for (const u of g.units) memberGroup.set(u, g.id);
|
|
505
|
+
|
|
506
|
+
const prefixOf = new Map(keys.map((m) => [rootOf(m), m === undefined ? "" : `module.${m}.`] as const));
|
|
507
|
+
const moduleOf = new Map(keys.map((m) => [rootOf(m), m === undefined ? "." : `module.${m}`] as const));
|
|
508
|
+
|
|
509
|
+
const roots = doc.members.map((member) => {
|
|
510
|
+
const raw = rawChanges(slices.get(keys.find((m) => rootOf(m) === member.member)!)!);
|
|
511
|
+
const counts: Record<string, number> = {};
|
|
512
|
+
const rootChanges: Json[] = [];
|
|
513
|
+
for (const e of doc.entries as ChangeSetEntry[]) {
|
|
514
|
+
if (e.member !== member.member) continue;
|
|
515
|
+
counts[e.action] = (counts[e.action] ?? 0) + 1;
|
|
516
|
+
const r = raw.get(`${e.address}\u0000${e.deposed ?? ""}`);
|
|
517
|
+
if (e.action === "no-op" && r?.importing === undefined) continue;
|
|
518
|
+
const attributes = applied ? resolveUnknowns(e.attributes, applied.get(`${prefixOf.get(member.member)}${e.address}`)) : e.attributes;
|
|
519
|
+
rootChanges.push({
|
|
520
|
+
address: e.address,
|
|
521
|
+
type: e.type,
|
|
522
|
+
action: e.action,
|
|
523
|
+
...(e.disruption ? { disruption: e.disruption } : {}),
|
|
524
|
+
...(e.deposed !== undefined ? { deposed: e.deposed } : {}),
|
|
525
|
+
...(e.module ? { module: e.module } : {}),
|
|
526
|
+
...(e.index !== undefined ? { index: e.index } : {}),
|
|
527
|
+
attributes,
|
|
528
|
+
...(r?.replace_paths ? { replace_paths: r.replace_paths } : {}),
|
|
529
|
+
...(r?.importing !== undefined ? { importing: r.importing } : {}),
|
|
530
|
+
fold: "open",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const failed = member.status === "failed";
|
|
534
|
+
const group = memberGroup.get(member.member);
|
|
535
|
+
const mod = keys.find((m) => rootOf(m) === member.member);
|
|
536
|
+
const reads = mod !== undefined && input.estates ? estateReads(plan, mod, input.estates, rootOf) : [];
|
|
537
|
+
if (mod !== undefined && input.componentReads) {
|
|
538
|
+
const named = new Set(reads.map((r) => r.upstream));
|
|
539
|
+
const declared = keys.filter((k): k is string => k !== undefined);
|
|
540
|
+
for (const r of componentOutputReads(plan, mod, input.componentReads, declared, rootOf)) if (!named.has(r.upstream)) reads.push(r);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
path: member.member,
|
|
544
|
+
binary: { name: planner, ...(input.binaryVersion ? { version: input.binaryVersion } : {}) },
|
|
545
|
+
status: member.status,
|
|
546
|
+
...(member.error ? { error: member.error } : {}),
|
|
547
|
+
plan_digest: member.planDigest,
|
|
548
|
+
counts,
|
|
549
|
+
...(group ? { group } : {}),
|
|
550
|
+
plan: {},
|
|
551
|
+
...(reads.length > 0 ? { reads } : {}),
|
|
552
|
+
...(input.run.job_url ? { job_url: input.run.job_url } : {}),
|
|
553
|
+
changes: rootChanges,
|
|
554
|
+
highlights: [],
|
|
555
|
+
fold: rootChanges.length > 0 || failed ? "open" : "folded",
|
|
556
|
+
why: failed ? ["refused to plan"] : [],
|
|
557
|
+
composed: { wave: 1, module: moduleOf.get(member.member)! },
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const groups = summary.groups.map((g) => ({
|
|
562
|
+
id: g.id,
|
|
563
|
+
...(g.resource ? { resource: g.resource } : {}),
|
|
564
|
+
units: g.units,
|
|
565
|
+
outlier: g.outlier,
|
|
566
|
+
noChanges: g.noChanges,
|
|
567
|
+
changes: g.changes,
|
|
568
|
+
...(g.extends ? { extends: g.extends } : {}),
|
|
569
|
+
...(g.plus ? { plus: g.plus } : {}),
|
|
570
|
+
destroys: g.destroys.length,
|
|
571
|
+
varies: [],
|
|
572
|
+
fold: g.destroys.length > 0 || g.outlier ? "open" : "folded",
|
|
573
|
+
why: [...(g.outlier ? ["outlier: read it on its own"] : []), ...(g.destroys.length > 0 ? [`${g.destroys.length} destroys or replacements`] : [])],
|
|
574
|
+
}));
|
|
575
|
+
|
|
576
|
+
const named: { root: string; address?: string; type?: string; action: string; deposed?: string; reason?: string }[] = [
|
|
577
|
+
...summary.destroys.map((d) => ({ root: d.member, address: d.address, type: d.type, action: d.action, ...(d.deposed ? { deposed: d.deposed } : {}) })),
|
|
578
|
+
...summary.forgets.map((d) => ({ root: d.member, address: d.address, type: d.type, action: "forget" as const })),
|
|
579
|
+
...summary.failed.map((f) => ({ root: f.member, action: "refused" as const, reason: f.reason })),
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
const totals = { ...doc.summary.actions } as Record<ChangeSetAction, number>;
|
|
583
|
+
const changing = new Set(doc.entries.filter((e) => COUNTED.has(e.action)).map((e) => e.member));
|
|
584
|
+
const planDigests = doc.members.map((m) => m.planDigest);
|
|
585
|
+
const failedAny = planDigests.some((d) => d === null);
|
|
586
|
+
const stage = input.stage;
|
|
587
|
+
const rootPaths = doc.members.map((m) => m.member);
|
|
588
|
+
const members = doc.members.map((m) => {
|
|
589
|
+
const planned = Object.entries(roots.find((r) => r.path === m.member)!.counts).filter(([a]) => COUNTED.has(a as ChangeSetAction)).reduce((n, [, c]) => n + c, 0);
|
|
590
|
+
return {
|
|
591
|
+
root: m.member,
|
|
592
|
+
module: moduleOf.get(m.member)!,
|
|
593
|
+
...(stage === "tf-apply" ? { apply: planned === 0 ? "no-changes" : "applied", changes: { done: planned, planned }, state: "written" } : {}),
|
|
594
|
+
};
|
|
595
|
+
});
|
|
596
|
+
const wave = {
|
|
597
|
+
number: 1,
|
|
598
|
+
roots: rootPaths,
|
|
599
|
+
set_digest: failedAny ? null : changeSetDigest(doc.members),
|
|
600
|
+
approval: "not-requested",
|
|
601
|
+
state: failedAny ? "failed" : stage === "tf-apply" ? "applied" : "planned",
|
|
602
|
+
composed: { plan_digest: failedAny ? null : changeSetDigest(doc.members), members },
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const run = {
|
|
606
|
+
project: input.run.project,
|
|
607
|
+
commit: input.run.commit,
|
|
608
|
+
stage,
|
|
609
|
+
binary: planner,
|
|
610
|
+
runtime: input.runtime ?? "ci",
|
|
611
|
+
started: input.started ?? input.finished,
|
|
612
|
+
finished: input.finished,
|
|
613
|
+
...(input.run.job_url ? { job_url: input.run.job_url } : {}),
|
|
614
|
+
...(input.run.commit_url ? { commit_url: input.run.commit_url } : {}),
|
|
615
|
+
...(input.run.pull_request ? { pull_request: input.run.pull_request } : {}),
|
|
616
|
+
...(input.run.pull_request_url ? { pull_request_url: input.run.pull_request_url } : {}),
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const report: Json = {
|
|
620
|
+
schema: REPORT_SCHEMA,
|
|
621
|
+
minor: REPORT_MINOR,
|
|
622
|
+
run,
|
|
623
|
+
change_set: doc.digest,
|
|
624
|
+
unit: summary.unit,
|
|
625
|
+
units: summary.units,
|
|
626
|
+
totals,
|
|
627
|
+
totals_roots: changing.size,
|
|
628
|
+
groups,
|
|
629
|
+
roots,
|
|
630
|
+
waves: [wave],
|
|
631
|
+
named,
|
|
632
|
+
holes: summary.holes.map((h) => ({ root: h.member, address: h.address, ...(h.type ? { type: h.type } : {}), reason: h.reason })),
|
|
633
|
+
redaction: { marker: REDACTED, values: 0 },
|
|
634
|
+
compose: { roots: rootPaths, max: rootPaths.length, wave: 1, ...(planner === "choudoufu" ? { estate: true as const } : {}) },
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const path = runPath(input.run.commit, stage, input.finished);
|
|
638
|
+
const failed = roots.filter((r) => r.status === "failed").length;
|
|
639
|
+
const row: IndexRow = {
|
|
640
|
+
project: input.run.project,
|
|
641
|
+
commit: input.run.commit,
|
|
642
|
+
stage,
|
|
643
|
+
finished: input.finished,
|
|
644
|
+
path,
|
|
645
|
+
roots: roots.length,
|
|
646
|
+
groups: groups.length,
|
|
647
|
+
totals: { create: totals.create, update: totals.update, replace: totals.replace, delete: totals.delete },
|
|
648
|
+
refused: named.filter((n) => n.action === "refused").length,
|
|
649
|
+
failed,
|
|
650
|
+
changed: roots.filter((r) => r.status === "planned" && r.changes.length > 0).length,
|
|
651
|
+
destroys: named.filter((n) => n.action === "delete" || n.action === "replace").map((n) => `${n.root}: ${n.address}`),
|
|
652
|
+
...(stage === "tf-apply" && failed === 0 ? { applied: input.finished } : {}),
|
|
653
|
+
...(run.commit_url ? { commit_url: run.commit_url } : {}),
|
|
654
|
+
...(run.pull_request ? { pull_request: run.pull_request } : {}),
|
|
655
|
+
...(run.pull_request_url ? { pull_request_url: run.pull_request_url } : {}),
|
|
656
|
+
...(run.job_url ? { job_url: run.job_url } : {}),
|
|
657
|
+
};
|
|
658
|
+
return { report, changeSet: doc, path, row };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// The indexes.
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
/** How many rows an index keeps. */
|
|
666
|
+
export const INDEX_ROWS = 500;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* `existing` index text with `row` added: a row at the same path is replaced,
|
|
670
|
+
* newest first, an unreadable index starts again from this row.
|
|
671
|
+
*/
|
|
672
|
+
export function addToIndex(existing: string | undefined, row: IndexRow): { schema: string; reports: IndexRow[] } {
|
|
673
|
+
let reports: IndexRow[] = [];
|
|
674
|
+
if (existing) {
|
|
675
|
+
try {
|
|
676
|
+
const parsed = JSON.parse(existing) as { reports?: unknown };
|
|
677
|
+
if (Array.isArray(parsed.reports)) reports = parsed.reports as IndexRow[];
|
|
678
|
+
} catch {
|
|
679
|
+
// An unreadable index is rebuilt from this run on.
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
reports = [...reports.filter((r) => r.path !== row.path), row];
|
|
683
|
+
reports.sort((a, b) => (a.finished < b.finished ? 1 : a.finished > b.finished ? -1 : a.path < b.path ? -1 : 1));
|
|
684
|
+
return { schema: INDEX_SCHEMA, reports: reports.slice(0, INDEX_ROWS) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** A place the report's files go: a key is `project/...` under the reports prefix. */
|
|
688
|
+
export interface ReportStore {
|
|
689
|
+
read(key: string): Promise<string | undefined>;
|
|
690
|
+
put(key: string, body: string): Promise<void>;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Write the run's `report.json` and `change-set.json` (the document whose
|
|
695
|
+
* digest the report names, as `chant change-set` writes it), and add its row to the project's index
|
|
696
|
+
* (`<project>/index.json`, paths relative to it) and to the prefix's
|
|
697
|
+
* (`index.json`, paths starting at the project), the two places terragucci
|
|
698
|
+
* writes. Returns the keys written.
|
|
699
|
+
*/
|
|
700
|
+
export async function writeReport(store: ReportStore, r: TerraformReport): Promise<string[]> {
|
|
701
|
+
const project = r.row.project;
|
|
702
|
+
const keys: string[] = [];
|
|
703
|
+
const put = async (key: string, value: unknown): Promise<void> => {
|
|
704
|
+
await store.put(key, JSON.stringify(value, null, 2) + "\n");
|
|
705
|
+
keys.push(key);
|
|
706
|
+
};
|
|
707
|
+
await put(`${project}/${r.path}/report.json`, r.report);
|
|
708
|
+
await put(`${project}/${r.path}/change-set.json`, r.changeSet);
|
|
709
|
+
for (const [key, path] of [[`${project}/index.json`, r.path], ["index.json", `${project}/${r.path}`]] as const) {
|
|
710
|
+
await put(key, addToIndex(await store.read(key), { ...r.row, path }));
|
|
711
|
+
}
|
|
712
|
+
return keys;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** A store under a local directory: what the tests and a job that uploads itself use. */
|
|
716
|
+
export function dirStore(dir: string): ReportStore {
|
|
717
|
+
return {
|
|
718
|
+
async read(key) {
|
|
719
|
+
try {
|
|
720
|
+
return readFileSync(join(dir, key), "utf8");
|
|
721
|
+
} catch {
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
async put(key, body) {
|
|
726
|
+
mkdirSync(dirname(join(dir, key)), { recursive: true });
|
|
727
|
+
writeFileSync(join(dir, key), body);
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* A store behind an HTTP address that takes unsigned `GET` and `PUT` of
|
|
734
|
+
* `<base>/<key>`: the demo's floci bucket, `http://floci:4566/<bucket>/<prefix>`.
|
|
735
|
+
* It signs nothing, so it does not write to AWS S3 or another store that asks
|
|
736
|
+
* for credentials. A 404 on read is no object.
|
|
737
|
+
*/
|
|
738
|
+
export function httpStore(base: string, fetcher: typeof fetch = fetch): ReportStore {
|
|
739
|
+
const url = (key: string): string => `${base.replace(/\/+$/, "")}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
740
|
+
return {
|
|
741
|
+
async read(key) {
|
|
742
|
+
const res = await fetcher(url(key));
|
|
743
|
+
if (res.status === 404) return undefined;
|
|
744
|
+
if (!res.ok) throw new Error(`GET ${url(key)}: ${res.status}`);
|
|
745
|
+
return res.text();
|
|
746
|
+
},
|
|
747
|
+
async put(key, body) {
|
|
748
|
+
const res = await fetcher(url(key), { method: "PUT", headers: { "content-type": "application/json" }, body });
|
|
749
|
+
if (!res.ok) throw new Error(`PUT ${url(key)}: ${res.status}`);
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|