@intentius/chant-lexicon-terraform 0.57.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 +53 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts +11 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +28 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/composites/terraform-apply-op.d.ts +88 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -0
- package/dist/composites/terraform-watch-op.d.ts +124 -0
- package/dist/composites/terraform-watch-op.d.ts.map +1 -0
- package/dist/config.d.ts +83 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +127 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/hcl/parse.d.ts +87 -0
- package/dist/hcl/parse.d.ts.map +1 -0
- package/dist/hcl/roots.d.ts +37 -0
- package/dist/hcl/roots.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +12 -0
- package/dist/lint/audit-catalog.d.ts +12 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/audit-lineage.d.ts +21 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/tf001.d.ts +17 -0
- package/dist/lint/post-synth/tf001.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +5 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/plan-before-apply.d.ts +30 -0
- package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +26 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/context.d.ts +70 -0
- package/dist/lsp/context.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +12 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/lsp/option-keys.d.ts +34 -0
- package/dist/lsp/option-keys.d.ts.map +1 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +1 -0
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/TF001.md +11 -0
- package/dist/okf/rules/TF101.md +11 -0
- package/dist/op/activities/index.d.ts +22 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/terraform.d.ts +213 -0
- package/dist/op/activities/terraform.d.ts.map +1 -0
- package/dist/op/builders.d.ts +56 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/rules/plan-before-apply.ts +143 -0
- package/dist/rules/tf001.ts +66 -0
- package/dist/serializer.d.ts +19 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/skills/chant-terraform.md +92 -0
- package/dist/state-ownership.d.ts +27 -0
- package/dist/state-ownership.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +75 -0
- package/src/__fixtures__/no-backend/main.tf +24 -0
- package/src/__fixtures__/show-state.json +68 -0
- package/src/__fixtures__/with-backend/main.tf +28 -0
- package/src/__fixtures__/with-module/main.tf +49 -0
- package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
- package/src/codegen/docs-cli.ts +4 -0
- package/src/codegen/docs.ts +50 -0
- package/src/codegen/generate-cli.ts +10 -0
- package/src/codegen/generate.ts +68 -0
- package/src/codegen/package.ts +50 -0
- package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
- package/src/composites/terraform-apply-op.test.ts +193 -0
- package/src/composites/terraform-apply-op.ts +166 -0
- package/src/composites/terraform-watch-op.test.ts +184 -0
- package/src/composites/terraform-watch-op.ts +204 -0
- package/src/config.ts +76 -0
- package/src/describe-resources.test.ts +342 -0
- package/src/describe-resources.ts +357 -0
- package/src/generated/index.d.ts +2 -0
- package/src/generated/index.ts +4 -0
- package/src/generated/lexicon-terraform.json +1 -0
- package/src/hcl/parse.ts +235 -0
- package/src/hcl/roots.ts +67 -0
- package/src/index.ts +65 -0
- package/src/lint/audit-catalog.ts +30 -0
- package/src/lint/audit-lineage.ts +21 -0
- package/src/lint/audit.test.ts +45 -0
- package/src/lint/post-synth/index.ts +7 -0
- package/src/lint/post-synth/post-synth.test.ts +95 -0
- package/src/lint/post-synth/tf001.ts +66 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/plan-before-apply.test.ts +111 -0
- package/src/lint/rules/plan-before-apply.ts +143 -0
- package/src/lsp/completions.test.ts +120 -0
- package/src/lsp/completions.ts +101 -0
- package/src/lsp/context.test.ts +152 -0
- package/src/lsp/context.ts +349 -0
- package/src/lsp/hover.test.ts +82 -0
- package/src/lsp/hover.ts +44 -0
- package/src/lsp/option-keys.ts +106 -0
- package/src/op/activities/index.ts +48 -0
- package/src/op/activities/registry.test.ts +29 -0
- package/src/op/activities/terraform.test.ts +445 -0
- package/src/op/activities/terraform.ts +469 -0
- package/src/op/builders.test.ts +90 -0
- package/src/op/builders.ts +96 -0
- package/src/package-cli.ts +21 -0
- package/src/plugin.test.ts +271 -0
- package/src/plugin.ts +157 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +26 -0
- package/src/skills/chant-terraform.md +92 -0
- package/src/state-ownership.ts +32 -0
- package/src/validate-cli.ts +5 -0
- package/src/validate.ts +28 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { isLexiconPlugin } from "@intentius/chant/lexicon";
|
|
5
|
+
import { validateLexiconConfig } from "@intentius/chant/lexicon-config";
|
|
6
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
7
|
+
import { isDeclarable, isResourceDeclarable, type Declarable } from "@intentius/chant/declarable";
|
|
8
|
+
import { terraformPlugin } from "./plugin";
|
|
9
|
+
import { terraformConfigSchema } from "./config";
|
|
10
|
+
import {
|
|
11
|
+
DATA_TYPE,
|
|
12
|
+
PROVIDER_TYPE,
|
|
13
|
+
RESOURCE_TYPE,
|
|
14
|
+
TERRAFORM_TYPE,
|
|
15
|
+
VARIABLE_TYPE,
|
|
16
|
+
parseTerraformRootContent,
|
|
17
|
+
} from "./hcl/parse";
|
|
18
|
+
|
|
19
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
|
|
20
|
+
|
|
21
|
+
/** The `props` of a parsed entity, narrowed through the guard the checks use. */
|
|
22
|
+
function propsOf(entity: Declarable | undefined): Record<string, unknown> {
|
|
23
|
+
if (!entity || !isResourceDeclarable(entity)) throw new Error("not a ResourceDeclarable");
|
|
24
|
+
return entity.props as Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `buildRoots` is optional on the contract; every test here needs it present. */
|
|
28
|
+
function buildRoots(
|
|
29
|
+
projectRoot: string,
|
|
30
|
+
config: Record<string, unknown>,
|
|
31
|
+
): Promise<{ entities: Map<string, Declarable>; warnings?: string[] }> {
|
|
32
|
+
const hook = terraformPlugin.buildRoots;
|
|
33
|
+
if (!hook) throw new Error("terraformPlugin.buildRoots is not registered");
|
|
34
|
+
return hook({ projectRoot, config, entities: new Map() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("terraform plugin", () => {
|
|
38
|
+
it("is a valid LexiconPlugin", () => {
|
|
39
|
+
expect(isLexiconPlugin(terraformPlugin)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("has the correct name", () => {
|
|
43
|
+
expect(terraformPlugin.name).toBe("terraform");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("has a serializer with the TF rule prefix", () => {
|
|
47
|
+
expect(terraformPlugin.serializer.name).toBe("terraform");
|
|
48
|
+
expect(terraformPlugin.serializer.rulePrefix).toBe("TF");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("declares its config schema", () => {
|
|
52
|
+
expect(terraformPlugin.configSchema).toBe(terraformConfigSchema);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns at least one lint rule and one post-synth check", () => {
|
|
56
|
+
expect(terraformPlugin.lintRules?.().length).toBeGreaterThan(0);
|
|
57
|
+
expect(terraformPlugin.postSynthChecks?.().length).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("registers the LSP providers and docs", () => {
|
|
61
|
+
expect(typeof terraformPlugin.completionProvider).toBe("function");
|
|
62
|
+
expect(typeof terraformPlugin.hoverProvider).toBe("function");
|
|
63
|
+
expect(typeof terraformPlugin.docs).toBe("function");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("loads the chant-terraform skill with real content", () => {
|
|
67
|
+
const skills = terraformPlugin.skills?.() ?? [];
|
|
68
|
+
expect(skills).toHaveLength(1);
|
|
69
|
+
expect(skills[0].name).toBe("chant-terraform");
|
|
70
|
+
expect(skills[0].content.length).toBeGreaterThan(0);
|
|
71
|
+
expect(skills[0].content).toContain("TerraformApplyOp");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("terraform config schema", () => {
|
|
76
|
+
it("accepts a root with every optional field", () => {
|
|
77
|
+
const parsed = terraformConfigSchema.parse({
|
|
78
|
+
binary: "tofu",
|
|
79
|
+
roots: {
|
|
80
|
+
app: {
|
|
81
|
+
dir: "./terraform/app",
|
|
82
|
+
workspace: "prod",
|
|
83
|
+
varFiles: ["prod.tfvars"],
|
|
84
|
+
backendConfig: { bucket: "tfstate" },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(parsed.roots.app.workspace).toBe("prod");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects an unknown key at the namespace level", () => {
|
|
92
|
+
expect(() => terraformConfigSchema.parse({ roots: {}, binaries: "tofu" })).toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects an unknown key inside a root", () => {
|
|
96
|
+
expect(() => terraformConfigSchema.parse({ roots: { app: { dir: ".", varfiles: [] } } })).toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects a binary that is neither terraform nor tofu", () => {
|
|
100
|
+
expect(() => terraformConfigSchema.parse({ binary: "pulumi", roots: {} })).toThrow();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("config load through the plugin's configSchema", () => {
|
|
105
|
+
/** What `chant build` does with the namespace before anything else runs. */
|
|
106
|
+
function validate(config: Record<string, unknown>): ReturnType<typeof validateLexiconConfig> {
|
|
107
|
+
return validateLexiconConfig([terraformPlugin], config as unknown as ChantConfig);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it("accepts a well-formed namespace", () => {
|
|
111
|
+
expect(validate({ terraform: { roots: { app: { dir: "./terraform" } } } })).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Zod 4 reports an unrecognized key at the path of the object that carries
|
|
115
|
+
// it, naming the key in the message rather than in the path.
|
|
116
|
+
it("fails an unknown key under terraform", () => {
|
|
117
|
+
const problems = validate({ terraform: { roots: {}, workspaces: ["prod"] } });
|
|
118
|
+
expect(problems).toHaveLength(1);
|
|
119
|
+
expect(problems[0].path).toBe("terraform");
|
|
120
|
+
expect(problems[0].lexicon).toBe("terraform");
|
|
121
|
+
expect(problems[0].message).toContain("workspaces");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("fails an unknown key inside a root", () => {
|
|
125
|
+
const problems = validate({ terraform: { roots: { app: { dir: ".", varfiles: [] } } } });
|
|
126
|
+
expect(problems.map((p) => p.path)).toEqual(["terraform.roots.app"]);
|
|
127
|
+
expect(problems[0].message).toContain("varfiles");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("fails a root with no dir", () => {
|
|
131
|
+
const problems = validate({ terraform: { roots: { app: {} } } });
|
|
132
|
+
expect(problems.map((p) => p.path)).toEqual(["terraform.roots.app.dir"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("says nothing about a project that declares no terraform namespace", () => {
|
|
136
|
+
expect(validate({ lexicons: ["terraform"] })).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("buildRoots", () => {
|
|
141
|
+
it("returns one entity per block for the with-backend fixture", async () => {
|
|
142
|
+
const { entities, warnings } = await buildRoots(fixtures, {
|
|
143
|
+
terraform: { roots: { app: { dir: "./with-backend" } } },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(warnings ?? []).toEqual([]);
|
|
147
|
+
expect([...entities.keys()].sort()).toEqual([
|
|
148
|
+
"app/null_resource.first",
|
|
149
|
+
"app/null_resource.second",
|
|
150
|
+
"app/provider.null",
|
|
151
|
+
"app/terraform",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const terraformBlock = entities.get("app/terraform")!;
|
|
155
|
+
expect(terraformBlock.entityType).toBe(TERRAFORM_TYPE);
|
|
156
|
+
expect(terraformBlock.lexicon).toBe("terraform");
|
|
157
|
+
expect(isDeclarable(terraformBlock)).toBe(true);
|
|
158
|
+
expect(isResourceDeclarable(terraformBlock)).toBe(true);
|
|
159
|
+
|
|
160
|
+
const props = propsOf(terraformBlock);
|
|
161
|
+
expect(props.address).toBe("terraform");
|
|
162
|
+
expect(props.file).toBe("main.tf");
|
|
163
|
+
expect(props.root).toBe("app");
|
|
164
|
+
expect((props.body as Record<string, unknown>).backend).toBeDefined();
|
|
165
|
+
|
|
166
|
+
expect(entities.get("app/provider.null")!.entityType).toBe(PROVIDER_TYPE);
|
|
167
|
+
expect(entities.get("app/null_resource.first")!.entityType).toBe(RESOURCE_TYPE);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("parses the no-backend fixture with no backend on the terraform block", async () => {
|
|
171
|
+
const { entities } = await buildRoots(fixtures, {
|
|
172
|
+
terraform: { roots: { legacy: { dir: "./no-backend" } } },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect([...entities.keys()].sort()).toEqual([
|
|
176
|
+
"legacy/null_resource.first",
|
|
177
|
+
"legacy/null_resource.second",
|
|
178
|
+
"legacy/provider.null",
|
|
179
|
+
"legacy/terraform",
|
|
180
|
+
]);
|
|
181
|
+
const body = propsOf(entities.get("legacy/terraform")).body as Record<string, unknown>;
|
|
182
|
+
expect(body.backend).toBeUndefined();
|
|
183
|
+
expect(body.required_version).toBe(">= 1.5.0");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keys both roots apart when they are configured together", async () => {
|
|
187
|
+
const { entities } = await buildRoots(fixtures, {
|
|
188
|
+
terraform: {
|
|
189
|
+
roots: { app: { dir: "./with-backend" }, legacy: { dir: "./no-backend" } },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
expect(entities.size).toBe(8);
|
|
193
|
+
expect(entities.has("app/terraform")).toBe(true);
|
|
194
|
+
expect(entities.has("legacy/terraform")).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("resolves a relative dir against projectRoot, not the cwd", async () => {
|
|
198
|
+
const { entities, warnings } = await buildRoots(join(fixtures, "with-backend"), {
|
|
199
|
+
terraform: { roots: { app: { dir: "." } } },
|
|
200
|
+
});
|
|
201
|
+
expect(warnings ?? []).toEqual([]);
|
|
202
|
+
expect(entities.has("app/terraform")).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("warns instead of throwing for a missing directory", async () => {
|
|
206
|
+
const { entities, warnings } = await buildRoots(fixtures, {
|
|
207
|
+
terraform: { roots: { gone: { dir: "./does-not-exist" } } },
|
|
208
|
+
});
|
|
209
|
+
expect(entities.size).toBe(0);
|
|
210
|
+
expect(warnings).toHaveLength(1);
|
|
211
|
+
expect(warnings![0]).toContain("terraform.roots.gone");
|
|
212
|
+
expect(warnings![0]).toContain("directory not found");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("keeps the other roots when one is missing", async () => {
|
|
216
|
+
const { entities, warnings } = await buildRoots(fixtures, {
|
|
217
|
+
terraform: { roots: { gone: { dir: "./does-not-exist" }, app: { dir: "./with-backend" } } },
|
|
218
|
+
});
|
|
219
|
+
expect(warnings).toHaveLength(1);
|
|
220
|
+
expect(entities.has("app/terraform")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("contributes nothing when the namespace is absent", async () => {
|
|
224
|
+
const { entities, warnings } = await buildRoots(fixtures, {});
|
|
225
|
+
expect(entities.size).toBe(0);
|
|
226
|
+
expect(warnings ?? []).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("auditEntities (#2085, parse-to-graph for chant audit)", () => {
|
|
231
|
+
it("parses a joined `# file:` bundle into entities", async () => {
|
|
232
|
+
const hook = terraformPlugin.auditEntities;
|
|
233
|
+
if (!hook) throw new Error("terraformPlugin.auditEntities is not registered");
|
|
234
|
+
const content = '# file: main.tf\nresource "null_resource" "root" {}';
|
|
235
|
+
const entities = await hook(content);
|
|
236
|
+
expect([...entities.keys()]).toEqual(["audit-root/null_resource.root"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("never throws on malformed input; returns an empty map instead", async () => {
|
|
240
|
+
const hook = terraformPlugin.auditEntities;
|
|
241
|
+
if (!hook) throw new Error("terraformPlugin.auditEntities is not registered");
|
|
242
|
+
const entities = await hook("resource null_resource root { this is not valid hcl {{{");
|
|
243
|
+
expect(entities.size).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("parseTerraformRootContent", () => {
|
|
248
|
+
it("reads the joined `# file:` bundle form chant audit produces", async () => {
|
|
249
|
+
const content = [
|
|
250
|
+
'# file: main.tf\nresource "null_resource" "root" {}',
|
|
251
|
+
'# file: variables.tf\nvariable "region" {\n type = string\n}',
|
|
252
|
+
].join("\n");
|
|
253
|
+
|
|
254
|
+
const entities = await parseTerraformRootContent(content, ".");
|
|
255
|
+
expect([...entities.keys()].sort()).toEqual([`./null_resource.root`, `./var.region`]);
|
|
256
|
+
expect(entities.get("./var.region")!.entityType).toBe(VARIABLE_TYPE);
|
|
257
|
+
expect(propsOf(entities.get("./null_resource.root")).file).toBe("main.tf");
|
|
258
|
+
expect(propsOf(entities.get("./var.region")).file).toBe("variables.tf");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("parses a bare .tf string with no marker at all", async () => {
|
|
262
|
+
const entities = await parseTerraformRootContent('data "null_data_source" "d" {}', "app");
|
|
263
|
+
expect(entities.get("app/data.null_data_source.d")!.entityType).toBe(DATA_TYPE);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("numbers two blocks that share an address rather than dropping one", async () => {
|
|
267
|
+
const content = "# file: a.tf\nlocals {\n x = 1\n}\n# file: b.tf\nlocals {\n y = 2\n}";
|
|
268
|
+
const entities = await parseTerraformRootContent(content, "app");
|
|
269
|
+
expect([...entities.keys()]).toEqual(["app/locals", "app/locals~2"]);
|
|
270
|
+
});
|
|
271
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { LexiconPlugin } from "@intentius/chant/lexicon";
|
|
2
|
+
import type { CompletionContext, HoverContext } from "@intentius/chant/lsp/types";
|
|
3
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
4
|
+
import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
|
|
5
|
+
import { terraformSerializer } from "./serializer";
|
|
6
|
+
import { rules } from "./lint/rules";
|
|
7
|
+
import { postSynthChecks } from "./lint/post-synth";
|
|
8
|
+
import { terraformAuditCatalog } from "./lint/audit-catalog";
|
|
9
|
+
import { completions } from "./lsp/completions";
|
|
10
|
+
import { hover } from "./lsp/hover";
|
|
11
|
+
import { terraformConfigSchema, type TerraformConfig } from "./config";
|
|
12
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
13
|
+
import { parseTerraformRootContent } from "./hcl/parse";
|
|
14
|
+
import { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
|
|
15
|
+
|
|
16
|
+
const loadSkills = createSkillsLoader(import.meta.url, [
|
|
17
|
+
{
|
|
18
|
+
file: "chant-terraform.md",
|
|
19
|
+
name: "chant-terraform",
|
|
20
|
+
description:
|
|
21
|
+
"Read an existing Terraform root module into chant's build and audit, and drive it with the init/plan/apply Ops",
|
|
22
|
+
},
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* terraform lexicon plugin.
|
|
27
|
+
*
|
|
28
|
+
* There is no upstream schema to pin here: Terraform's resource surface lives
|
|
29
|
+
* in provider registries, one schema per provider, and this lexicon reads the
|
|
30
|
+
* HCL an estate already has rather than generating types for it. So `spec/`
|
|
31
|
+
* and `codegen/` stay near-empty and there is no `upstreamPin`.
|
|
32
|
+
*/
|
|
33
|
+
export const terraformPlugin: LexiconPlugin = {
|
|
34
|
+
name: "terraform",
|
|
35
|
+
serializer: terraformSerializer,
|
|
36
|
+
configSchema: terraformConfigSchema,
|
|
37
|
+
|
|
38
|
+
// ── Required lifecycle methods ────────────────────────────────
|
|
39
|
+
|
|
40
|
+
async generate(options?: { verbose?: boolean }): Promise<void> {
|
|
41
|
+
const { generate } = await import("./codegen/generate");
|
|
42
|
+
await generate(options);
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async validate(_options?: { verbose?: boolean }): Promise<void> {
|
|
46
|
+
const { validate } = await import("./validate");
|
|
47
|
+
const { printValidationResult } = await import("@intentius/chant/codegen/validate");
|
|
48
|
+
const result = await validate();
|
|
49
|
+
printValidationResult(result);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async coverage(_options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
|
|
53
|
+
// No upstream spec to measure against. See the note on the plugin.
|
|
54
|
+
console.error("Coverage analysis not applicable: this lexicon generates no types from an upstream spec");
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
|
|
58
|
+
const { packageLexicon } = await import("./codegen/package");
|
|
59
|
+
const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
|
|
60
|
+
const { join, dirname } = await import("path");
|
|
61
|
+
const { fileURLToPath } = await import("url");
|
|
62
|
+
|
|
63
|
+
const { spec, stats } = await packageLexicon(options);
|
|
64
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
65
|
+
writeBundleSpec(spec, join(pkgDir, "dist"));
|
|
66
|
+
|
|
67
|
+
console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// ── Optional extensions ────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Terraform's ownership channel is the state file, not a tag or a label
|
|
74
|
+
* (#2087). Every address `terraform show -json` returns is `owned`;
|
|
75
|
+
* everything else is `unknown`. Declaring the channel is what makes that a
|
|
76
|
+
* claim the conformance suite checks rather than a silent degradation.
|
|
77
|
+
* See `./describe-resources.ts` and `docs/pages/observation.mdx`.
|
|
78
|
+
*/
|
|
79
|
+
ownershipChannel: {
|
|
80
|
+
keys: TERRAFORM_STATE_OWNERSHIP_KEYS,
|
|
81
|
+
reads: ["describeResources"],
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async describeResources(options) {
|
|
85
|
+
const { describeResources } = await import("./describe-resources");
|
|
86
|
+
return describeResources(options);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Each entry in `terraform.roots` is a root module directory that parses at
|
|
91
|
+
* build time into one entity per HCL block. The render lives in
|
|
92
|
+
* `./hcl/roots.ts`; this member only reads the namespace and hands over the
|
|
93
|
+
* project root, which is what relative `dir` paths resolve against.
|
|
94
|
+
*/
|
|
95
|
+
async buildRoots(ctx): Promise<{ entities: Map<string, Declarable>; warnings: string[] }> {
|
|
96
|
+
const roots = (ctx.config as { terraform?: TerraformConfig }).terraform?.roots ?? {};
|
|
97
|
+
if (Object.keys(roots).length === 0) return { entities: new Map(), warnings: [] };
|
|
98
|
+
return renderTerraformRoots({ projectRoot: ctx.projectRoot, roots });
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
lintRules() {
|
|
102
|
+
return rules;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
postSynthChecks() {
|
|
106
|
+
return postSynthChecks;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
auditCatalog() {
|
|
110
|
+
return terraformAuditCatalog;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse-to-graph for `chant audit` (#1567, #2085). `content` is the
|
|
115
|
+
* `# file: <name>`-joined bundle discovery builds for one discovered root
|
|
116
|
+
* module (`classifyTerraform`, `packages/core/src/audit/core.ts`); the root
|
|
117
|
+
* name itself isn't threaded through this hook's single-argument contract,
|
|
118
|
+
* so a fixed placeholder ("audit-root") stands in for it. TF001 only uses
|
|
119
|
+
* the root name to group and de-duplicate diagnostics within one parse, so
|
|
120
|
+
* this is enough for the same graph-reading check that fires on `chant
|
|
121
|
+
* build` to fire here too. Never throws: malformed HCL yields an empty map.
|
|
122
|
+
*/
|
|
123
|
+
async auditEntities(content: string): Promise<Map<string, Declarable>> {
|
|
124
|
+
try {
|
|
125
|
+
return await parseTerraformRootContent(content, "audit-root");
|
|
126
|
+
} catch {
|
|
127
|
+
return new Map();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
skills: loadSkills,
|
|
132
|
+
|
|
133
|
+
mcpTools() {
|
|
134
|
+
return []; // TODO: Implement MCP tools
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
mcpResources() {
|
|
138
|
+
return []; // TODO: Implement MCP resources
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
detectTemplate(_data: unknown) {
|
|
142
|
+
return false; // TODO: Detect if a template belongs to this lexicon
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
completionProvider(ctx: CompletionContext) {
|
|
146
|
+
return completions(ctx);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
hoverProvider(ctx: HoverContext) {
|
|
150
|
+
return hover(ctx);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async docs(options?) {
|
|
154
|
+
const { generateDocs } = await import("./codegen/docs");
|
|
155
|
+
return generateDocs(options);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import { terraformSerializer } from "./serializer";
|
|
4
|
+
import { RESOURCE_TYPE, TERRAFORM_TYPE, terraformEntity } from "./hcl/parse";
|
|
5
|
+
|
|
6
|
+
describe("terraform serializer", () => {
|
|
7
|
+
it("has the contract's two required members", () => {
|
|
8
|
+
expect(terraformSerializer.name).toBe("terraform");
|
|
9
|
+
expect(terraformSerializer.rulePrefix).toBe("TF");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("emits an empty string for an empty map", () => {
|
|
13
|
+
expect(terraformSerializer.serialize(new Map())).toBe("");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("emits no output for parsed root entities, the .tf files are the artifact", () => {
|
|
17
|
+
const entities = new Map<string, Declarable>([
|
|
18
|
+
["app/terraform", terraformEntity(TERRAFORM_TYPE, "terraform", {}, "main.tf", "app")],
|
|
19
|
+
[
|
|
20
|
+
"app/null_resource.first",
|
|
21
|
+
terraformEntity(RESOURCE_TYPE, "null_resource.first", { triggers: {} }, "main.tf", "app"),
|
|
22
|
+
],
|
|
23
|
+
]);
|
|
24
|
+
expect(terraformSerializer.serialize(entities)).toBe("");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Declarable, Serializer } from "@intentius/chant";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* terraform serializer.
|
|
5
|
+
*
|
|
6
|
+
* A stub, deliberately. The entities this lexicon carries come from `.tf`
|
|
7
|
+
* files that already exist on disk (`buildRoots()` parses the roots named in
|
|
8
|
+
* `terraform.roots`), so there is nothing for chant to write back: emitting
|
|
9
|
+
* HCL here would put a second, generated copy of the estate beside the
|
|
10
|
+
* authored one. The lexicon's value is what the entities let the rest of
|
|
11
|
+
* chant do with the root (post-synth checks, `chant audit`, the Op surface),
|
|
12
|
+
* not a rendered artifact.
|
|
13
|
+
*
|
|
14
|
+
* `name` and `rulePrefix` are the two members the `Serializer` contract
|
|
15
|
+
* requires, and both are load-bearing: `name` is the key `build()` files this
|
|
16
|
+
* lexicon's output under, and `rulePrefix` is what every `TF*` id is checked
|
|
17
|
+
* against.
|
|
18
|
+
*/
|
|
19
|
+
export const terraformSerializer: Serializer = {
|
|
20
|
+
name: "terraform",
|
|
21
|
+
rulePrefix: "TF",
|
|
22
|
+
|
|
23
|
+
serialize(_entities: Map<string, Declarable>): string {
|
|
24
|
+
return "";
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-terraform
|
|
3
|
+
description: Read an existing Terraform root module into chant's build and audit, and drive it with the init/plan/apply Ops
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Terraform as an Existing Estate
|
|
8
|
+
|
|
9
|
+
## What this lexicon covers
|
|
10
|
+
|
|
11
|
+
There is no generated resource surface here, and nothing is written back over the HCL. Terraform's own `.tf` files stay the only source of truth, and `terraform apply` keeps working exactly as before. What chant adds is: one entity per HCL block, read at build time, so the post-synth checks and `chant audit` have something to see; and Op activities that drive `init`/`plan`/`apply`/`show` against a saved plan.
|
|
12
|
+
|
|
13
|
+
## Naming your roots
|
|
14
|
+
|
|
15
|
+
Add the `terraform` namespace to `chant.config.ts`. Importing the package brings the key into `ChantConfig`.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
19
|
+
import "@intentius/chant-lexicon-terraform";
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
lexicons: ["terraform"],
|
|
23
|
+
terraform: {
|
|
24
|
+
binary: "terraform", // or "tofu" — the two are wire-compatible for everything this lexicon does
|
|
25
|
+
roots: {
|
|
26
|
+
app: { dir: "./terraform/app", workspace: "prod", varFiles: ["prod.tfvars"] },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
} satisfies ChantConfig;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`dir` is the only required field, resolved against the project root (where `chant.config.ts` lives), not against the cwd a step happens to run from. The root's name (`app` above) is the entity-key prefix and the string every Op step and builder takes as `root` — keep it stable once other declarations reference it.
|
|
33
|
+
|
|
34
|
+
## What a build produces
|
|
35
|
+
|
|
36
|
+
Every block of every configured root becomes one entity, keyed `<root>/<address>`: `terraform { }` -> `Terraform::Terraform`, `provider "x" {}` -> `Terraform::Provider`, `resource "x" "y" {}` -> `Terraform::Resource`, `data "x" "y" {}` -> `Terraform::Data`, `module "x" {}` -> `Terraform::Module`, `variable "x" {}` -> `Terraform::Variable`, `output "x" {}` -> `Terraform::Output`, `locals {}` -> `Terraform::Locals`. Each carries `props.address`, `props.body` (the block, verbatim), `props.file` and `props.root`. A root whose `dir` doesn't exist, or whose HCL the parser refuses, warns and contributes no entities — the rest of the build is unaffected.
|
|
37
|
+
|
|
38
|
+
## The one thing to check before anything else
|
|
39
|
+
|
|
40
|
+
`TF001` fires once per root whose `terraform` block declares neither a `backend "<type>"` nor a `cloud {}`. That root's state is a local `terraform.tfstate`: unshared, unlocked, holding every resource attribute in plaintext. Add a real backend before anything else touches this root:
|
|
41
|
+
|
|
42
|
+
```hcl
|
|
43
|
+
terraform {
|
|
44
|
+
backend "s3" {
|
|
45
|
+
bucket = "acme-tfstate"
|
|
46
|
+
key = "app/terraform.tfstate"
|
|
47
|
+
region = "us-east-1"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`TF101` is the second rule, and it's about the Ops below: a `terraformApply` step's `planFile` must reference the output of a preceding `terraformPlan` step (`plan.out.planFile`), never a literal path. Applying an unreviewed plan, or a plan somebody hand-edited on disk, is exactly what a saved-plan discipline exists to prevent.
|
|
53
|
+
|
|
54
|
+
## Driving a root with an Op
|
|
55
|
+
|
|
56
|
+
`TerraformApplyOp` is Init, Plan, an optional approval Gate, then Apply — Init and Plan on the `longInfra` profile, the Gate's `show` step on `fastIdempotent`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { TerraformApplyOp } from "@intentius/chant-lexicon-terraform";
|
|
60
|
+
|
|
61
|
+
export const { op } = TerraformApplyOp({
|
|
62
|
+
name: "app-apply",
|
|
63
|
+
root: "app",
|
|
64
|
+
gate: "on-destroy", // default — "always" gates every apply, "never" drops the Gate phase entirely
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`gate: "never"` is what makes the Op runnable under the local executor (`chant run`); any other mode emits a Gate phase, and a gate is durable-runtime work the local executor refuses. The Gate phase always shows the saved plan first and reports its `destroys` count as a `Destroys` search attribute, because `GateStep` carries no condition to branch on at build time — the approver sees what's at stake before approving, rather than the Op deciding for them.
|
|
69
|
+
|
|
70
|
+
Terraform has no automatic rollback, so `compensate: true` with no command throws at build time, naming the Op, rather than warning once an apply has already half-run:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
export const { op } = TerraformApplyOp({
|
|
74
|
+
name: "app-apply-gated",
|
|
75
|
+
root: "app",
|
|
76
|
+
gate: "always",
|
|
77
|
+
compensate: { command: "terraform destroy -auto-approve" },
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Reach for the four builders directly (`terraformInit`, `terraformPlan`, `terraformApply`, `terraformShow`) when a phase shape other than Init/Plan/Gate/Apply is needed — `plan.out.planFile` is how a later step references an earlier Plan step's saved output, and it only resolves when the Plan step carries an `id`.
|
|
82
|
+
|
|
83
|
+
## Where a running estate's ownership answer lives
|
|
84
|
+
|
|
85
|
+
Terraform's own state file is the ownership answer for what it manages — this lexicon doesn't add a second one. See the lexicon's "Live Observation" doc page for how a scheduled watch reads that state.
|
|
86
|
+
|
|
87
|
+
## Rules
|
|
88
|
+
|
|
89
|
+
| Rule | Severity | What it catches |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| TF001 | error (post-synth) | root module declares no remote backend |
|
|
92
|
+
| TF101 | error | `terraformApply`'s `planFile` isn't a preceding `terraformPlan` step's output |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terraform's ownership channel (#2087): the state file.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, and a tiny one, for the same reason cedar's
|
|
5
|
+
* `src/avp/ownership.ts` is: `plugin.ts` declares `ownershipChannel` eagerly
|
|
6
|
+
* while `describeResources` is behind a dynamic import, so the keys cannot
|
|
7
|
+
* live in the reader without dragging the reader (and `node:child_process`
|
|
8
|
+
* with it) into every plugin load.
|
|
9
|
+
*
|
|
10
|
+
* The design record is the module doc of `./describe-resources.ts` and the
|
|
11
|
+
* reader-facing version is `docs/pages/observation.mdx`. The short form:
|
|
12
|
+
* chant elsewhere answers "is this mine?" from a marker it stamped onto the
|
|
13
|
+
* live resource; terraform answers it from the state file, which already
|
|
14
|
+
* records exactly which addresses a configuration manages. Nothing is ever
|
|
15
|
+
* stamped, so nothing is ever written back over an estate's own tags.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ChannelKeys } from "@intentius/chant/ownership";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the verdict is read from, rather than what is written.
|
|
22
|
+
*
|
|
23
|
+
* {@link ChannelKeys} names tag or label keys everywhere else, because
|
|
24
|
+
* everywhere else the marker is a key/value pair on the resource. These three
|
|
25
|
+
* name the state file, the configured root that owns that state, and the
|
|
26
|
+
* workspace the root selects.
|
|
27
|
+
*/
|
|
28
|
+
export const TERRAFORM_STATE_OWNERSHIP_KEYS: ChannelKeys = {
|
|
29
|
+
managedBy: "terraform.state",
|
|
30
|
+
stack: "terraform.root",
|
|
31
|
+
env: "terraform.workspace",
|
|
32
|
+
};
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate generated lexicon-terraform artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the core validation framework
|
|
5
|
+
* with terraform-specific configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { validateLexiconArtifacts, type ValidateResult } from "@intentius/chant/codegen/validate";
|
|
11
|
+
|
|
12
|
+
export type { ValidateCheck, ValidateResult } from "@intentius/chant/codegen/validate";
|
|
13
|
+
|
|
14
|
+
// TODO: Add names of required entities for your lexicon
|
|
15
|
+
const REQUIRED_NAMES: string[] = [];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate the generated lexicon-terraform artifacts.
|
|
19
|
+
*/
|
|
20
|
+
export async function validate(opts?: { basePath?: string }): Promise<ValidateResult> {
|
|
21
|
+
const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
22
|
+
|
|
23
|
+
return validateLexiconArtifacts({
|
|
24
|
+
lexiconJsonFilename: "lexicon-terraform.json",
|
|
25
|
+
requiredNames: REQUIRED_NAMES,
|
|
26
|
+
basePath,
|
|
27
|
+
});
|
|
28
|
+
}
|