@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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live observation for declared terraform entities (#2087).
|
|
3
|
+
*
|
|
4
|
+
* `buildRoots()` turns each configured root module into one entity per HCL
|
|
5
|
+
* block, keyed `<root>/<address>` (`./hcl/parse.ts`). This reader answers the
|
|
6
|
+
* lifecycle question for those entities by running `terraform show -json` over
|
|
7
|
+
* the root's current state and matching addresses.
|
|
8
|
+
*
|
|
9
|
+
* ## The state file is the ownership answer
|
|
10
|
+
*
|
|
11
|
+
* Every other lexicon in chant stamps a tag or a label at synthesis and reads
|
|
12
|
+
* it back off the live resource. Terraform stamps nothing, and there is
|
|
13
|
+
* nowhere to stamp: a resource's provider-side tags are the practitioner's
|
|
14
|
+
* own, and writing chant's marker into them would edit an estate this lexicon
|
|
15
|
+
* promises never to write. What terraform has instead is the thing chant
|
|
16
|
+
* elsewhere refuses to host, a trusted state file that already records
|
|
17
|
+
* exactly which addresses this configuration manages.
|
|
18
|
+
*
|
|
19
|
+
* So the answer here is state membership. An address `terraform show -json`
|
|
20
|
+
* returns is `owned`; anything else is `unknown`. Concretely that makes a
|
|
21
|
+
* declared `module` block `unknown`: the state carries the module's
|
|
22
|
+
* resources, never a row for the block itself, so chant can see the block is
|
|
23
|
+
* live without the state ever saying that block is managed. `unknown` never
|
|
24
|
+
* escalates to a delete, which is the correct posture for a thing chant did
|
|
25
|
+
* not read a verdict for.
|
|
26
|
+
*
|
|
27
|
+
* That places terraform on the trusted-state-file row of the third axis in
|
|
28
|
+
* docs/src/content/docs/concepts/lifecycle-models.mdx, where chant elsewhere
|
|
29
|
+
* sits on the live-marker row. `docs/pages/observation.mdx` is the reader's
|
|
30
|
+
* version of this paragraph.
|
|
31
|
+
*
|
|
32
|
+
* ## What is readable at all
|
|
33
|
+
*
|
|
34
|
+
* `values.root_module.resources[]` (and, recursively, `child_modules[]`)
|
|
35
|
+
* carries `resource` and `data` blocks. A `terraform`, `provider`, `variable`,
|
|
36
|
+
* `output` or `locals` block has no row there and reads `unsupported-kind`.
|
|
37
|
+
* That is honest, not a gap to close by inventing an address for something
|
|
38
|
+
* that has none.
|
|
39
|
+
*
|
|
40
|
+
* ## Tri-state (#1089)
|
|
41
|
+
*
|
|
42
|
+
* A root whose `init` or `show` fails reports EVERY entity declared in that
|
|
43
|
+
* root as not-observed with reason `read-failed` and the root named, never
|
|
44
|
+
* absent. A failed read must never render as a list of creates. Roots are
|
|
45
|
+
* read independently and merged (`mergeObservations`), so one broken backend
|
|
46
|
+
* does not un-observe a root that answered.
|
|
47
|
+
*
|
|
48
|
+
* ## Nothing from `values` is surfaced
|
|
49
|
+
*
|
|
50
|
+
* A state row's `values` are the resource's full attribute set, secrets
|
|
51
|
+
* included, and `sensitive_values` describes only what the configuration
|
|
52
|
+
* declared sensitive. So the attributes reported here are the row's identity
|
|
53
|
+
* (address, type, mode, provider, root) and nothing from `values` except the
|
|
54
|
+
* `id`, which is the physical id every observation carries.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import type { DescribeResourcesResult } from "@intentius/chant/lexicon";
|
|
58
|
+
import {
|
|
59
|
+
mergeObservations,
|
|
60
|
+
normalizeObservation,
|
|
61
|
+
observation,
|
|
62
|
+
observeEntities,
|
|
63
|
+
type DeclaredEntity,
|
|
64
|
+
type EntityObservation,
|
|
65
|
+
type ObserverAdapter,
|
|
66
|
+
} from "@intentius/chant/observation";
|
|
67
|
+
import { terraformInit, terraformShow } from "./op/activities/terraform";
|
|
68
|
+
import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE } from "./hcl/parse";
|
|
69
|
+
|
|
70
|
+
// The channel keys live in their own module so `plugin.ts` can declare
|
|
71
|
+
// `ownershipChannel` without loading this reader. Re-exported here because
|
|
72
|
+
// this is where they are used.
|
|
73
|
+
export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
|
|
74
|
+
|
|
75
|
+
/** One row out of `values.root_module.resources[]`, at any module depth. */
|
|
76
|
+
export interface StateResourceRow {
|
|
77
|
+
/** Fully qualified terraform address, `module.<name>.` prefixes included. */
|
|
78
|
+
address: string;
|
|
79
|
+
/** `managed` for a `resource` block, `data` for a `data` block. */
|
|
80
|
+
mode?: string;
|
|
81
|
+
/** Resource type, e.g. `null_resource`. */
|
|
82
|
+
type?: string;
|
|
83
|
+
/** Provider that owns the row, e.g. `registry.terraform.io/hashicorp/null`. */
|
|
84
|
+
providerName?: string;
|
|
85
|
+
/** `values.id`, when the row carries a string id. Nothing else from `values` is read. */
|
|
86
|
+
id?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What one root's state read produced. */
|
|
90
|
+
export interface StateIndex {
|
|
91
|
+
/** Every resource/data row, keyed by fully qualified address. */
|
|
92
|
+
rows: Map<string, StateResourceRow>;
|
|
93
|
+
/** Every `module.<name>` address the state carries a child module for, at any depth. */
|
|
94
|
+
modules: Set<string>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
99
|
+
? (value as Record<string, unknown>)
|
|
100
|
+
: {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function asArray(value: unknown): unknown[] {
|
|
104
|
+
return Array.isArray(value) ? value : [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function asString(value: unknown): string | undefined {
|
|
108
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Index a `terraform show -json` document by address.
|
|
113
|
+
*
|
|
114
|
+
* Terraform already writes fully qualified addresses inside `child_modules`
|
|
115
|
+
* (`module.cdn.null_resource.edge`, as `src/__fixtures__/show-state.json`
|
|
116
|
+
* records), so the module prefix is applied only when a row's own address is
|
|
117
|
+
* missing it. Belt and braces for an older `format_version`, never a second
|
|
118
|
+
* `module.cdn.` on top of the first.
|
|
119
|
+
*/
|
|
120
|
+
export function indexStateResources(showJson: unknown): StateIndex {
|
|
121
|
+
const rows = new Map<string, StateResourceRow>();
|
|
122
|
+
const modules = new Set<string>();
|
|
123
|
+
|
|
124
|
+
const walk = (module: unknown, prefix: string): void => {
|
|
125
|
+
const node = asRecord(module);
|
|
126
|
+
for (const entry of asArray(node.resources)) {
|
|
127
|
+
const row = asRecord(entry);
|
|
128
|
+
const raw = asString(row.address);
|
|
129
|
+
if (!raw) continue;
|
|
130
|
+
const address = prefix && !raw.startsWith(`${prefix}.`) ? `${prefix}.${raw}` : raw;
|
|
131
|
+
rows.set(address, {
|
|
132
|
+
address,
|
|
133
|
+
...(asString(row.mode) ? { mode: asString(row.mode) } : {}),
|
|
134
|
+
...(asString(row.type) ? { type: asString(row.type) } : {}),
|
|
135
|
+
...(asString(row.provider_name) ? { providerName: asString(row.provider_name) } : {}),
|
|
136
|
+
...(asString(asRecord(row.values).id) ? { id: asString(asRecord(row.values).id) } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
for (const entry of asArray(node.child_modules)) {
|
|
140
|
+
const child = asRecord(entry);
|
|
141
|
+
const address = asString(child.address) ?? prefix;
|
|
142
|
+
if (address) modules.add(address);
|
|
143
|
+
walk(child, address ?? "");
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
walk(asRecord(asRecord(showJson).values).root_module, "");
|
|
148
|
+
return { rows, modules };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The ownership verdict for one declared address: `owned` when the state
|
|
153
|
+
* carries a row for it, `unknown` otherwise. Exported because it is the whole
|
|
154
|
+
* of terraform's ownership channel, and a channel with one rule deserves one
|
|
155
|
+
* function to point at.
|
|
156
|
+
*/
|
|
157
|
+
export function classifyStateOwnership(address: string, index: StateIndex): "owned" | "unknown" {
|
|
158
|
+
return index.rows.has(address) ? "owned" : "unknown";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when the state carries at least one resource beneath `module.<name>`. */
|
|
162
|
+
function moduleIsLive(address: string, index: StateIndex): boolean {
|
|
163
|
+
if (index.modules.has(address)) return true;
|
|
164
|
+
for (const key of index.rows.keys()) if (key.startsWith(`${address}.`)) return true;
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The activity pair this reader drives. Injectable so tests never run terraform. */
|
|
169
|
+
export interface TerraformReadDeps {
|
|
170
|
+
init: typeof terraformInit;
|
|
171
|
+
show: typeof terraformShow;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const REAL_DEPS: TerraformReadDeps = { init: terraformInit, show: terraformShow };
|
|
175
|
+
|
|
176
|
+
/** A declared entity, plus the root and address `buildRoots()` recorded on it. */
|
|
177
|
+
interface TerraformDeclared extends DeclaredEntity {
|
|
178
|
+
root: string;
|
|
179
|
+
address: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Split a `<root>/<address>` entity key. Only a fallback: `buildRoots()`
|
|
184
|
+
* records both on `props`, and a duplicated address is keyed `…~2` there, so
|
|
185
|
+
* the props are the reliable source.
|
|
186
|
+
*/
|
|
187
|
+
function fromEntityName(name: string): { root: string; address: string } {
|
|
188
|
+
const slash = name.indexOf("/");
|
|
189
|
+
if (slash === -1) return { root: "", address: name };
|
|
190
|
+
return { root: name.slice(0, slash), address: name.slice(slash + 1).replace(/~\d+$/, "") };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function declaredOf(name: string, entity: { entityType: string; props: Record<string, unknown> } | undefined): TerraformDeclared {
|
|
194
|
+
const props = entity?.props ?? {};
|
|
195
|
+
const fallback = fromEntityName(name);
|
|
196
|
+
return {
|
|
197
|
+
name,
|
|
198
|
+
type: entity?.entityType ?? "",
|
|
199
|
+
props,
|
|
200
|
+
root: asString(props.root) ?? fallback.root,
|
|
201
|
+
address: asString(props.address) ?? fallback.address,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One root's reader: `init`, then `show` over state, then address matching. */
|
|
206
|
+
function adapter(root: string, cwd: string | undefined, deps: TerraformReadDeps): ObserverAdapter<StateIndex> {
|
|
207
|
+
const where = cwd ? { cwd } : {};
|
|
208
|
+
let dir: string | undefined;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
async bind(): Promise<StateIndex> {
|
|
212
|
+
// `init` first: `show` against an uninitialized root reports no state at
|
|
213
|
+
// all, which would read as "everything is absent", the exact failure
|
|
214
|
+
// the tri-state exists to prevent.
|
|
215
|
+
const initialized = await deps.init({ root, ...where });
|
|
216
|
+
dir = initialized.dir;
|
|
217
|
+
const shown = await deps.show({ root, ...where });
|
|
218
|
+
dir = shown.dir;
|
|
219
|
+
return indexStateResources(shown.json);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
classifyBindFailure(err) {
|
|
223
|
+
const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
224
|
+
return {
|
|
225
|
+
reason: "read-failed",
|
|
226
|
+
detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: ${message}`,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async read(index, entity): Promise<EntityObservation> {
|
|
231
|
+
const { address } = entity as TerraformDeclared;
|
|
232
|
+
const queried = `terraform show -json (root "${root}", address "${address}")`;
|
|
233
|
+
|
|
234
|
+
if (entity.type === RESOURCE_TYPE || entity.type === DATA_TYPE) {
|
|
235
|
+
const row = index.rows.get(address);
|
|
236
|
+
if (!row) return { absent: true, queried };
|
|
237
|
+
return {
|
|
238
|
+
present: {
|
|
239
|
+
type: entity.type,
|
|
240
|
+
physicalId: row.id ?? address,
|
|
241
|
+
status: row.mode ?? "managed",
|
|
242
|
+
ownership: classifyStateOwnership(address, index),
|
|
243
|
+
attributes: {
|
|
244
|
+
address,
|
|
245
|
+
root,
|
|
246
|
+
...(row.type ? { resourceType: row.type } : {}),
|
|
247
|
+
...(row.mode ? { mode: row.mode } : {}),
|
|
248
|
+
...(row.providerName ? { provider: row.providerName } : {}),
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
queried,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (entity.type === MODULE_TYPE) {
|
|
256
|
+
if (!moduleIsLive(address, index)) return { absent: true, queried };
|
|
257
|
+
// The state has resources under this module but no row for the block
|
|
258
|
+
// itself, so state membership, which is the whole ownership channel
|
|
259
|
+
// here, has nothing to say about it. See the module doc.
|
|
260
|
+
return {
|
|
261
|
+
present: {
|
|
262
|
+
type: entity.type,
|
|
263
|
+
physicalId: address,
|
|
264
|
+
status: "module",
|
|
265
|
+
ownership: classifyStateOwnership(address, index),
|
|
266
|
+
attributes: { address, root },
|
|
267
|
+
},
|
|
268
|
+
queried,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
unobserved: {
|
|
274
|
+
reason: "unsupported-kind",
|
|
275
|
+
detail: `${entity.type} has no row in terraform state: only resource and data blocks do`,
|
|
276
|
+
},
|
|
277
|
+
queried,
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface DescribeResourcesOptions {
|
|
284
|
+
environment: string;
|
|
285
|
+
buildOutput: string;
|
|
286
|
+
entityNames: string[];
|
|
287
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
288
|
+
/**
|
|
289
|
+
* Restrict to state-backed entities (#1348). A withheld entity is
|
|
290
|
+
* `filtered`, never a silent drop into `absent`: a live module block still
|
|
291
|
+
* exists, chant just has no state row saying it is managed.
|
|
292
|
+
*/
|
|
293
|
+
owned?: boolean;
|
|
294
|
+
/**
|
|
295
|
+
* Directory the activities start the `chant.config.*` search from. Default:
|
|
296
|
+
* the running process's cwd. Same meaning as `TerraformWatchOpConfig.cwd`.
|
|
297
|
+
*/
|
|
298
|
+
cwd?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Observe every declared terraform entity, one `init` + `show` per configured
|
|
303
|
+
* root. Roots are read independently, so a broken backend on one never
|
|
304
|
+
* un-observes another (`mergeObservations`).
|
|
305
|
+
*/
|
|
306
|
+
export async function describeResources(
|
|
307
|
+
options: DescribeResourcesOptions,
|
|
308
|
+
deps: TerraformReadDeps = REAL_DEPS,
|
|
309
|
+
): Promise<DescribeResourcesResult> {
|
|
310
|
+
const byRoot = new Map<string, TerraformDeclared[]>();
|
|
311
|
+
const rootless: TerraformDeclared[] = [];
|
|
312
|
+
|
|
313
|
+
for (const name of options.entityNames) {
|
|
314
|
+
const declared = declaredOf(name, options.entities.get(name));
|
|
315
|
+
if (!declared.root) {
|
|
316
|
+
rootless.push(declared);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const bucket = byRoot.get(declared.root);
|
|
320
|
+
if (bucket) bucket.push(declared);
|
|
321
|
+
else byRoot.set(declared.root, [declared]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const parts = [];
|
|
325
|
+
for (const [root, declared] of byRoot) {
|
|
326
|
+
parts.push(normalizeObservation(await observeEntities(declared, adapter(root, options.cwd, deps))));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const merged = mergeObservations(parts);
|
|
330
|
+
const resources = { ...merged.resources };
|
|
331
|
+
const unobserved = { ...merged.unobserved };
|
|
332
|
+
|
|
333
|
+
// An entity carrying no root came from somewhere other than `buildRoots()`;
|
|
334
|
+
// there is no state file to look it up in, and saying so is not absence.
|
|
335
|
+
for (const entity of rootless) {
|
|
336
|
+
unobserved[entity.name] = {
|
|
337
|
+
...(entity.type ? { type: entity.type } : {}),
|
|
338
|
+
reason: "unsupported-kind",
|
|
339
|
+
detail: "no `terraform.roots` entry on this entity, so there is no root module state to read it from",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (options.owned) {
|
|
344
|
+
for (const [name, meta] of Object.entries(merged.resources)) {
|
|
345
|
+
if (meta.ownership === "owned") continue;
|
|
346
|
+
delete resources[name];
|
|
347
|
+
unobserved[name] = {
|
|
348
|
+
type: meta.type,
|
|
349
|
+
reason: "filtered",
|
|
350
|
+
detail: "the root's state carries no row for this address and --owned was requested",
|
|
351
|
+
...(merged.queried[name] ? { queried: merged.queried[name] } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return observation(resources, unobserved, merged.queried, merged.notes);
|
|
357
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/src/hcl/parse.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one shared HCL parse for the terraform lexicon.
|
|
3
|
+
*
|
|
4
|
+
* Two entry points, one entity shape. `parseTerraformRootDir` reads the `.tf`
|
|
5
|
+
* files of a directory (non-recursive, matching Terraform's own module
|
|
6
|
+
* scoping) and is what `buildRoots()` calls. `parseTerraformRootContent` takes
|
|
7
|
+
* the joined-file string `chant audit` hands a lexicon for a discovered root
|
|
8
|
+
* module: every `.tf` in filename order behind a `# file: <name>` line comment.
|
|
9
|
+
* Both funnel through `blocksToEntities`, so an entity the audit path sees is
|
|
10
|
+
* the entity the build path sees.
|
|
11
|
+
*
|
|
12
|
+
* Core owns the parser glue: `loadHcl2json()` lazy-loads `@cdktf/hcl2json`
|
|
13
|
+
* (a ~1.8 MB wasm blob) and raises a one-line install hint when it is absent.
|
|
14
|
+
* `parseTerraformDir()` next to it is NOT reused: it returns carve's `TfGraph`,
|
|
15
|
+
* a scoring-and-excision shape with no room for the per-block bodies a
|
|
16
|
+
* serializer and the post-synth checks read. The import path is the wildcard
|
|
17
|
+
* core's exports map already carries (`"./*"` to `./src/*.ts`), so nothing
|
|
18
|
+
* changed in `packages/core/package.json` for this.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { loadHcl2json, type Hcl2Json } from "@intentius/chant/terraform/parse";
|
|
24
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
25
|
+
|
|
26
|
+
/** A parsed HCL block body, as `@cdktf/hcl2json` encodes it. */
|
|
27
|
+
export type BlockBody = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
/** The entity every block becomes. `props` is what post-synth checks read. */
|
|
30
|
+
export interface TerraformEntity extends Declarable {
|
|
31
|
+
readonly lexicon: "terraform";
|
|
32
|
+
readonly kind: "resource";
|
|
33
|
+
readonly props: {
|
|
34
|
+
/** Terraform address, e.g. `aws_s3_bucket.assets`, `var.region`, `module.cdn`. */
|
|
35
|
+
readonly address: string;
|
|
36
|
+
/** The block body, verbatim from hcl2json (interpolations survive as `"${...}"`). */
|
|
37
|
+
readonly body: BlockBody;
|
|
38
|
+
/** File the block came from, as named by the parse input. */
|
|
39
|
+
readonly file: string;
|
|
40
|
+
/** Configured root name this block belongs to. */
|
|
41
|
+
readonly root: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `entityType` per block kind. */
|
|
46
|
+
export const TERRAFORM_TYPE = "Terraform::Terraform";
|
|
47
|
+
export const PROVIDER_TYPE = "Terraform::Provider";
|
|
48
|
+
export const RESOURCE_TYPE = "Terraform::Resource";
|
|
49
|
+
export const DATA_TYPE = "Terraform::Data";
|
|
50
|
+
export const MODULE_TYPE = "Terraform::Module";
|
|
51
|
+
export const VARIABLE_TYPE = "Terraform::Variable";
|
|
52
|
+
export const OUTPUT_TYPE = "Terraform::Output";
|
|
53
|
+
export const LOCALS_TYPE = "Terraform::Locals";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build one entity. Written out rather than run through `createResource`
|
|
57
|
+
* from `@intentius/chant/runtime`: that factory is for generated resource
|
|
58
|
+
* classes and hides `props` behind a non-enumerable descriptor plus an
|
|
59
|
+
* `attrMap` of `AttrRef`s this lexicon has nothing to put in. The literal
|
|
60
|
+
* carries the same marker, so `isDeclarable()` and `isResourceDeclarable()`
|
|
61
|
+
* both hold.
|
|
62
|
+
*/
|
|
63
|
+
export function terraformEntity(
|
|
64
|
+
entityType: string,
|
|
65
|
+
address: string,
|
|
66
|
+
body: BlockBody,
|
|
67
|
+
file: string,
|
|
68
|
+
root: string,
|
|
69
|
+
): TerraformEntity {
|
|
70
|
+
return {
|
|
71
|
+
[DECLARABLE_MARKER]: true,
|
|
72
|
+
lexicon: "terraform",
|
|
73
|
+
entityType,
|
|
74
|
+
kind: "resource",
|
|
75
|
+
props: { address, body, file, root },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One `.tf` file's name and source. */
|
|
80
|
+
export interface TerraformFile {
|
|
81
|
+
name: string;
|
|
82
|
+
source: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The `# file: <name>` boundary `chant audit` writes between joined `.tf` files. */
|
|
86
|
+
const FILE_MARKER = /^# file: (.+)$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split the joined form back into files. A string with no marker at all is one
|
|
90
|
+
* anonymous file, so a caller that hands over a single `.tf` still parses.
|
|
91
|
+
*/
|
|
92
|
+
export function splitBundleContent(content: string, fallbackName = "main.tf"): TerraformFile[] {
|
|
93
|
+
const files: TerraformFile[] = [];
|
|
94
|
+
let current: TerraformFile | undefined;
|
|
95
|
+
for (const line of content.split("\n")) {
|
|
96
|
+
const marker = FILE_MARKER.exec(line);
|
|
97
|
+
if (marker) {
|
|
98
|
+
current = { name: marker[1].trim(), source: "" };
|
|
99
|
+
files.push(current);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!current) {
|
|
103
|
+
current = { name: fallbackName, source: "" };
|
|
104
|
+
files.push(current);
|
|
105
|
+
}
|
|
106
|
+
current.source += current.source === "" ? line : `\n${line}`;
|
|
107
|
+
}
|
|
108
|
+
return files.filter((f) => f.source.trim() !== "");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every `.tf` directly under `dir`, in filename order. Non-recursive. */
|
|
112
|
+
export function listTerraformFiles(dir: string): string[] {
|
|
113
|
+
return readdirSync(dir)
|
|
114
|
+
.filter((f) => f.endsWith(".tf"))
|
|
115
|
+
.sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asArray(value: unknown): unknown[] {
|
|
119
|
+
return Array.isArray(value) ? value : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
123
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
124
|
+
? (value as Record<string, unknown>)
|
|
125
|
+
: {};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse a set of files into entities keyed `<root>/<address>`. Two blocks that
|
|
130
|
+
* genuinely share an address (two `locals` blocks, or the same address in two
|
|
131
|
+
* files of one root) are numbered `~2`, `~3` rather than overwriting.
|
|
132
|
+
*/
|
|
133
|
+
export async function blocksToEntities(
|
|
134
|
+
files: readonly TerraformFile[],
|
|
135
|
+
root: string,
|
|
136
|
+
hcl2json?: Hcl2Json,
|
|
137
|
+
): Promise<Map<string, Declarable>> {
|
|
138
|
+
const parser = hcl2json ?? (await loadHcl2json());
|
|
139
|
+
const entities = new Map<string, Declarable>();
|
|
140
|
+
|
|
141
|
+
const add = (entityType: string, address: string, body: unknown, file: string): void => {
|
|
142
|
+
const entity = terraformEntity(
|
|
143
|
+
entityType,
|
|
144
|
+
address,
|
|
145
|
+
(typeof body === "object" && body !== null ? body : {}) as BlockBody,
|
|
146
|
+
file,
|
|
147
|
+
root,
|
|
148
|
+
);
|
|
149
|
+
let key = `${root}/${address}`;
|
|
150
|
+
for (let n = 2; entities.has(key); n++) key = `${root}/${address}~${n}`;
|
|
151
|
+
entities.set(key, entity);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** `terraform` and `locals` carry no labels: the tree holds a bare body array. */
|
|
155
|
+
const unlabelled = (
|
|
156
|
+
tree: Record<string, unknown>,
|
|
157
|
+
section: string,
|
|
158
|
+
entityType: string,
|
|
159
|
+
file: string,
|
|
160
|
+
): void => {
|
|
161
|
+
for (const body of asArray(tree[section])) add(entityType, section, body, file);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** `provider`, `module`, `variable`, `output`: one label, so `Record<name, body[]>`. */
|
|
165
|
+
const oneLabel = (
|
|
166
|
+
tree: Record<string, unknown>,
|
|
167
|
+
section: string,
|
|
168
|
+
entityType: string,
|
|
169
|
+
address: (name: string) => string,
|
|
170
|
+
file: string,
|
|
171
|
+
): void => {
|
|
172
|
+
for (const [name, bodies] of Object.entries(asRecord(tree[section]))) {
|
|
173
|
+
for (const body of asArray(bodies)) add(entityType, address(name), body, file);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** `resource`, `data`: two labels, so `Record<type, Record<name, body[]>>`. */
|
|
178
|
+
const twoLabels = (
|
|
179
|
+
tree: Record<string, unknown>,
|
|
180
|
+
section: string,
|
|
181
|
+
entityType: string,
|
|
182
|
+
address: (type: string, name: string) => string,
|
|
183
|
+
file: string,
|
|
184
|
+
): void => {
|
|
185
|
+
for (const [type, named] of Object.entries(asRecord(tree[section]))) {
|
|
186
|
+
for (const [name, bodies] of Object.entries(asRecord(named))) {
|
|
187
|
+
for (const body of asArray(bodies)) add(entityType, address(type, name), body, file);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const tree = (await parser.parse(file.name, file.source)) as Record<string, unknown>;
|
|
194
|
+
unlabelled(tree, "terraform", TERRAFORM_TYPE, file.name);
|
|
195
|
+
unlabelled(tree, "locals", LOCALS_TYPE, file.name);
|
|
196
|
+
oneLabel(tree, "provider", PROVIDER_TYPE, (n) => `provider.${n}`, file.name);
|
|
197
|
+
oneLabel(tree, "module", MODULE_TYPE, (n) => `module.${n}`, file.name);
|
|
198
|
+
oneLabel(tree, "variable", VARIABLE_TYPE, (n) => `var.${n}`, file.name);
|
|
199
|
+
oneLabel(tree, "output", OUTPUT_TYPE, (n) => `output.${n}`, file.name);
|
|
200
|
+
twoLabels(tree, "resource", RESOURCE_TYPE, (t, n) => `${t}.${n}`, file.name);
|
|
201
|
+
twoLabels(tree, "data", DATA_TYPE, (t, n) => `data.${t}.${n}`, file.name);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return entities;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse a root module directory. Reads every `.tf` directly under `dir`.
|
|
209
|
+
* Throws whatever the parser throws for malformed HCL; `buildRoots()` is where
|
|
210
|
+
* that becomes a warning.
|
|
211
|
+
*/
|
|
212
|
+
export async function parseTerraformRootDir(
|
|
213
|
+
dir: string,
|
|
214
|
+
root: string,
|
|
215
|
+
hcl2json?: Hcl2Json,
|
|
216
|
+
): Promise<Map<string, Declarable>> {
|
|
217
|
+
const files = listTerraformFiles(dir).map((name) => ({
|
|
218
|
+
name,
|
|
219
|
+
source: readFileSync(join(dir, name), "utf-8"),
|
|
220
|
+
}));
|
|
221
|
+
return blocksToEntities(files, root, hcl2json);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parse the joined-file string form `chant audit` produces for a discovered
|
|
226
|
+
* root module (`AuditInput.content`). Unwired for now: #2085's
|
|
227
|
+
* `auditEntities()` is what calls it.
|
|
228
|
+
*/
|
|
229
|
+
export async function parseTerraformRootContent(
|
|
230
|
+
content: string,
|
|
231
|
+
root: string,
|
|
232
|
+
hcl2json?: Hcl2Json,
|
|
233
|
+
): Promise<Map<string, Declarable>> {
|
|
234
|
+
return blocksToEntities(splitBundleContent(content), root, hcl2json);
|
|
235
|
+
}
|
package/src/hcl/roots.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terraform build roots.
|
|
3
|
+
*
|
|
4
|
+
* An estate that keeps its `.tf` tree has no typed chant source for it. What
|
|
5
|
+
* it can declare is the directory: each entry in `terraform.roots` names a
|
|
6
|
+
* root module that parses at build time into entities, so the blocks are
|
|
7
|
+
* serialized into the build output and seen by the post-synth checks. Same
|
|
8
|
+
* shape `lexicons/k8s/src/kustomize/root.ts` uses for kustomize overlays: the
|
|
9
|
+
* render lives here, the plugin's `buildRoots()` member stays thin.
|
|
10
|
+
*
|
|
11
|
+
* Unlike the kustomize renderer, a missing directory or a `.tf` the parser
|
|
12
|
+
* refuses is a warning and zero entities for that root, never a throw. Reading
|
|
13
|
+
* someone else's estate is the whole job here, and half of it parsing is more
|
|
14
|
+
* useful than none of it, especially when the audit path (#2085) walks
|
|
15
|
+
* repositories it did not write.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { isAbsolute, resolve } from "node:path";
|
|
20
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
21
|
+
import type { Hcl2Json } from "@intentius/chant/terraform/parse";
|
|
22
|
+
import type { TerraformRootConfig } from "../config";
|
|
23
|
+
import { parseTerraformRootDir } from "./parse";
|
|
24
|
+
|
|
25
|
+
export interface TerraformRootsResult {
|
|
26
|
+
entities: Map<string, Declarable>;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RenderTerraformRootsOptions {
|
|
31
|
+
/** Directory the project config was loaded from; relative `dir` resolves against it. */
|
|
32
|
+
projectRoot: string;
|
|
33
|
+
/** `terraform.roots`: name to root-module config. */
|
|
34
|
+
roots: Readonly<Record<string, TerraformRootConfig>>;
|
|
35
|
+
/** Injectable parser (tests); defaults to core's lazy-loaded `@cdktf/hcl2json`. */
|
|
36
|
+
hcl2json?: Hcl2Json;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse each configured root into entities keyed `<root>/<address>`. Roots are
|
|
41
|
+
* visited in declaration order, and one root's failure never stops the next.
|
|
42
|
+
*/
|
|
43
|
+
export async function renderTerraformRoots(
|
|
44
|
+
opts: RenderTerraformRootsOptions,
|
|
45
|
+
): Promise<TerraformRootsResult> {
|
|
46
|
+
const entities = new Map<string, Declarable>();
|
|
47
|
+
const warnings: string[] = [];
|
|
48
|
+
|
|
49
|
+
for (const [name, root] of Object.entries(opts.roots)) {
|
|
50
|
+
const dir = isAbsolute(root.dir) ? root.dir : resolve(opts.projectRoot, root.dir);
|
|
51
|
+
|
|
52
|
+
if (!existsSync(dir)) {
|
|
53
|
+
warnings.push(`terraform.roots.${name}: directory not found at ${dir}, no entities contributed`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const parsed = await parseTerraformRootDir(dir, name, opts.hcl2json);
|
|
59
|
+
for (const [key, entity] of parsed) entities.set(key, entity);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
warnings.push(`terraform.roots.${name}: could not parse ${dir}, ${message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { entities, warnings };
|
|
67
|
+
}
|