@intentius/chant-lexicon-terraform 0.58.0 → 0.60.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/README.md +9 -0
- package/dist/composites/terraform-adopt-op.d.ts +7 -2
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.d.ts +8 -2
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-watch-op.d.ts +30 -7
- package/dist/composites/terraform-watch-op.d.ts.map +1 -1
- package/dist/hcl/parse.d.ts +14 -0
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/integrity.json +7 -5
- package/dist/lint/audit-catalog.d.ts +2 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +39 -12
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/tf001.d.ts +40 -6
- package/dist/lint/post-synth/tf001.d.ts.map +1 -1
- package/dist/lint/post-synth/tf015.d.ts +11 -2
- package/dist/lint/post-synth/tf015.d.ts.map +1 -1
- package/dist/lint/post-synth/tf026.d.ts +7 -0
- package/dist/lint/post-synth/tf026.d.ts.map +1 -1
- package/dist/lint/post-synth/tf027.d.ts +30 -0
- package/dist/lint/post-synth/tf027.d.ts.map +1 -0
- package/dist/lint/post-synth/tf028.d.ts +33 -0
- package/dist/lint/post-synth/tf028.d.ts.map +1 -0
- package/dist/lsp/option-keys.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/TF027.md +11 -0
- package/dist/okf/rules/TF028.md +11 -0
- package/dist/op/activities/terraform.d.ts +32 -11
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/builders.d.ts +7 -5
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/resolve-root-mode.d.ts +29 -15
- package/dist/op/resolve-root-mode.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/tf001.ts +70 -14
- package/dist/rules/tf015.ts +11 -2
- package/dist/rules/tf026.ts +6 -2
- package/dist/rules/tf027.ts +74 -0
- package/dist/rules/tf028.ts +96 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +181 -0
- package/src/__fixtures__/remote-backend/main.tf +34 -0
- package/src/__fixtures__/with-backend/main.tf +7 -0
- package/src/codegen/lint-rules-page.test.ts +18 -1
- package/src/composites/terraform-adopt-op.acceptance.test.ts +46 -19
- package/src/composites/terraform-adopt-op.test.ts +9 -4
- package/src/composites/terraform-adopt-op.ts +8 -3
- package/src/composites/terraform-apply-op.acceptance.test.ts +14 -9
- package/src/composites/terraform-apply-op.test.ts +13 -5
- package/src/composites/terraform-apply-op.ts +23 -7
- package/src/composites/terraform-watch-op.test.ts +85 -4
- package/src/composites/terraform-watch-op.ts +59 -8
- package/src/hcl/parse.ts +20 -0
- package/src/lint/audit-catalog.ts +26 -2
- package/src/lint/audit-lineage.ts +79 -14
- package/src/lint/audit.test.ts +171 -3
- package/src/lint/post-synth/fixtures/TF001/negative-cloud.tf +28 -0
- package/src/lint/post-synth/fixtures/TF001/negative.tf +4 -2
- package/src/lint/post-synth/fixtures/TF001/positive-local.tf +31 -0
- package/src/lint/post-synth/fixtures/TF014/negative/main.tf +2 -2
- package/src/lint/post-synth/fixtures/TF014/positive/main.tf +2 -2
- package/src/lint/post-synth/fixtures/TF020/scoped/main.tf +2 -2
- package/src/lint/post-synth/fixtures/TF027/live-default/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF027/live-untagged-delete/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF027/live-untagged-delete-scoped/main.tf +17 -0
- package/src/lint/post-synth/fixtures/TF027/live-untagged-report/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF028/live/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF028/stock/main.tf +10 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/live-refusals-config-format.test.ts +143 -0
- package/src/lint/post-synth/module-rules.test.ts +1 -1
- package/src/lint/post-synth/post-synth.test.ts +64 -6
- package/src/lint/post-synth/tf001.ts +70 -14
- package/src/lint/post-synth/tf015.ts +11 -2
- package/src/lint/post-synth/tf026.ts +6 -2
- package/src/lint/post-synth/tf027.test.ts +62 -0
- package/src/lint/post-synth/tf027.ts +74 -0
- package/src/lint/post-synth/tf028.test.ts +92 -0
- package/src/lint/post-synth/tf028.ts +96 -0
- package/src/lsp/option-keys.ts +3 -2
- package/src/op/activities/choudoufu.acceptance.test.ts +7 -23
- package/src/op/activities/choudoufu.test.ts +49 -17
- package/src/op/activities/terraform.ts +67 -29
- package/src/op/builders.ts +7 -5
- package/src/op/resolve-root-mode.ts +29 -15
- package/src/plugin.test.ts +38 -0
- package/src/plugin.ts +122 -31
- package/src/validate.ts +10 -1
package/src/lint/audit.test.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { describe, expect, test } from "vitest";
|
|
6
|
-
import { auditFiles, type ChecksProvider, type EntitiesProvider } from "@intentius/chant/audit/core";
|
|
6
|
+
import { auditFiles, CROSS_FILE, type AuditFinding, type ChecksProvider, type EntitiesProvider } from "@intentius/chant/audit/core";
|
|
7
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
7
8
|
import { collectCandidates, discoverByDetection, type DetectPlugin } from "@intentius/chant/audit/discover";
|
|
8
9
|
import { auditTerraformState } from "@intentius/chant/audit/terraform-state";
|
|
9
10
|
import { terraformPlugin } from "../plugin";
|
|
11
|
+
import { AUDIT_ROOT_NAME } from "../hcl/parse";
|
|
12
|
+
import { renderTerraformRoots } from "../hcl/roots";
|
|
10
13
|
import { postSynthChecks } from "./post-synth";
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -39,10 +42,24 @@ describe("chant audit against a discovered terraform root module", () => {
|
|
|
39
42
|
expect(tf001[0]!.lexicon).toBe("terraform");
|
|
40
43
|
});
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
// #2218: `backend "local"` is the local backend named rather than fallen
|
|
46
|
+
// back into, so it is the same finding as no backend at all. The audit path
|
|
47
|
+
// gets its own case because it parses joined file content rather than a
|
|
48
|
+
// directory, and the label has to survive that parse too.
|
|
49
|
+
test('reports TF001 for a root whose only backend is `backend "local"`', async () => {
|
|
43
50
|
const inputs = discoverByDetection(join(fixtures, "with-backend"), [terraformDetectPlugin]);
|
|
44
51
|
expect(inputs).toHaveLength(1);
|
|
45
52
|
|
|
53
|
+
const findings = await auditFiles(inputs, { checksProvider, entitiesProvider });
|
|
54
|
+
const tf001 = findings.filter((f) => f.checkId === "TF001");
|
|
55
|
+
expect(tf001).toHaveLength(1);
|
|
56
|
+
expect(tf001[0]!.message).toContain('the backend it declares is `backend "local"`');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reports nothing for the remote-backend root", async () => {
|
|
60
|
+
const inputs = discoverByDetection(join(fixtures, "remote-backend"), [terraformDetectPlugin]);
|
|
61
|
+
expect(inputs).toHaveLength(1);
|
|
62
|
+
|
|
46
63
|
const findings = await auditFiles(inputs, { checksProvider, entitiesProvider });
|
|
47
64
|
expect(findings.filter((f) => f.checkId === "TF001")).toHaveLength(0);
|
|
48
65
|
});
|
|
@@ -82,3 +99,154 @@ describe("TF023: Terraform state committed to the repository", () => {
|
|
|
82
99
|
expect(terraformPlugin.auditCatalog?.()).not.toHaveProperty("TF023");
|
|
83
100
|
});
|
|
84
101
|
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* #2217: `chant audit` used to hand the plugin one directory's joined text and
|
|
105
|
+
* no directory, so the parse stopped at the root module's own files and every
|
|
106
|
+
* discovered directory parsed under the same fixed scope name. A root and its
|
|
107
|
+
* `modules/*` then merged into one entity map, which produced a finding keyed
|
|
108
|
+
* `<scope>/<address>#2` against the file `(cross-file)` and left the two rules
|
|
109
|
+
* about child modules (TF014, TF015) with no child to see. These run the real
|
|
110
|
+
* discovery and the real audit over the tree fixtures those rules were written
|
|
111
|
+
* for.
|
|
112
|
+
*/
|
|
113
|
+
describe("chant audit descends a root module's local modules (#2217)", () => {
|
|
114
|
+
const treeFixtures = join(dirname(fileURLToPath(import.meta.url)), "post-synth", "fixtures");
|
|
115
|
+
|
|
116
|
+
async function audit(dir: string): Promise<AuditFinding[]> {
|
|
117
|
+
return auditFiles(discoverByDetection(dir, [terraformDetectPlugin]), { checksProvider, entitiesProvider });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test("a directory the root calls as a module is not audited as a root of its own", () => {
|
|
121
|
+
const inputs = discoverByDetection(join(treeFixtures, "TF014", "positive"), [terraformDetectPlugin]);
|
|
122
|
+
expect(inputs.map((i) => i.path)).toEqual(["."]);
|
|
123
|
+
expect(inputs[0]!.dir).toBe(join(treeFixtures, "TF014", "positive"));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("TF014 fires on the positive fixture, named by its call chain", async () => {
|
|
127
|
+
const findings = await audit(join(treeFixtures, "TF014", "positive"));
|
|
128
|
+
const tf014 = findings.filter((f) => f.checkId === "TF014");
|
|
129
|
+
expect(tf014).toHaveLength(1);
|
|
130
|
+
expect(tf014[0]!.entity).toBe("audit-root/module.cdn/provider.aws");
|
|
131
|
+
expect(tf014[0]!.message).toContain("Callers: audit-root -> module.cdn (main.tf:13).");
|
|
132
|
+
expect(tf014[0]!.file).toBe(".");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("nothing is attributed to a synthetic cross-file entity any more", async () => {
|
|
136
|
+
const findings = await audit(join(treeFixtures, "TF014", "positive"));
|
|
137
|
+
expect(findings.map((f) => f.file)).not.toContain(CROSS_FILE);
|
|
138
|
+
expect(findings.filter((f) => /#\d+$/.test(f.entity ?? ""))).toEqual([]);
|
|
139
|
+
// The root's own provider block is still the root's: TF002 reports it
|
|
140
|
+
// against the root scope, not against a merge of the two directories.
|
|
141
|
+
const tf002 = findings.filter((f) => f.checkId === "TF002");
|
|
142
|
+
expect(tf002.map((f) => f.entity)).toEqual(["audit-root/provider.aws"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("TF014 says nothing about the negative fixture's alias-only block", async () => {
|
|
146
|
+
const findings = await audit(join(treeFixtures, "TF014", "negative"));
|
|
147
|
+
expect(findings.filter((f) => f.checkId === "TF014")).toEqual([]);
|
|
148
|
+
expect(findings.map((f) => f.file)).not.toContain(CROSS_FILE);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("TF015 fires on a child module that declares a backend", async () => {
|
|
152
|
+
const findings = await audit(join(treeFixtures, "TF015", "positive"));
|
|
153
|
+
expect(findings.map((f) => `${f.checkId} ${f.entity}`)).toEqual(["TF015 audit-root/module.cdn/terraform"]);
|
|
154
|
+
expect(findings[0]!.message).toContain("Callers: audit-root -> module.cdn");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("TF015 leaves a child's required_version and required_providers alone", async () => {
|
|
158
|
+
expect(await audit(join(treeFixtures, "TF015", "negative"))).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("TF001 stays root-only: the child module without a backend is not reported", async () => {
|
|
162
|
+
const findings = await audit(join(treeFixtures, "TF014", "positive"));
|
|
163
|
+
expect(findings.filter((f) => f.checkId === "TF001")).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("two roots in one repository are two scopes, each named after its directory", async () => {
|
|
167
|
+
const repo = mkdtempSync(join(tmpdir(), "chant-2217-"));
|
|
168
|
+
for (const env of ["prod", "dev"]) {
|
|
169
|
+
mkdirSync(join(repo, "envs", env), { recursive: true });
|
|
170
|
+
writeFileSync(join(repo, "envs", env, "main.tf"), 'terraform {\n required_version = ">= 1.5.0"\n}\n');
|
|
171
|
+
}
|
|
172
|
+
const findings = await audit(repo);
|
|
173
|
+
rmSync(repo, { recursive: true, force: true });
|
|
174
|
+
|
|
175
|
+
const tf001 = findings.filter((f) => f.checkId === "TF001");
|
|
176
|
+
expect(tf001.map((f) => f.missing?.scope).sort()).toEqual(["envs.dev", "envs.prod"]);
|
|
177
|
+
expect(tf001.map((f) => f.file).sort()).toEqual(["envs/dev", "envs/prod"]);
|
|
178
|
+
expect(findings.map((f) => f.file)).not.toContain(CROSS_FILE);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The regression net for #2217: for every fixture root in the family, the
|
|
184
|
+
* verdict `chant audit` reaches through real discovery is the verdict
|
|
185
|
+
* `chant build` reaches through `renderTerraformRoots`. The two paths parse
|
|
186
|
+
* the same directories with the same checks, so any rule whose behaviour
|
|
187
|
+
* depends on scope (every module-descent rule, and every root-only rule that
|
|
188
|
+
* must NOT follow one) is pinned on both paths at once.
|
|
189
|
+
*
|
|
190
|
+
* The build side renders with no `binary`, so a `live` fixture (TF024 to
|
|
191
|
+
* TF026) is stock on both sides: the audit path does not detect live mode
|
|
192
|
+
* (#2103), and this asserts the two agree, not that either sees an estate.
|
|
193
|
+
*/
|
|
194
|
+
describe("every fixture root gets the same verdict from chant audit as from chant build (#2217)", () => {
|
|
195
|
+
const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), "post-synth", "fixtures");
|
|
196
|
+
|
|
197
|
+
function verdictOf(findings: Array<{ checkId: string; entity?: string }>): string[] {
|
|
198
|
+
return findings.map((f) => `${f.checkId} ${f.entity ?? ""}`).sort();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function buildVerdict(dir: string): Promise<string[]> {
|
|
202
|
+
const { entities, warnings } = await renderTerraformRoots({
|
|
203
|
+
projectRoot: dir,
|
|
204
|
+
roots: { [AUDIT_ROOT_NAME]: { dir } },
|
|
205
|
+
});
|
|
206
|
+
const ctx = {
|
|
207
|
+
outputs: new Map(),
|
|
208
|
+
entities,
|
|
209
|
+
buildResult: { outputs: new Map(), entities, warnings, errors: [], sourceFileCount: entities.size },
|
|
210
|
+
} as unknown as PostSynthContext;
|
|
211
|
+
return verdictOf((terraformPlugin.postSynthChecks?.() ?? []).flatMap((check) => check.check(ctx)));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function auditVerdict(dir: string): Promise<string[]> {
|
|
215
|
+
const inputs = discoverByDetection(dir, [terraformDetectPlugin]);
|
|
216
|
+
return verdictOf(await auditFiles(inputs, { checksProvider, entitiesProvider }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Every fixture case: a `<id>/<case>/` tree as it stands, and a `<id>/<case>.tf` copied into a directory of its own. */
|
|
220
|
+
function cases(): Array<{ name: string; materialize: (into: string) => string }> {
|
|
221
|
+
const out: Array<{ name: string; materialize: (into: string) => string }> = [];
|
|
222
|
+
for (const id of readdirSync(fixtureRoot).filter((e) => statSync(join(fixtureRoot, e)).isDirectory()).sort()) {
|
|
223
|
+
for (const entry of readdirSync(join(fixtureRoot, id)).sort()) {
|
|
224
|
+
const full = join(fixtureRoot, id, entry);
|
|
225
|
+
if (statSync(full).isDirectory()) {
|
|
226
|
+
out.push({ name: `${id}/${entry}`, materialize: () => full });
|
|
227
|
+
} else if (entry.endsWith(".tf")) {
|
|
228
|
+
out.push({
|
|
229
|
+
name: `${id}/${entry}`,
|
|
230
|
+
materialize: (into) => {
|
|
231
|
+
copyFileSync(full, join(into, entry));
|
|
232
|
+
return into;
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const { name, materialize } of cases()) {
|
|
242
|
+
test(name, async () => {
|
|
243
|
+
const scratch = mkdtempSync(join(tmpdir(), "chant-2217-fx-"));
|
|
244
|
+
const dir = materialize(scratch);
|
|
245
|
+
try {
|
|
246
|
+
expect(await auditVerdict(dir)).toEqual(await buildVerdict(dir));
|
|
247
|
+
} finally {
|
|
248
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Terraform Cloud / HCP holds the state, so there is no terraform.tfstate on
|
|
2
|
+
# the machine that runs the plan. `cloud {}` counts as remote for TF001.
|
|
3
|
+
terraform {
|
|
4
|
+
required_version = ">= 1.5.0"
|
|
5
|
+
|
|
6
|
+
cloud {
|
|
7
|
+
organization = "acme"
|
|
8
|
+
|
|
9
|
+
workspaces {
|
|
10
|
+
name = "app"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
required_providers {
|
|
15
|
+
null = {
|
|
16
|
+
source = "hashicorp/null"
|
|
17
|
+
version = "~> 3.2"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
provider "null" {}
|
|
23
|
+
|
|
24
|
+
resource "null_resource" "first" {
|
|
25
|
+
triggers = {
|
|
26
|
+
name = "first"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `backend "local"` is the local backend asked for by name: the same
|
|
2
|
+
# terraform.tfstate in the working directory a root with no backend block
|
|
3
|
+
# falls back to. TF001 reports it for that reason (#2218).
|
|
4
|
+
terraform {
|
|
5
|
+
required_version = ">= 1.5.0"
|
|
6
|
+
|
|
7
|
+
backend "local" {
|
|
8
|
+
path = "terraform.tfstate"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
required_providers {
|
|
12
|
+
null = {
|
|
13
|
+
source = "hashicorp/null"
|
|
14
|
+
version = "~> 3.2"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
provider "null" {}
|
|
20
|
+
|
|
21
|
+
resource "null_resource" "first" {
|
|
22
|
+
triggers = {
|
|
23
|
+
name = "first"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
resource "null_resource" "second" {
|
|
28
|
+
triggers = {
|
|
29
|
+
name = "second"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -25,6 +25,8 @@ import { tf022 } from "./tf022";
|
|
|
25
25
|
import { tf024 } from "./tf024";
|
|
26
26
|
import { tf025 } from "./tf025";
|
|
27
27
|
import { tf026 } from "./tf026";
|
|
28
|
+
import { tf027 } from "./tf027";
|
|
29
|
+
import { tf028 } from "./tf028";
|
|
28
30
|
|
|
29
31
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
30
32
|
tf001,
|
|
@@ -52,4 +54,6 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
52
54
|
tf024,
|
|
53
55
|
tf025,
|
|
54
56
|
tf026,
|
|
57
|
+
tf027,
|
|
58
|
+
tf028,
|
|
55
59
|
];
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two live-root refusals against both config file formats (#2216).
|
|
3
|
+
*
|
|
4
|
+
* `TerraformApplyOp` and `TerraformWatchOp` each resolve a root's mode
|
|
5
|
+
* synchronously when they are built, and `resolveRootModeSync` reads a
|
|
6
|
+
* `chant.config.json` and nothing else: a `chant.config.ts` is project-
|
|
7
|
+
* authored code it will not evaluate. Every project in this repository is on
|
|
8
|
+
* a `chant.config.ts`, so a refusal that lives only in a composite has never
|
|
9
|
+
* fired for anyone. TF027 and TF028 read the mode the build stamps onto the
|
|
10
|
+
* parsed HCL instead, which is the same either way, and these tests
|
|
11
|
+
* materialise one project in each format to prove it.
|
|
12
|
+
*
|
|
13
|
+
* The build path here is the plugin's own `buildRoots` hook (what `chant
|
|
14
|
+
* build` calls to turn `terraform.roots` into entities) followed by the
|
|
15
|
+
* checks the plugin contributes, so the entities under test are the ones a
|
|
16
|
+
* real build produces rather than a hand-assembled map.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
23
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
24
|
+
import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
25
|
+
import { terraformPlugin } from "../../plugin";
|
|
26
|
+
import { TerraformApplyOp } from "../../composites/terraform-apply-op";
|
|
27
|
+
import { TerraformWatchOp } from "../../composites/terraform-watch-op";
|
|
28
|
+
|
|
29
|
+
const UNTAGGED_DELETE_ROOT = `terraform {
|
|
30
|
+
required_version = ">= 1.5.0"
|
|
31
|
+
|
|
32
|
+
live {
|
|
33
|
+
estate = "prod-estate"
|
|
34
|
+
|
|
35
|
+
policy {
|
|
36
|
+
undeclared_untagged = "delete"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
resource "null_resource" "first" {}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const LIVE_ROOT = `terraform {
|
|
45
|
+
required_version = ">= 1.5.0"
|
|
46
|
+
|
|
47
|
+
live {
|
|
48
|
+
estate = "prod-estate"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resource "null_resource" "first" {}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const CONFIG = {
|
|
56
|
+
lexicons: ["terraform"],
|
|
57
|
+
terraform: { binary: "choudoufu", roots: { estate: { dir: "./estate" } } },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const dirs: string[] = [];
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** A project whose one root is `mainTf`, its config written in `format`. */
|
|
67
|
+
function project(format: "ts" | "json", mainTf: string): string {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), `chant-tf-2216-${format}-`));
|
|
69
|
+
dirs.push(dir);
|
|
70
|
+
mkdirSync(join(dir, "estate"), { recursive: true });
|
|
71
|
+
writeFileSync(join(dir, "estate", "main.tf"), mainTf);
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(dir, `chant.config.${format}`),
|
|
74
|
+
format === "json"
|
|
75
|
+
? JSON.stringify(CONFIG, null, 2)
|
|
76
|
+
: `import type { ChantConfig } from "@intentius/chant/config";\n\nexport default ${JSON.stringify(CONFIG, null, 2)} satisfies ChantConfig;\n`,
|
|
77
|
+
);
|
|
78
|
+
return dir;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `chant build`'s own path: the plugin's `buildRoots`, then the checks it contributes. */
|
|
82
|
+
async function checkProject(dir: string, ops: Map<string, Declarable> = new Map()): Promise<PostSynthDiagnostic[]> {
|
|
83
|
+
const { entities } = await terraformPlugin.buildRoots!({ projectRoot: dir, config: CONFIG, entities: new Map() });
|
|
84
|
+
for (const [name, op] of ops) entities.set(name, op);
|
|
85
|
+
const ctx = { outputs: new Map(), entities } as unknown as PostSynthContext;
|
|
86
|
+
return (terraformPlugin.postSynthChecks?.() ?? []).flatMap((check) => check.check(ctx));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('TF027 fires on undeclared_untagged = "delete" whatever the config format (#2216)', () => {
|
|
90
|
+
for (const format of ["ts", "json"] as const) {
|
|
91
|
+
test(`chant.config.${format}: the build reports the root and the setting`, async () => {
|
|
92
|
+
const diags = await checkProject(project(format, UNTAGGED_DELETE_ROOT));
|
|
93
|
+
const tf027 = diags.filter((d) => d.checkId === "TF027");
|
|
94
|
+
expect(tf027).toHaveLength(1);
|
|
95
|
+
expect(tf027[0].severity).toBe("error");
|
|
96
|
+
expect(tf027[0].message).toContain('Root module "estate"');
|
|
97
|
+
expect(tf027[0].message).toContain('undeclared_untagged = "delete"');
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("the same project with no such policy setting is clean", async () => {
|
|
102
|
+
const diags = await checkProject(project("ts", LIVE_ROOT));
|
|
103
|
+
expect(diags.filter((d) => d.checkId === "TF027")).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the composite's own refusal fires only on the format it can read, which is why the check exists", () => {
|
|
107
|
+
const json = project("json", UNTAGGED_DELETE_ROOT);
|
|
108
|
+
expect(() => TerraformApplyOp({ name: "estate-apply", root: "estate", cwd: json })).toThrow(
|
|
109
|
+
/undeclared_untagged = "delete"/,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const ts = project("ts", UNTAGGED_DELETE_ROOT);
|
|
113
|
+
expect(() => TerraformApplyOp({ name: "estate-apply", root: "estate", cwd: ts })).not.toThrow();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("TF028 reports a stock-mode watch Op on a live root whatever the config format (#2216)", () => {
|
|
118
|
+
test("chant.config.ts: the composite builds, and the build reports the Op", async () => {
|
|
119
|
+
const dir = project("ts", LIVE_ROOT);
|
|
120
|
+
const { op } = TerraformWatchOp({ name: "estate-watch", root: "estate", cwd: dir });
|
|
121
|
+
const diags = await checkProject(dir, new Map([["estate-watch", op as unknown as Declarable]]));
|
|
122
|
+
const tf028 = diags.filter((d) => d.checkId === "TF028");
|
|
123
|
+
expect(tf028).toHaveLength(1);
|
|
124
|
+
expect(tf028[0].message).toContain('Op "estate-watch"');
|
|
125
|
+
expect(tf028[0].message).toContain('root module "estate"');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("chant.config.json: the composite refuses before there is an Op to check", () => {
|
|
129
|
+
const dir = project("json", LIVE_ROOT);
|
|
130
|
+
expect(() => TerraformWatchOp({ name: "estate-watch", root: "estate", cwd: dir })).toThrow(
|
|
131
|
+
/runs choudoufu with a declared estate/,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("live: true on the same root is clean in both formats", async () => {
|
|
136
|
+
for (const format of ["ts", "json"] as const) {
|
|
137
|
+
const dir = project(format, LIVE_ROOT);
|
|
138
|
+
const { op } = TerraformWatchOp({ name: "estate-watch", root: "estate", live: true, cwd: dir });
|
|
139
|
+
const diags = await checkProject(dir, new Map([["estate-watch", op as unknown as Declarable]]));
|
|
140
|
+
expect(diags.filter((d) => d.checkId === "TF028")).toEqual([]);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -72,7 +72,7 @@ describe("TF001 still fires only on roots (#2112)", () => {
|
|
|
72
72
|
|
|
73
73
|
it("still reports a root that has none, descent or no descent", async () => {
|
|
74
74
|
const ctx = await loadTreeFixture("TF020", "scoped", "app");
|
|
75
|
-
expect(tf001.check(ctx)).toEqual([]); // this root has a
|
|
75
|
+
expect(tf001.check(ctx)).toEqual([]); // this root has a remote backend block
|
|
76
76
|
|
|
77
77
|
const bare = await loadFixture("TF001", "positive", "app");
|
|
78
78
|
expect(tf001.check(bare)).toHaveLength(1);
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
-
import { tf001 } from "./tf001";
|
|
3
|
+
import { backendTypes, hasRemoteBackend, tf001 } from "./tf001";
|
|
4
4
|
import { loadFixture } from "./fixtures/load";
|
|
5
5
|
import { TERRAFORM_TYPE, terraformEntity } from "../../hcl/parse";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The local context builder for the edge cases below that are about the
|
|
9
9
|
* *collection* (several roots or several blocks sharing one root), not about
|
|
10
|
-
* one root's parsed shape, which is what real HCL fixtures represent. The
|
|
11
|
-
* headline cases (a backendless root, a root
|
|
12
|
-
* `fixtures/TF001/` and `loadFixture`
|
|
13
|
-
* for why.
|
|
10
|
+
* one root's parsed shape, which is what real HCL fixtures represent. The four
|
|
11
|
+
* headline cases (a backendless root, a `backend "local"` root, a remote
|
|
12
|
+
* backend, a `cloud` block) go through `fixtures/TF001/` and `loadFixture`
|
|
13
|
+
* instead; see that module's doc comment for why.
|
|
14
14
|
*/
|
|
15
15
|
function makeCtx(entities: Record<string, ReturnType<typeof terraformEntity>>): PostSynthContext {
|
|
16
16
|
return {
|
|
@@ -32,14 +32,33 @@ describe("TF001: root module declares no remote backend", () => {
|
|
|
32
32
|
expect(diags[0].entity).toBe("TF001/terraform");
|
|
33
33
|
expect(diags[0].lexicon).toBe("terraform");
|
|
34
34
|
expect(diags[0].message).toContain("TF001");
|
|
35
|
+
expect(diags[0].message).toContain("there is no backend block at all");
|
|
35
36
|
expect(diags[0].missing).toEqual({ kind: "backend", scope: "TF001" });
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
// #2218: the check reads the backend block's type label, so the local
|
|
40
|
+
// backend is the same finding whether it is fallen back into or asked for
|
|
41
|
+
// by name, and the message says which one it found.
|
|
42
|
+
test('flags a root whose backend is `backend "local"`', async () => {
|
|
43
|
+
const diags = tf001.check(await loadFixture("TF001", "positive-local"));
|
|
44
|
+
expect(diags).toHaveLength(1);
|
|
45
|
+
expect(diags[0].checkId).toBe("TF001");
|
|
46
|
+
expect(diags[0].entity).toBe("TF001/terraform");
|
|
47
|
+
expect(diags[0].message).toContain('the backend it declares is `backend "local"`');
|
|
48
|
+
expect(diags[0].message).not.toContain("no backend block");
|
|
49
|
+
expect(diags[0].missing).toEqual({ kind: "backend", scope: "TF001" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("passes a root with a remote backend block", async () => {
|
|
39
53
|
const diags = tf001.check(await loadFixture("TF001", "negative"));
|
|
40
54
|
expect(diags).toHaveLength(0);
|
|
41
55
|
});
|
|
42
56
|
|
|
57
|
+
test("passes a root whose cloud block is the whole state configuration", async () => {
|
|
58
|
+
const diags = tf001.check(await loadFixture("TF001", "negative-cloud"));
|
|
59
|
+
expect(diags).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
43
62
|
test("passes a root using Terraform Cloud's cloud block", () => {
|
|
44
63
|
const diags = tf001.check(
|
|
45
64
|
makeCtx({
|
|
@@ -91,4 +110,43 @@ describe("TF001: root module declares no remote backend", () => {
|
|
|
91
110
|
const diags = tf001.check(makeCtx({ "app/terraform": terraformBlock("app", { backend: {} }) }));
|
|
92
111
|
expect(diags).toHaveLength(1);
|
|
93
112
|
});
|
|
113
|
+
|
|
114
|
+
test('flags a hand-built body carrying `backend "local"`', () => {
|
|
115
|
+
const diags = tf001.check(
|
|
116
|
+
makeCtx({
|
|
117
|
+
"app/terraform": terraformBlock("app", { backend: { local: [{ path: "terraform.tfstate" }] } }),
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
expect(diags).toHaveLength(1);
|
|
121
|
+
expect(diags[0].message).toContain('the backend it declares is `backend "local"`');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The label read itself (#2218). hcl2json nests a labelled block one level
|
|
127
|
+
* deeper than an unlabelled one, and the type only survives the parse as a
|
|
128
|
+
* key of that inner object, so the shape is worth pinning down apart from the
|
|
129
|
+
* check that reads it.
|
|
130
|
+
*/
|
|
131
|
+
describe("backendTypes / hasRemoteBackend", () => {
|
|
132
|
+
test("reads the type label out of the nested object hcl2json builds", () => {
|
|
133
|
+
expect(backendTypes({ backend: { local: [{ path: "terraform.tfstate" }] } })).toEqual(["local"]);
|
|
134
|
+
expect(backendTypes({ backend: { s3: [{ bucket: "tfstate" }] } })).toEqual(["s3"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("has no labels to read when there is no backend block, or a malformed one", () => {
|
|
138
|
+
expect(backendTypes({})).toEqual([]);
|
|
139
|
+
expect(backendTypes({ backend: [{ bucket: "tfstate" }] })).toEqual([]);
|
|
140
|
+
expect(backendTypes({ backend: "s3" })).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("counts every backend but `local` as remote, and counts `cloud`", () => {
|
|
144
|
+
expect(hasRemoteBackend({ backend: { local: [{}] } })).toBe(false);
|
|
145
|
+
expect(hasRemoteBackend({})).toBe(false);
|
|
146
|
+
expect(hasRemoteBackend({ backend: { s3: [{}] } })).toBe(true);
|
|
147
|
+
expect(hasRemoteBackend({ backend: { gcs: [{}] } })).toBe(true);
|
|
148
|
+
expect(hasRemoteBackend({ backend: { azurerm: [{}] } })).toBe(true);
|
|
149
|
+
expect(hasRemoteBackend({ backend: { http: [{}] } })).toBe(true);
|
|
150
|
+
expect(hasRemoteBackend({ cloud: [{ organization: "acme" }] })).toBe(true);
|
|
151
|
+
});
|
|
94
152
|
});
|