@intentius/chant-lexicon-terraform 0.102.0 → 0.104.0
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/dist/__fixtures__/terragrunt/affected-cases.d.ts +23 -0
- package/dist/__fixtures__/terragrunt/affected-cases.d.ts.map +1 -0
- package/dist/__fixtures__/terragrunt/record.d.ts +17 -0
- package/dist/__fixtures__/terragrunt/record.d.ts.map +1 -0
- package/dist/change-set.d.ts +67 -0
- package/dist/change-set.d.ts.map +1 -0
- package/dist/changed-roots.d.ts +30 -0
- package/dist/changed-roots.d.ts.map +1 -0
- package/dist/commands.d.ts +26 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/components/capability-plugin.d.ts +54 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/composites/terraform-pin-rollout-op.d.ts +44 -0
- package/dist/composites/terraform-pin-rollout-op.d.ts.map +1 -0
- package/dist/hcl/descend.d.ts +1 -1
- package/dist/hcl/descend.d.ts.map +1 -1
- package/dist/hcl/edges.d.ts +2 -2
- package/dist/hcl/edges.d.ts.map +1 -1
- package/dist/hcl/parse.d.ts +29 -1
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/roots.d.ts +1 -1
- package/dist/hcl/roots.d.ts.map +1 -1
- package/dist/hcl/suppressions.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +13 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/module-source.d.ts +27 -1
- package/dist/lint/post-synth/module-source.d.ts.map +1 -1
- package/dist/lint/post-synth/terragrunt.d.ts +37 -0
- package/dist/lint/post-synth/terragrunt.d.ts.map +1 -0
- package/dist/lint/post-synth/tf002.d.ts +3 -1
- package/dist/lint/post-synth/tf002.d.ts.map +1 -1
- package/dist/lint/post-synth/tf038.d.ts +22 -0
- package/dist/lint/post-synth/tf038.d.ts.map +1 -0
- package/dist/lint/post-synth/tf039.d.ts +23 -0
- package/dist/lint/post-synth/tf039.d.ts.map +1 -0
- package/dist/lint/post-synth/tf040.d.ts +30 -0
- package/dist/lint/post-synth/tf040.d.ts.map +1 -0
- package/dist/lint/post-synth/tf041.d.ts +20 -0
- package/dist/lint/post-synth/tf041.d.ts.map +1 -0
- package/dist/lint/post-synth/tf042.d.ts +18 -0
- package/dist/lint/post-synth/tf042.d.ts.map +1 -0
- package/dist/lint/post-synth/tf043.d.ts +17 -0
- package/dist/lint/post-synth/tf043.d.ts.map +1 -0
- package/dist/lint/post-synth/tf044.d.ts +17 -0
- package/dist/lint/post-synth/tf044.d.ts.map +1 -0
- package/dist/lint/post-synth/tf045.d.ts +22 -0
- package/dist/lint/post-synth/tf045.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/TF038.md +11 -0
- package/dist/okf/rules/TF039.md +11 -0
- package/dist/okf/rules/TF040.md +11 -0
- package/dist/okf/rules/TF041.md +11 -0
- package/dist/okf/rules/TF042.md +11 -0
- package/dist/okf/rules/TF043.md +11 -0
- package/dist/okf/rules/TF044.md +11 -0
- package/dist/okf/rules/TF045.md +11 -0
- package/dist/op/activities/index.d.ts +4 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/pin-rollout.d.ts +46 -0
- package/dist/op/activities/pin-rollout.d.ts.map +1 -0
- package/dist/op/activities/terraform.d.ts +24 -50
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +48 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/builders.d.ts +9 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/pin/edit-ts.d.ts +21 -0
- package/dist/pin/edit-ts.d.ts.map +1 -0
- package/dist/pin/edit.d.ts +52 -0
- package/dist/pin/edit.d.ts.map +1 -0
- package/dist/pin/forge.d.ts +39 -0
- package/dist/pin/forge.d.ts.map +1 -0
- package/dist/pin/index.d.ts +22 -0
- package/dist/pin/index.d.ts.map +1 -0
- package/dist/pin/rollout.d.ts +116 -0
- package/dist/pin/rollout.d.ts.map +1 -0
- package/dist/pin/scan.d.ts +54 -0
- package/dist/pin/scan.d.ts.map +1 -0
- package/dist/pin/source.d.ts +93 -0
- package/dist/pin/source.d.ts.map +1 -0
- package/dist/pin/waves.d.ts +66 -0
- package/dist/pin/waves.d.ts.map +1 -0
- package/dist/plan-digest.d.ts +59 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/module-source.ts +56 -1
- package/dist/rules/terragrunt.ts +76 -0
- package/dist/rules/tf002.ts +1 -1
- package/dist/rules/tf038.ts +71 -0
- package/dist/rules/tf039.ts +64 -0
- package/dist/rules/tf040.ts +76 -0
- package/dist/rules/tf041.ts +56 -0
- package/dist/rules/tf042.ts +42 -0
- package/dist/rules/tf043.ts +49 -0
- package/dist/rules/tf044.ts +51 -0
- package/dist/rules/tf045.ts +47 -0
- package/dist/terragrunt/affected.d.ts +119 -0
- package/dist/terragrunt/affected.d.ts.map +1 -0
- package/dist/terragrunt/index.d.ts +11 -0
- package/dist/terragrunt/index.d.ts.map +1 -0
- package/dist/terragrunt/mocks.d.ts +147 -0
- package/dist/terragrunt/mocks.d.ts.map +1 -0
- package/dist/terragrunt/run.d.ts +130 -0
- package/dist/terragrunt/run.d.ts.map +1 -0
- package/dist/terragrunt/units.d.ts +103 -0
- package/dist/terragrunt/units.d.ts.map +1 -0
- package/dist/terragrunt/wave.d.ts +89 -0
- package/dist/terragrunt/wave.d.ts.map +1 -0
- package/package.json +12 -2
- package/src/__fixtures__/change-set/choudoufu-set-plan.change-set.golden.json +95 -0
- package/src/__fixtures__/change-set/choudoufu-set-plan.json +76 -0
- package/src/__fixtures__/change-set/choudoufu.part.golden.json +173 -0
- package/src/__fixtures__/change-set/choudoufu.plan.json +610 -0
- package/src/__fixtures__/change-set/terraform.import-forget.plan.json +81 -0
- package/src/__fixtures__/change-set/terraform.part.golden.json +173 -0
- package/src/__fixtures__/change-set/terraform.plan.json +589 -0
- package/src/__fixtures__/change-set/tofu.part.golden.json +173 -0
- package/src/__fixtures__/change-set/tofu.plan.json +588 -0
- package/src/__fixtures__/change-set/v1.tf.txt +21 -0
- package/src/__fixtures__/change-set/v2-module-app.tf.txt +4 -0
- package/src/__fixtures__/change-set/v2.tf.txt +21 -0
- package/src/__fixtures__/pin/choudoufu-live-waves-n5.json +83 -0
- package/src/__fixtures__/pin/largeset-oci-n5/apply-order.txt +5 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/estate.chdf.hcl +6 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/main.tf +33 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/estate.chdf.hcl +6 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/main.tf +45 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/estate.chdf.hcl +6 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/main.tf +47 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/estate.chdf.hcl +6 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/main.tf +31 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/estate.chdf.hcl +6 -0
- package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/main.tf +27 -0
- package/src/__fixtures__/pin/largeset-oci-n5/fixture.json +77 -0
- package/src/__fixtures__/plan-summary/largeset-n5-bump.json +2071 -0
- package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.json +95 -0
- package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.md +22 -0
- package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.txt +12 -0
- package/src/__fixtures__/terragrunt/affected/catalog/units/web/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/affected/gitignore +3 -0
- package/src/__fixtures__/terragrunt/affected/live/dev/app/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/affected/live/dev/vpc/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/affected/live/prod/app/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/affected/live/stg/terragrunt.stack.hcl +5 -0
- package/src/__fixtures__/terragrunt/affected/modules/app/main.tf +6 -0
- package/src/__fixtures__/terragrunt/affected/modules/app/policy.json +1 -0
- package/src/__fixtures__/terragrunt/affected/modules/net/main.tf +2 -0
- package/src/__fixtures__/terragrunt/affected/modules/thing/main.tf +3 -0
- package/src/__fixtures__/terragrunt/affected/root.hcl +5 -0
- package/src/__fixtures__/terragrunt/affected-cases.ts +126 -0
- package/src/__fixtures__/terragrunt/five-units/catalog/units/thing/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/five-units/live/dev/app/terragrunt.hcl +13 -0
- package/src/__fixtures__/terragrunt/five-units/live/dev/db/terragrunt.hcl +8 -0
- package/src/__fixtures__/terragrunt/five-units/live/dev/vpc/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/five-units/live/prod/app/terragrunt.hcl +8 -0
- package/src/__fixtures__/terragrunt/five-units/live/prod/vpc/terragrunt.hcl +3 -0
- package/src/__fixtures__/terragrunt/five-units/modules/thing/main.tf +7 -0
- package/src/__fixtures__/terragrunt/five-units/root.hcl +5 -0
- package/src/__fixtures__/terragrunt/record.ts +56 -0
- package/src/__fixtures__/terragrunt/recorded/affected-find.json +50 -0
- package/src/__fixtures__/terragrunt/recorded/failed-plan-report.json +36 -0
- package/src/__fixtures__/terragrunt/recorded/find.json +67 -0
- package/src/__fixtures__/terragrunt/recorded/version.txt +1 -0
- package/src/__fixtures__/terragrunt/recorded/wave-1/apply-report.json +22 -0
- package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/dev/vpc/tfplan.json +1 -0
- package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/prod/vpc/tfplan.json +1 -0
- package/src/__fixtures__/terragrunt/recorded/wave-1/plan-report.json +22 -0
- package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/dev/db/tfplan.json +1 -0
- package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/prod/app/tfplan.json +1 -0
- package/src/__fixtures__/terragrunt/recorded/wave-2/plan-report.json +22 -0
- package/src/change-set-combined-run.test.ts +156 -0
- package/src/change-set.test.ts +151 -0
- package/src/change-set.ts +273 -0
- package/src/changed-roots.test.ts +58 -0
- package/src/changed-roots.ts +102 -0
- package/src/commands.ts +112 -0
- package/src/components/capability-plugin.test.ts +91 -0
- package/src/components/capability-plugin.ts +131 -0
- package/src/components/pr-loop.tofu.test.ts +244 -0
- package/src/composites/terraform-pin-rollout-op.test.ts +69 -0
- package/src/composites/terraform-pin-rollout-op.ts +60 -0
- package/src/hcl/descend.ts +1 -1
- package/src/hcl/edges.ts +2 -2
- package/src/hcl/parse.test.ts +25 -0
- package/src/hcl/parse.ts +91 -6
- package/src/hcl/roots.ts +1 -1
- package/src/hcl/suppressions.ts +8 -2
- package/src/index.ts +18 -0
- package/src/lint/audit-catalog.ts +82 -0
- package/src/lint/audit.test.ts +3 -1
- package/src/lint/post-synth/fixtures/TF038/positive.tf +3 -0
- package/src/lint/post-synth/fixtures/TF039/positive.tf +4 -0
- package/src/lint/post-synth/fixtures/TF041/negative/terragrunt.hcl +12 -0
- package/src/lint/post-synth/fixtures/TF041/positive/terragrunt.hcl +7 -0
- package/src/lint/post-synth/fixtures/TF041/positive-apply/terragrunt.hcl +8 -0
- package/src/lint/post-synth/fixtures/TF042/negative/terragrunt.hcl +13 -0
- package/src/lint/post-synth/fixtures/TF042/positive/terragrunt.hcl +9 -0
- package/src/lint/post-synth/fixtures/TF043/negative/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF043/negative-other/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF043/positive-git/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF043/positive-oci/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF043/positive-tfr/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF044/negative/terragrunt.hcl +9 -0
- package/src/lint/post-synth/fixtures/TF044/positive/terragrunt.hcl +7 -0
- package/src/lint/post-synth/fixtures/TF044/positive-generate/terragrunt.hcl +9 -0
- package/src/lint/post-synth/fixtures/TF045/negative/root.hcl +1 -0
- package/src/lint/post-synth/fixtures/TF045/negative/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF045/positive/root.hcl +9 -0
- package/src/lint/post-synth/fixtures/TF045/positive/terragrunt.hcl +3 -0
- package/src/lint/post-synth/fixtures/TF045/unit-only/terragrunt.hcl +3 -0
- package/src/lint/post-synth/index.ts +16 -0
- package/src/lint/post-synth/module-source.test.ts +42 -1
- package/src/lint/post-synth/module-source.ts +56 -1
- package/src/lint/post-synth/terragrunt.ts +76 -0
- package/src/lint/post-synth/tf002.ts +1 -1
- package/src/lint/post-synth/tf038.test.ts +51 -0
- package/src/lint/post-synth/tf038.ts +71 -0
- package/src/lint/post-synth/tf039.test.ts +40 -0
- package/src/lint/post-synth/tf039.ts +64 -0
- package/src/lint/post-synth/tf040.test.ts +55 -0
- package/src/lint/post-synth/tf040.ts +76 -0
- package/src/lint/post-synth/tf041.test.ts +50 -0
- package/src/lint/post-synth/tf041.ts +56 -0
- package/src/lint/post-synth/tf042.test.ts +19 -0
- package/src/lint/post-synth/tf042.ts +42 -0
- package/src/lint/post-synth/tf043.test.ts +65 -0
- package/src/lint/post-synth/tf043.ts +49 -0
- package/src/lint/post-synth/tf044.test.ts +27 -0
- package/src/lint/post-synth/tf044.ts +51 -0
- package/src/lint/post-synth/tf045.test.ts +30 -0
- package/src/lint/post-synth/tf045.ts +47 -0
- package/src/op/activities/index.ts +4 -0
- package/src/op/activities/pin-rollout.ts +77 -0
- package/src/op/activities/terraform.test.ts +13 -0
- package/src/op/activities/terraform.ts +46 -86
- package/src/op/activity-contracts.test.ts +4 -1
- package/src/op/activity-contracts.ts +42 -0
- package/src/op/builders.ts +13 -0
- package/src/pin/bundle.test.ts +53 -0
- package/src/pin/edit-ts.test.ts +39 -0
- package/src/pin/edit-ts.ts +77 -0
- package/src/pin/edit.test.ts +199 -0
- package/src/pin/edit.ts +157 -0
- package/src/pin/forge.ts +85 -0
- package/src/pin/index.ts +22 -0
- package/src/pin/rollout.test.ts +232 -0
- package/src/pin/rollout.ts +451 -0
- package/src/pin/scan.ts +309 -0
- package/src/pin/source.ts +206 -0
- package/src/pin/waves.test.ts +101 -0
- package/src/pin/waves.ts +125 -0
- package/src/plan-digest.ts +99 -0
- package/src/plan-summary.test.ts +154 -0
- package/src/plugin.ts +25 -2
- package/src/terragrunt/affected.acceptance.test.ts +83 -0
- package/src/terragrunt/affected.test.ts +177 -0
- package/src/terragrunt/affected.ts +348 -0
- package/src/terragrunt/index.ts +11 -0
- package/src/terragrunt/mocks.acceptance.test.ts +105 -0
- package/src/terragrunt/mocks.test.ts +309 -0
- package/src/terragrunt/mocks.ts +324 -0
- package/src/terragrunt/run.ts +369 -0
- package/src/terragrunt/terragrunt.acceptance.test.ts +122 -0
- package/src/terragrunt/terragrunt.test.ts +332 -0
- package/src/terragrunt/units.ts +235 -0
- package/src/terragrunt/wave.ts +181 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull-request loop (#3183) over five real Terraform roots, through the
|
|
3
|
+
* commands the generated CI runs: `chant components pr-plan` on the pull
|
|
4
|
+
* request and `chant components pr-apply` on the merge, each talking to a
|
|
5
|
+
* fake GitHub.
|
|
6
|
+
*
|
|
7
|
+
* The issue's "done when": a pull request touching one of five roots plans
|
|
8
|
+
* only that root and its dependents, applies on merge with the approved
|
|
9
|
+
* digest, and refuses with a clear message if the plan changed after review.
|
|
10
|
+
*
|
|
11
|
+
* The roots use `terraform_data`, which is built into `tofu` and `terraform`,
|
|
12
|
+
* with local state, so nothing downloads a provider or reaches a cloud. The
|
|
13
|
+
* estate is net -> a, b and a -> app, with dns on its own; a and b read net's
|
|
14
|
+
* output and app reads a's, through `stackOutput()`. Skipped when neither
|
|
15
|
+
* binary is on the PATH.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
23
|
+
import type { ParsedArgs } from "@intentius/chant/cli/registry";
|
|
24
|
+
import type { PrReport } from "@intentius/chant/pr-loop";
|
|
25
|
+
import { terraformPlugin } from "../plugin";
|
|
26
|
+
|
|
27
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
28
|
+
|
|
29
|
+
function which(bin: string): boolean {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync(bin, ["version"], { stdio: "ignore" });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const BINARY = which("tofu") ? "tofu" : which("terraform") ? "terraform" : undefined;
|
|
38
|
+
|
|
39
|
+
const ROOTS: Record<string, string> = {
|
|
40
|
+
net: 'resource "terraform_data" "vpc" {\n input = "10.0.0.0/16"\n}\n\noutput "cidr" {\n value = terraform_data.vpc.output\n}\n',
|
|
41
|
+
a: 'variable "cidr" {\n type = string\n}\n\nresource "terraform_data" "subnet" {\n input = "${var.cidr}/a-v1"\n}\n\noutput "id" {\n value = terraform_data.subnet.output\n}\n',
|
|
42
|
+
b: 'variable "cidr" {\n type = string\n}\n\nresource "terraform_data" "subnet" {\n input = "${var.cidr}/b-v1"\n}\n',
|
|
43
|
+
app: 'variable "subnet" {\n type = string\n}\n\nresource "terraform_data" "app" {\n input = "app on ${var.subnet}"\n}\n',
|
|
44
|
+
dns: 'resource "terraform_data" "zone" {\n input = "example.test"\n}\n',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const COMPONENTS = `import { phase, stackOutput, type Component } from "@intentius/chant/components";
|
|
48
|
+
|
|
49
|
+
const apply = (root: string, vars?: Record<string, unknown>) =>
|
|
50
|
+
[phase("Apply", [{ kind: "terraform-apply", root, ...(vars ? { vars } : {}) }])];
|
|
51
|
+
|
|
52
|
+
export const net: Component = { name: "net", dependsOn: [], deploy: apply("net") };
|
|
53
|
+
export const a: Component = { name: "a", dependsOn: ["net"], deploy: apply("a", { cidr: stackOutput("net", "cidr") }) };
|
|
54
|
+
export const b: Component = { name: "b", dependsOn: ["net"], deploy: apply("b", { cidr: stackOutput("net", "cidr") }) };
|
|
55
|
+
export const app: Component = { name: "app", dependsOn: ["a"], deploy: apply("app", { subnet: stackOutput("a", "id") }) };
|
|
56
|
+
export const dns: Component = { name: "dns", dependsOn: [], deploy: apply("dns") };
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/** GitHub as far as the loop uses it: comments, statuses, reviews and the merged pull request of a commit. */
|
|
60
|
+
class FakeGithub {
|
|
61
|
+
comments: Array<{ id: number; pr: number; body: string }> = [];
|
|
62
|
+
statuses: Array<{ sha: string; state: string; context: string; description: string }> = [];
|
|
63
|
+
reviews: Record<number, Array<{ user: { login: string }; state: string }>> = {};
|
|
64
|
+
mergedBy: Record<string, number> = {};
|
|
65
|
+
private nextId = 1;
|
|
66
|
+
|
|
67
|
+
fetch = async (url: string, init: { method: string; body?: string }) => {
|
|
68
|
+
const path = new URL(url).pathname.replace(/^\/repos\/acme\/infra/, "");
|
|
69
|
+
const body = init.body ? JSON.parse(init.body) : undefined;
|
|
70
|
+
const ok = (value: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify(value) });
|
|
71
|
+
let m: RegExpMatchArray | null;
|
|
72
|
+
if ((m = path.match(/^\/issues\/(\d+)\/comments$/))) {
|
|
73
|
+
const pr = Number(m[1]);
|
|
74
|
+
if (init.method === "GET") return ok(this.comments.filter((c) => c.pr === pr));
|
|
75
|
+
const c = { id: this.nextId++, pr, body: body.body };
|
|
76
|
+
this.comments.push(c);
|
|
77
|
+
return ok(c);
|
|
78
|
+
}
|
|
79
|
+
if ((m = path.match(/^\/issues\/comments\/(\d+)$/))) {
|
|
80
|
+
const c = this.comments.find((x) => x.id === Number(m![1]))!;
|
|
81
|
+
c.body = body.body;
|
|
82
|
+
return ok(c);
|
|
83
|
+
}
|
|
84
|
+
if ((m = path.match(/^\/statuses\/([0-9a-f]+)$/))) {
|
|
85
|
+
this.statuses.push({ sha: m[1], ...body });
|
|
86
|
+
return ok({});
|
|
87
|
+
}
|
|
88
|
+
if ((m = path.match(/^\/pulls\/(\d+)\/reviews$/))) return ok(this.reviews[Number(m[1])] ?? []);
|
|
89
|
+
if ((m = path.match(/^\/commits\/([0-9a-f]+)\/pulls$/))) {
|
|
90
|
+
const pr = this.mergedBy[m[1]];
|
|
91
|
+
return ok(pr ? [{ number: pr, merged_at: "2026-10-03T00:00:00Z", merge_commit_sha: m[1] }] : []);
|
|
92
|
+
}
|
|
93
|
+
return { ok: false, status: 404, text: async () => "" };
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const args = (overrides: Partial<ParsedArgs>): ParsedArgs => ({
|
|
98
|
+
command: "components",
|
|
99
|
+
path: ".",
|
|
100
|
+
format: "",
|
|
101
|
+
fix: false,
|
|
102
|
+
watch: false,
|
|
103
|
+
verbose: false,
|
|
104
|
+
help: false,
|
|
105
|
+
live: false,
|
|
106
|
+
env: "local",
|
|
107
|
+
noReleaseRecord: true,
|
|
108
|
+
...overrides,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform"} roots`, () => {
|
|
112
|
+
let dir: string;
|
|
113
|
+
let cwd: string;
|
|
114
|
+
const github = new FakeGithub();
|
|
115
|
+
const env = { ...process.env };
|
|
116
|
+
const git = (...a: string[]) => execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim();
|
|
117
|
+
const write = (path: string, text: string) => {
|
|
118
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
119
|
+
writeFileSync(join(dir, path), text);
|
|
120
|
+
};
|
|
121
|
+
const report = (stage: "plan" | "apply"): PrReport => JSON.parse(readFileSync(join(dir, ".chant", "pr", `pr-${stage}.json`), "utf8"));
|
|
122
|
+
const state = (root: string) => readFileSync(join(dir, "roots", root, "terraform.tfstate"), "utf8");
|
|
123
|
+
const ctx = (overrides: Partial<ParsedArgs>) => ({ args: args(overrides), plugins: [terraformPlugin], serializers: [terraformPlugin.serializer] });
|
|
124
|
+
let handlers: typeof import("@intentius/chant/cli/handlers/pr");
|
|
125
|
+
let recordGateApproval: typeof import("@intentius/chant/cli/handlers/operator").recordGateApproval;
|
|
126
|
+
|
|
127
|
+
beforeAll(async () => {
|
|
128
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-pr-loop-")));
|
|
129
|
+
symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"));
|
|
130
|
+
for (const [name, text] of Object.entries(ROOTS)) write(`roots/${name}/main.tf`, text);
|
|
131
|
+
write(
|
|
132
|
+
"chant.config.json",
|
|
133
|
+
JSON.stringify({ lexicons: ["terraform"], terraform: { binary: BINARY, roots: Object.fromEntries(Object.keys(ROOTS).map((r) => [r, { dir: `roots/${r}` }])) } }, null, 2),
|
|
134
|
+
);
|
|
135
|
+
write("estate.component.ts", COMPONENTS);
|
|
136
|
+
write(".gitignore", "node_modules\n.terraform\n.terraform.lock.hcl\n*.tfstate*\nchant.tfplan\n.chant\n");
|
|
137
|
+
git("init", "-q", "-b", "main");
|
|
138
|
+
git("config", "user.email", "ci@example.test");
|
|
139
|
+
git("config", "user.name", "ci");
|
|
140
|
+
git("add", "-A");
|
|
141
|
+
git("commit", "-qm", "five roots");
|
|
142
|
+
|
|
143
|
+
cwd = process.cwd();
|
|
144
|
+
process.chdir(dir);
|
|
145
|
+
Object.assign(process.env, {
|
|
146
|
+
GITHUB_REPOSITORY: "acme/infra",
|
|
147
|
+
GITHUB_TOKEN: "t0k",
|
|
148
|
+
GITHUB_API_URL: "https://api.github.com",
|
|
149
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
150
|
+
GITHUB_RUN_ID: "1",
|
|
151
|
+
});
|
|
152
|
+
vi.stubGlobal("fetch", github.fetch);
|
|
153
|
+
handlers = await import("@intentius/chant/cli/handlers/pr");
|
|
154
|
+
({ recordGateApproval } = await import("@intentius/chant/cli/handlers/operator"));
|
|
155
|
+
|
|
156
|
+
// Every root applied once, so each has state and outputs to read.
|
|
157
|
+
const { runComponentsFanOut } = await import("@intentius/chant/cli/handlers/fan-out");
|
|
158
|
+
write("all.json", JSON.stringify({ changed: ["net", "dns"] }));
|
|
159
|
+
expect(await runComponentsFanOut(ctx({ fromAffected: "all.json" }) as never)).toBe(0);
|
|
160
|
+
}, 240_000);
|
|
161
|
+
|
|
162
|
+
afterAll(() => {
|
|
163
|
+
vi.unstubAllGlobals();
|
|
164
|
+
if (cwd) process.chdir(cwd);
|
|
165
|
+
for (const key of Object.keys(process.env)) if (!(key in env)) delete process.env[key];
|
|
166
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/** A pull request that rewrites root a's subnet to `version`, merged by `merge`. */
|
|
170
|
+
async function openPullRequest(pr: number, version: string): Promise<PrReport> {
|
|
171
|
+
git("checkout", "-qb", `pr-${pr}`);
|
|
172
|
+
write("roots/a/main.tf", ROOTS.a.replace("a-v1", version));
|
|
173
|
+
git("commit", "-qam", `a ${version}`);
|
|
174
|
+
expect(await handlers.runComponentsPrPlan(ctx({ base: "main", pr, forge: "github" }) as never)).toBe(0);
|
|
175
|
+
return report("plan");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function merge(pr: number): Promise<number> {
|
|
179
|
+
git("checkout", "-q", "main");
|
|
180
|
+
const before = git("rev-parse", "HEAD");
|
|
181
|
+
git("merge", "-q", "--no-ff", `pr-${pr}`, "-m", `Merge pull request #${pr}`);
|
|
182
|
+
github.mergedBy[git("rev-parse", "HEAD")] = pr;
|
|
183
|
+
return handlers.runComponentsPrApply(ctx({ base: before, forge: "github", requireReview: true }) as never);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test("a change to one root plans that root and its dependent, and nothing else", async () => {
|
|
187
|
+
const plan = await openPullRequest(1, "a-v2");
|
|
188
|
+
|
|
189
|
+
expect(plan.status).toBe("planned");
|
|
190
|
+
expect(plan.selection).toMatchObject({ changed: ["a"], dependents: ["app"], waves: [["a"], ["app"]] });
|
|
191
|
+
expect(plan.members.map((m) => [m.member, m.counts.update])).toEqual([
|
|
192
|
+
["a", 1],
|
|
193
|
+
["app", 0],
|
|
194
|
+
]);
|
|
195
|
+
expect(plan.changeSet.digest).toBe(plan.digest);
|
|
196
|
+
expect(plan.approval).toEqual({ status: "pending" });
|
|
197
|
+
|
|
198
|
+
const note = github.comments.find((c) => c.pr === 1)!;
|
|
199
|
+
expect(note.body.startsWith("<!-- chant-pr:local -->")).toBe(true);
|
|
200
|
+
expect(note.body).toContain(`chant approve pr-1 pr-apply --plan ${plan.digest} --approver github:<your-login> --sign`);
|
|
201
|
+
expect(github.statuses.at(-1)).toMatchObject({ context: "chant/plan", state: "success", sha: plan.head });
|
|
202
|
+
}, 240_000);
|
|
203
|
+
|
|
204
|
+
test("on merge it applies the plan the reviewer approved", async () => {
|
|
205
|
+
const plan = report("plan");
|
|
206
|
+
github.reviews[1] = [{ user: { login: "alice" }, state: "APPROVED" }];
|
|
207
|
+
expect((await recordGateApproval("pr-1", "pr-apply", { actor: "github:alice", plan: plan.digest })).ok).toBe(true);
|
|
208
|
+
|
|
209
|
+
expect(await merge(1)).toBe(0);
|
|
210
|
+
const applied = report("apply");
|
|
211
|
+
expect(applied.digest).toBe(plan.digest);
|
|
212
|
+
expect(applied.status).toBe("applied");
|
|
213
|
+
expect(applied.approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
|
|
214
|
+
expect(applied.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
|
|
215
|
+
["a", "applied", undefined],
|
|
216
|
+
["app", "applied", ["a"]],
|
|
217
|
+
]);
|
|
218
|
+
expect(state("a")).toContain("10.0.0.0/16/a-v2");
|
|
219
|
+
// The same note, now with the apply's outcome.
|
|
220
|
+
expect(github.comments.filter((c) => c.pr === 1)).toHaveLength(1);
|
|
221
|
+
expect(github.comments.find((c) => c.pr === 1)!.body).toContain("### chant apply for `local`");
|
|
222
|
+
expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "success", sha: applied.head });
|
|
223
|
+
}, 240_000);
|
|
224
|
+
|
|
225
|
+
test("a plan that changed after review is refused, naming both digests, and nothing applies", async () => {
|
|
226
|
+
const reviewed = await openPullRequest(2, "a-v3");
|
|
227
|
+
github.reviews[2] = [{ user: { login: "alice" }, state: "APPROVED" }];
|
|
228
|
+
expect((await recordGateApproval("pr-2", "pr-apply", { actor: "github:alice", plan: reviewed.digest })).ok).toBe(true);
|
|
229
|
+
// Pushed after the review.
|
|
230
|
+
write("roots/a/main.tf", ROOTS.a.replace("a-v1", "a-v4"));
|
|
231
|
+
git("commit", "-qam", "a v4");
|
|
232
|
+
|
|
233
|
+
expect(await merge(2)).toBe(3);
|
|
234
|
+
const refused = report("apply");
|
|
235
|
+
expect(refused.status).toBe("refused");
|
|
236
|
+
expect(refused.refusal).toBe("plan-changed");
|
|
237
|
+
expect(refused.approval).toEqual({ status: "changed", approved: reviewed.digest });
|
|
238
|
+
expect(refused.message).toContain(`approved: ${reviewed.digest}; planned now: ${refused.digest}`);
|
|
239
|
+
expect(refused.message).toContain("nothing was applied");
|
|
240
|
+
expect(state("a")).toContain("a-v2");
|
|
241
|
+
expect(state("a")).not.toContain("a-v4");
|
|
242
|
+
expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "failure", description: "refused: the plan changed after review" });
|
|
243
|
+
}, 240_000);
|
|
244
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { validateActivitySteps, type ActivityStep, type OpConfig } from "@intentius/chant/op";
|
|
3
|
+
import { TerraformPinRolloutOp } from "./terraform-pin-rollout-op";
|
|
4
|
+
import { terraformPinRolloutContract } from "../op/activity-contracts";
|
|
5
|
+
import { parsePinRolloutArgs, pinRolloutExitCode, terraformCommands } from "../commands";
|
|
6
|
+
|
|
7
|
+
function props(config: Parameters<typeof TerraformPinRolloutOp>[0]): OpConfig {
|
|
8
|
+
return (TerraformPinRolloutOp(config).op as unknown as { props: OpConfig }).props;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const BASE = { name: "vpc-1-4", module: "oci://registry.example.com/modules/vpc", from: "1.3.0", to: "1.4.0" };
|
|
12
|
+
|
|
13
|
+
describe("TerraformPinRolloutOp (#3189)", () => {
|
|
14
|
+
test("one Rollout phase, one step, the rollout's status as the run outcome", () => {
|
|
15
|
+
const op = props({ ...BASE, canaries: ["live/dev/vpc"], mode: "pull-request", schedule: "*/30 * * * *" });
|
|
16
|
+
expect(op.phases.map((p) => p.name)).toEqual(["Rollout"]);
|
|
17
|
+
const step = op.phases[0]!.steps[0] as ActivityStep;
|
|
18
|
+
expect(step.fn).toBe("terraformPinRollout");
|
|
19
|
+
expect(step.args).toEqual({ module: BASE.module, from: "1.3.0", to: "1.4.0", canaries: ["live/dev/vpc"], mode: "pull-request" });
|
|
20
|
+
expect(step.outcomeAttribute).toEqual({ name: "Rollout", from: "status" });
|
|
21
|
+
expect(op.schedule).toEqual({ cron: "*/30 * * * *", overlap: "skip" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("its step validates against the activity's contract", () => {
|
|
25
|
+
const op = props({ ...BASE, roots: [{ root: "a" }, { root: "b", dependsOn: ["a"] }] });
|
|
26
|
+
const contracts = new Map([["terraformPinRollout", terraformPinRolloutContract]]);
|
|
27
|
+
expect(validateActivitySteps(op, contracts)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("chant terraform pin-rollout", () => {
|
|
32
|
+
test("is mounted as the terraform group's pin-rollout verb", () => {
|
|
33
|
+
expect(terraformCommands.name).toBe("terraform");
|
|
34
|
+
expect(terraformCommands.commands.map((c) => c.name)).toEqual(["pin-rollout"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("parses roots, dependencies, a generated root and canaries into the activity's args", () => {
|
|
38
|
+
const { args, json } = parsePinRolloutArgs([
|
|
39
|
+
"--module=oci://r/m/vpc",
|
|
40
|
+
"--from", "1.3.0",
|
|
41
|
+
"--to", "1.4.0",
|
|
42
|
+
"--root", "a",
|
|
43
|
+
"--depends-on", "b=a",
|
|
44
|
+
"--ts-source", "c=c/vpc.ts",
|
|
45
|
+
"--canary", "a",
|
|
46
|
+
"--pull-request",
|
|
47
|
+
"--json",
|
|
48
|
+
]);
|
|
49
|
+
expect(json).toBe(true);
|
|
50
|
+
expect(args).toEqual({
|
|
51
|
+
module: "oci://r/m/vpc",
|
|
52
|
+
from: "1.3.0",
|
|
53
|
+
to: "1.4.0",
|
|
54
|
+
mode: "pull-request",
|
|
55
|
+
roots: [{ root: "a" }, { root: "b", dependsOn: ["a"] }, { root: "c", tsSource: "c/vpc.ts" }],
|
|
56
|
+
canaries: ["a"],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("report is the default mode, and the required flags are required", () => {
|
|
61
|
+
expect(parsePinRolloutArgs(["--module", "m", "--from", "1", "--to", "2"]).args.mode).toBe("report");
|
|
62
|
+
expect(() => parsePinRolloutArgs(["--module", "m", "--from", "1"])).toThrow(/--to is required/);
|
|
63
|
+
expect(() => parsePinRolloutArgs(["--module", "m", "--from", "1", "--to", "2", "--bogus"])).toThrow(/Unknown flag: --bogus/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("waiting exits 3, stopped exits 1, the rest 0", () => {
|
|
67
|
+
expect(["complete", "opened", "would-open", "waiting", "stopped"].map(pinRolloutExitCode)).toEqual([0, 0, 0, 3, 1]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformPinRolloutOp composite (#3189): roll a module version out as
|
|
3
|
+
* pin-bump pull requests, one wave at a time.
|
|
4
|
+
*
|
|
5
|
+
* One phase, one `terraformPinRollout` step. Each run reads the rollout's
|
|
6
|
+
* state from the forge and opens at most the next wave's PR, so the Op is
|
|
7
|
+
* meant to run again: on a `schedule`, or from the pipeline that applies a
|
|
8
|
+
* merged wave. Wave N+1 opens only on a run after wave N's PR merged and each
|
|
9
|
+
* of its roots reported its apply check as passed on the merge commit. Until
|
|
10
|
+
* then a run reports where the rollout stands and opens nothing. A failed
|
|
11
|
+
* root fails the run, naming it.
|
|
12
|
+
*
|
|
13
|
+
* The run's `Rollout` outcome is the rollout's status: `opened`, `waiting`,
|
|
14
|
+
* `complete`, or `would-open` in report mode.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { TerraformPinRolloutOp } from "@intentius/chant-lexicon-terraform";
|
|
19
|
+
*
|
|
20
|
+
* export const { op } = TerraformPinRolloutOp({
|
|
21
|
+
* name: "vpc-1-4",
|
|
22
|
+
* module: "oci://registry.example.com/modules/vpc",
|
|
23
|
+
* from: "1.3.0",
|
|
24
|
+
* to: "1.4.0",
|
|
25
|
+
* canaries: ["live/dev/vpc"],
|
|
26
|
+
* mode: "pull-request",
|
|
27
|
+
* schedule: "*\/30 * * * *",
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { Op, phase, OpResource } from "@intentius/chant/op";
|
|
33
|
+
import { terraformPinRollout } from "../op/builders";
|
|
34
|
+
import type { TerraformPinRolloutArgs } from "../op/activities/pin-rollout";
|
|
35
|
+
|
|
36
|
+
export interface TerraformPinRolloutOpConfig extends TerraformPinRolloutArgs {
|
|
37
|
+
/** Op name (kebab-case). */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Cron expression, so the rollout re-reads the forge and moves on without anyone running it. */
|
|
40
|
+
schedule?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TerraformPinRolloutOpResources {
|
|
44
|
+
/** Op resource. One Rollout phase. */
|
|
45
|
+
op: InstanceType<typeof OpResource>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function TerraformPinRolloutOp(config: TerraformPinRolloutOpConfig): TerraformPinRolloutOpResources {
|
|
49
|
+
const { name, schedule, ...args } = config;
|
|
50
|
+
const step = terraformPinRollout(args);
|
|
51
|
+
step.outcomeAttribute = { name: "Rollout", from: "status" };
|
|
52
|
+
const op = Op({
|
|
53
|
+
name,
|
|
54
|
+
overview: `Roll ${config.module} ${config.from} -> ${config.to} out as one pin-bump pull request per wave`,
|
|
55
|
+
labels: { Surface: "terraform-pin-rollout" },
|
|
56
|
+
...(schedule ? { schedule: { cron: schedule, overlap: "skip" as const } } : {}),
|
|
57
|
+
phases: [phase("Rollout", [step])],
|
|
58
|
+
});
|
|
59
|
+
return { op };
|
|
60
|
+
}
|
package/src/hcl/descend.ts
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
import { existsSync, statSync } from "node:fs";
|
|
50
50
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
51
51
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
52
|
-
import type { Hcl2Json } from "@intentius/chant/terraform/
|
|
52
|
+
import type { Hcl2Json } from "@intentius/chant/terraform/hcl2json";
|
|
53
53
|
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
54
54
|
import { classifyModuleSource } from "../lint/post-synth/module-source";
|
|
55
55
|
import {
|
package/src/hcl/edges.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* The `${...}` body is tokenized by hcl2json's own expression AST
|
|
16
16
|
* (`getReferencesInExpression`), never by a regex over the string. That is the
|
|
17
|
-
* same instrument `packages/core/src/terraform/
|
|
17
|
+
* same instrument `packages/core/src/terraform/hcl2json.ts` uses for the carve-out
|
|
18
18
|
* advisor, and for the same reason: a quoted address used as a map key
|
|
19
19
|
* (`var.m["aws_s3_bucket.assets.arn"]`) and an escaped `$${...}` literal are
|
|
20
20
|
* not references, and no regex over the raw string can tell. `./references.ts`
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
|
|
87
87
|
import { isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
|
|
88
88
|
import type { EntityReference } from "@intentius/chant/graph-ir";
|
|
89
|
-
import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/
|
|
89
|
+
import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/hcl2json";
|
|
90
90
|
import { refFromAccessor } from "@intentius/chant/terraform/graph";
|
|
91
91
|
import { LOCALS_TYPE, scopeOfKey, type BlockBody, type TerraformEntity } from "./parse";
|
|
92
92
|
|
package/src/hcl/parse.test.ts
CHANGED
|
@@ -67,3 +67,28 @@ describe("blocksToEntities: suppression attachment (chant #2111)", () => {
|
|
|
67
67
|
expect(second.suppressions).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("Terragrunt config files (#3417)", () => {
|
|
72
|
+
const files = [
|
|
73
|
+
{
|
|
74
|
+
name: "terragrunt.hcl",
|
|
75
|
+
source: 'terraform {\n source = "../m"\n}\ndependency "vpc" {\n config_path = "../vpc"\n}\nremote_state {\n backend = "s3"\n}\n',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
test("terragrunt.hcl and root.hcl become Terragrunt entities, never module entities", async () => {
|
|
80
|
+
const entities = await blocksToEntities(files, "unit");
|
|
81
|
+
expect([...entities.values()].map((e) => e.entityType).sort()).toEqual([
|
|
82
|
+
"Terraform::TerragruntConfig",
|
|
83
|
+
"Terraform::TerragruntDependency",
|
|
84
|
+
"Terraform::TerragruntRemoteState",
|
|
85
|
+
"Terraform::TerragruntSource",
|
|
86
|
+
]);
|
|
87
|
+
expect([...entities.keys()]).toContain("unit/dependency.vpc");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the same text under a .tf name is read as a Terraform terraform block", async () => {
|
|
91
|
+
const entities = await blocksToEntities([{ name: "main.tf", source: 'terraform {\n required_version = ">= 1.5"\n}\n' }], "unit");
|
|
92
|
+
expect([...entities.values()].map((e) => e.entityType)).toEqual(["Terraform::Terraform"]);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/hcl/parse.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/
|
|
23
|
+
import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/hcl2json";
|
|
24
24
|
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
25
25
|
import type { EntityReference } from "@intentius/chant/graph-ir";
|
|
26
26
|
import type { SuppressionDirective } from "@intentius/chant/lint/suppressions";
|
|
@@ -83,6 +83,13 @@ export interface TerraformEntity extends Declarable {
|
|
|
83
83
|
readonly mode?: TerraformRootMode;
|
|
84
84
|
/** The declared estate name. Present only when `mode` is `"live"`. */
|
|
85
85
|
readonly estate?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Whether `.terraform.lock.hcl` sits beside the root's `.tf` files (#3190,
|
|
88
|
+
* TF040). Stamped on every entity of a root parsed from a directory, and
|
|
89
|
+
* absent when the parse had no directory to look in (a fetched
|
|
90
|
+
* repository, inline source), which reads as "not determined".
|
|
91
|
+
*/
|
|
92
|
+
readonly lockFile?: boolean;
|
|
86
93
|
/** `terraform.roots.<name>.workspace`, recorded so TF025 can flag a non-default one on a live root. */
|
|
87
94
|
readonly workspace?: string;
|
|
88
95
|
/**
|
|
@@ -166,6 +173,26 @@ export const LOCALS_TYPE = "Terraform::Locals";
|
|
|
166
173
|
/** A `live { estate = "..." }` block, or an `estate.chdf.hcl` sidecar's content (#2103). */
|
|
167
174
|
export const LIVE_TYPE = "Terraform::Live";
|
|
168
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Terragrunt config entities (#3417). A `terragrunt.hcl` or `root.hcl` is not
|
|
178
|
+
* a Terraform module, so its blocks are read into these types and never into
|
|
179
|
+
* `TERRAFORM_TYPE`: TF001's `required_version` check and the rest of the
|
|
180
|
+
* module rules keep to the `.tf` files.
|
|
181
|
+
*/
|
|
182
|
+
export const TERRAGRUNT_CONFIG_TYPE = "Terraform::TerragruntConfig";
|
|
183
|
+
export const TERRAGRUNT_SOURCE_TYPE = "Terraform::TerragruntSource";
|
|
184
|
+
export const TERRAGRUNT_DEPENDENCY_TYPE = "Terraform::TerragruntDependency";
|
|
185
|
+
export const TERRAGRUNT_REMOTE_STATE_TYPE = "Terraform::TerragruntRemoteState";
|
|
186
|
+
export const TERRAGRUNT_GENERATE_TYPE = "Terraform::TerragruntGenerate";
|
|
187
|
+
|
|
188
|
+
/** The Terragrunt config files read, by their default names. A `--config` rename is not followed. */
|
|
189
|
+
export const TERRAGRUNT_FILENAMES: readonly string[] = ["root.hcl", "terragrunt.hcl"];
|
|
190
|
+
|
|
191
|
+
/** Whether `name` (a bare filename) is a Terragrunt config file this lexicon reads. */
|
|
192
|
+
export function isTerragruntFileName(name: string): boolean {
|
|
193
|
+
return TERRAGRUNT_FILENAMES.includes(name);
|
|
194
|
+
}
|
|
195
|
+
|
|
169
196
|
/** The sidecar filename choudoufu reads an estate declaration from when no in-block `live { }` is used. */
|
|
170
197
|
export const LIVE_SIDECAR_FILENAME = "estate.chdf.hcl";
|
|
171
198
|
|
|
@@ -260,6 +287,11 @@ export function listTerraformFiles(dir: string): string[] {
|
|
|
260
287
|
.sort();
|
|
261
288
|
}
|
|
262
289
|
|
|
290
|
+
/** The Terragrunt config files directly under `dir` (`root.hcl`, `terragrunt.hcl`), in filename order like the audit's bundle. */
|
|
291
|
+
export function listTerragruntFiles(dir: string): string[] {
|
|
292
|
+
return TERRAGRUNT_FILENAMES.filter((name) => existsSync(join(dir, name)));
|
|
293
|
+
}
|
|
294
|
+
|
|
263
295
|
/**
|
|
264
296
|
* Read the `estate.chdf.hcl` sidecar beside `dir`'s `.tf` files, if present.
|
|
265
297
|
* Sibling to {@link listTerraformFiles}: the sidecar is plain HCL (a bare
|
|
@@ -313,8 +345,13 @@ export interface TerraformRootModeOptions {
|
|
|
313
345
|
* declaration silently overrides.
|
|
314
346
|
*/
|
|
315
347
|
configEstate?: string;
|
|
348
|
+
/** Stamp `props.lockFile` on every entity (#3190). Set by the two directory-backed parses only. */
|
|
349
|
+
lockFile?: boolean;
|
|
316
350
|
}
|
|
317
351
|
|
|
352
|
+
/** The lock file `terraform init` writes beside a root's `.tf` files. */
|
|
353
|
+
export const LOCK_FILENAME = ".terraform.lock.hcl";
|
|
354
|
+
|
|
318
355
|
/**
|
|
319
356
|
* Parse a set of files into entities keyed `<root>/<address>`. Two blocks that
|
|
320
357
|
* genuinely share an address (two `locals` blocks, or the same address in two
|
|
@@ -351,8 +388,15 @@ export async function blocksToEntities(
|
|
|
351
388
|
const entities = new Map<string, Declarable>();
|
|
352
389
|
const prefix = callers.length > 0 ? `${root}/${callers.join("/")}` : root;
|
|
353
390
|
|
|
354
|
-
const add = (
|
|
355
|
-
|
|
391
|
+
const add = (
|
|
392
|
+
entityType: string,
|
|
393
|
+
address: string,
|
|
394
|
+
body: unknown,
|
|
395
|
+
file: TerraformFile,
|
|
396
|
+
scan: FileScan,
|
|
397
|
+
scanAddress: string = address,
|
|
398
|
+
): void => {
|
|
399
|
+
const { line, suppressions } = directivesFor(scan, scanAddress);
|
|
356
400
|
const entity = terraformEntity(
|
|
357
401
|
entityType,
|
|
358
402
|
address,
|
|
@@ -418,6 +462,10 @@ export async function blocksToEntities(
|
|
|
418
462
|
for (const file of files) {
|
|
419
463
|
const tree = (await parser.parse(file.name, file.source)) as Record<string, unknown>;
|
|
420
464
|
const scan = scanSuppressions(file.name, file.source);
|
|
465
|
+
if (isTerragruntFileName(file.name)) {
|
|
466
|
+
terragruntBlocks(tree, file, scan, add);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
421
469
|
unlabelled(tree, "terraform", TERRAFORM_TYPE, file, scan);
|
|
422
470
|
for (const liveBody of liveBlocksIn(tree)) add(LIVE_TYPE, "live", liveBody, file, scan);
|
|
423
471
|
unlabelled(tree, "locals", LOCALS_TYPE, file, scan);
|
|
@@ -450,7 +498,12 @@ export async function blocksToEntities(
|
|
|
450
498
|
const te = entity as TerraformEntity;
|
|
451
499
|
const stamped: TerraformEntity = {
|
|
452
500
|
...te,
|
|
453
|
-
props: {
|
|
501
|
+
props: {
|
|
502
|
+
...te.props,
|
|
503
|
+
mode,
|
|
504
|
+
...(mode === "live" ? { estate } : {}),
|
|
505
|
+
...(modeOptions?.lockFile !== undefined ? { lockFile: modeOptions.lockFile } : {}),
|
|
506
|
+
},
|
|
454
507
|
};
|
|
455
508
|
entities.set(key, stamped);
|
|
456
509
|
}
|
|
@@ -458,6 +511,34 @@ export async function blocksToEntities(
|
|
|
458
511
|
return entities;
|
|
459
512
|
}
|
|
460
513
|
|
|
514
|
+
/**
|
|
515
|
+
* One Terragrunt config file's blocks, as entities (#3417): the file's own
|
|
516
|
+
* settings (`terragrunt_version_constraint` and the `include` labels), the
|
|
517
|
+
* `terraform` block that names the unit's source, `remote_state`, each
|
|
518
|
+
* `dependency` and each `generate`. Everything else in the file (`inputs`,
|
|
519
|
+
* `locals`, `iam_role`) is left unread.
|
|
520
|
+
*/
|
|
521
|
+
function terragruntBlocks(
|
|
522
|
+
tree: Record<string, unknown>,
|
|
523
|
+
file: TerraformFile,
|
|
524
|
+
scan: FileScan,
|
|
525
|
+
add: (entityType: string, address: string, body: unknown, file: TerraformFile, scan: FileScan, scanAddress?: string) => void,
|
|
526
|
+
): void {
|
|
527
|
+
const config: BlockBody = { includes: Object.keys(asRecord(tree["include"])) };
|
|
528
|
+
if (typeof tree["terragrunt_version_constraint"] === "string") {
|
|
529
|
+
config["terragrunt_version_constraint"] = tree["terragrunt_version_constraint"];
|
|
530
|
+
}
|
|
531
|
+
add(TERRAGRUNT_CONFIG_TYPE, "terragrunt", config, file, scan, "");
|
|
532
|
+
for (const body of asArray(tree["terraform"])) add(TERRAGRUNT_SOURCE_TYPE, "terragrunt.terraform", body, file, scan, "terraform");
|
|
533
|
+
for (const body of asArray(tree["remote_state"])) add(TERRAGRUNT_REMOTE_STATE_TYPE, "remote_state", body, file, scan);
|
|
534
|
+
for (const [name, bodies] of Object.entries(asRecord(tree["dependency"]))) {
|
|
535
|
+
for (const body of asArray(bodies)) add(TERRAGRUNT_DEPENDENCY_TYPE, `dependency.${name}`, body, file, scan);
|
|
536
|
+
}
|
|
537
|
+
for (const [name, bodies] of Object.entries(asRecord(tree["generate"]))) {
|
|
538
|
+
for (const body of asArray(bodies)) add(TERRAGRUNT_GENERATE_TYPE, `generate.${name}`, body, file, scan);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
461
542
|
/**
|
|
462
543
|
* Parse a root module directory. Reads every `.tf` directly under `dir`, plus
|
|
463
544
|
* the `estate.chdf.hcl` sidecar beside them when present. Throws whatever the
|
|
@@ -471,7 +552,9 @@ export async function parseTerraformRootDir(
|
|
|
471
552
|
modeOptions?: TerraformRootModeOptions,
|
|
472
553
|
callers: readonly string[] = [],
|
|
473
554
|
): Promise<Map<string, Declarable>> {
|
|
474
|
-
|
|
555
|
+
// A child module is a Terraform module, never a Terragrunt unit (#3417).
|
|
556
|
+
const names = [...listTerraformFiles(dir), ...(callers.length === 0 ? listTerragruntFiles(dir) : [])];
|
|
557
|
+
const files = names.map((name) => ({
|
|
475
558
|
name,
|
|
476
559
|
source: readFileSync(join(dir, name), "utf-8"),
|
|
477
560
|
}));
|
|
@@ -479,7 +562,9 @@ export async function parseTerraformRootDir(
|
|
|
479
562
|
// estate a ROOT runs against, and choudoufu reads it from the root's own
|
|
480
563
|
// directory only. See `./descend.ts`.
|
|
481
564
|
const sidecar = callers.length === 0 ? readLiveSidecarFile(dir) : undefined;
|
|
482
|
-
|
|
565
|
+
// Only the root's own directory carries a lock file that matters (#3190).
|
|
566
|
+
const opts = callers.length === 0 ? { ...modeOptions, lockFile: existsSync(join(dir, LOCK_FILENAME)) } : modeOptions;
|
|
567
|
+
return blocksToEntities(files, root, hcl2json, opts, sidecar, callers);
|
|
483
568
|
}
|
|
484
569
|
|
|
485
570
|
/**
|
package/src/hcl/roots.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { existsSync } from "node:fs";
|
|
30
30
|
import { isAbsolute, resolve } from "node:path";
|
|
31
31
|
import { isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
|
|
32
|
-
import type { Hcl2Json } from "@intentius/chant/terraform/
|
|
32
|
+
import type { Hcl2Json } from "@intentius/chant/terraform/hcl2json";
|
|
33
33
|
import type { TerraformRootConfig } from "../config";
|
|
34
34
|
import { LIVE_TYPE, parseTerraformRootDir } from "./parse";
|
|
35
35
|
import { descendModules, resolveCallModuleType, type CallModuleType } from "./descend";
|
package/src/hcl/suppressions.ts
CHANGED
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
import type { SuppressionDirective, SuppressionIds } from "@intentius/chant/lint/suppressions";
|
|
28
28
|
|
|
29
29
|
/** A block header keyword with zero labels (`terraform {`, `locals {`). */
|
|
30
|
-
const BARE_RE = /^\s*(terraform|locals)\s*\{/;
|
|
30
|
+
const BARE_RE = /^\s*(terraform|locals|remote_state)\s*\{/;
|
|
31
31
|
/** One label (`provider "aws" {`, `module "vpc" {`, `variable "x" {`, `output "x" {`). */
|
|
32
|
-
const ONE_LABEL_RE = /^\s*(provider|module|variable|output)\s+"([^"]*)"\s*\{/;
|
|
32
|
+
const ONE_LABEL_RE = /^\s*(provider|module|variable|output|dependency|generate)\s+"([^"]*)"\s*\{/;
|
|
33
33
|
/** Two labels (`resource "aws_s3_bucket" "assets" {`, `data "aws_ami" "x" {`). */
|
|
34
34
|
const TWO_LABEL_RE = /^\s*(resource|data)\s+"([^"]*)"\s+"([^"]*)"\s*\{/;
|
|
35
35
|
|
|
@@ -43,6 +43,12 @@ function addressFor(keyword: string, labels: string[]): string {
|
|
|
43
43
|
return "terraform";
|
|
44
44
|
case "locals":
|
|
45
45
|
return "locals";
|
|
46
|
+
case "remote_state":
|
|
47
|
+
return "remote_state";
|
|
48
|
+
case "dependency":
|
|
49
|
+
return `dependency.${labels[0]}`;
|
|
50
|
+
case "generate":
|
|
51
|
+
return `generate.${labels[0]}`;
|
|
46
52
|
case "provider":
|
|
47
53
|
return `provider.${labels[0]}`;
|
|
48
54
|
case "module":
|