nexarch 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ingest-infra.js +194 -61
- package/dist/commands/init-agent.js +3 -3
- package/dist/commands/init-project-infra.js +22 -20
- package/dist/commands/init-project.js +124 -5
- package/dist/index.js +1 -0
- package/dist/lib/ansible-detect.js +109 -0
- package/dist/lib/ansible-projection.js +150 -0
- package/dist/lib/iac-detect.js +414 -0
- package/dist/lib/iac-projection.js +231 -0
- package/dist/lib/iac-tool.js +138 -0
- package/dist/lib/skills.js +32 -10
- package/package.json +2 -2
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { join, sep } from "path";
|
|
3
|
+
const SCAN_DEPTH = 8;
|
|
4
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", ".terraform", "dist", "build", ".next"]);
|
|
5
|
+
/**
|
|
6
|
+
* Path segments whose contents demonstrate Terraform rather than run it.
|
|
7
|
+
*
|
|
8
|
+
* An `examples/complete` directory configures a provider and looks exactly like
|
|
9
|
+
* an estate root by every local signal — which is why terraform-aws-vpc
|
|
10
|
+
* reported thirteen of them as environments while missing the modules that are
|
|
11
|
+
* the point of the repository. Worse than missing the real architecture: the
|
|
12
|
+
* examples are mutually exclusive demonstrations, so graphing them together
|
|
13
|
+
* describes an estate that has never existed anywhere.
|
|
14
|
+
*/
|
|
15
|
+
const NON_ESTATE_SEGMENT = /(^|[\\/])(examples?|docs?|test|tests|fixtures?|testdata|_example|sample|samples)([\\/]|$)/i;
|
|
16
|
+
function collectTerraformFiles(dir, depth = 0, out = []) {
|
|
17
|
+
if (depth > SCAN_DEPTH)
|
|
18
|
+
return out;
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(dir);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (IGNORED_DIRS.has(entry))
|
|
28
|
+
continue;
|
|
29
|
+
const full = join(dir, entry);
|
|
30
|
+
let isDir = false;
|
|
31
|
+
try {
|
|
32
|
+
isDir = statSync(full).isDirectory();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (isDir)
|
|
38
|
+
collectTerraformFiles(full, depth + 1, out);
|
|
39
|
+
else if (entry.endsWith(".tf"))
|
|
40
|
+
out.push(full);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function detectCiSystem(dir) {
|
|
45
|
+
if (existsSync(join(dir, ".gitlab-ci.yml")))
|
|
46
|
+
return { ciSystem: "gitlab", ciFile: ".gitlab-ci.yml" };
|
|
47
|
+
if (existsSync(join(dir, ".github", "workflows")))
|
|
48
|
+
return { ciSystem: "github", ciFile: ".github/workflows" };
|
|
49
|
+
const seen = [];
|
|
50
|
+
const visit = (current, depth) => {
|
|
51
|
+
if (depth > 3 || seen.length > 0)
|
|
52
|
+
return;
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(current);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (IGNORED_DIRS.has(entry))
|
|
62
|
+
continue;
|
|
63
|
+
const full = join(current, entry);
|
|
64
|
+
let isDir = false;
|
|
65
|
+
try {
|
|
66
|
+
isDir = statSync(full).isDirectory();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (isDir) {
|
|
72
|
+
visit(full, depth + 1);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const relative = full.slice(dir.length + 1).split(sep).join("/");
|
|
76
|
+
if (/azure-pipelines.*\.ya?ml$/i.test(entry)) {
|
|
77
|
+
seen.push({ ciSystem: "azure-pipelines", ciFile: relative });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (entry === ".gitlab-ci.yml") {
|
|
81
|
+
seen.push({ ciSystem: "gitlab", ciFile: relative });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
visit(dir, 0);
|
|
87
|
+
return seen[0] ?? { ciSystem: null, ciFile: null };
|
|
88
|
+
}
|
|
89
|
+
/** Collects reusable module directories wherever they live, not only at the root. */
|
|
90
|
+
function collectModuleDirectories(dir, depth = 0, out = []) {
|
|
91
|
+
if (depth > 3)
|
|
92
|
+
return out;
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = readdirSync(dir);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (IGNORED_DIRS.has(entry))
|
|
102
|
+
continue;
|
|
103
|
+
const full = join(dir, entry);
|
|
104
|
+
try {
|
|
105
|
+
if (!statSync(full).isDirectory())
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (entry === "modules") {
|
|
112
|
+
// The children of a modules directory are the reusable building blocks:
|
|
113
|
+
// network, database, keyvault, and so on — the vocabulary of the estate.
|
|
114
|
+
try {
|
|
115
|
+
for (const child of readdirSync(full)) {
|
|
116
|
+
if (statSync(join(full, child)).isDirectory())
|
|
117
|
+
out.push(child);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Unreadable modules directory is not fatal.
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
collectModuleDirectories(full, depth + 1, out);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Recognises a repository that publishes reusable modules rather than running an estate.
|
|
131
|
+
*
|
|
132
|
+
* The Terraform registry convention is a module at the repository root —
|
|
133
|
+
* `main.tf` with `variables.tf` and `outputs.tf` beside it — deliberately
|
|
134
|
+
* configuring no provider and no backend, because both are supplied by whoever
|
|
135
|
+
* calls it. Every signal that identifies an estate is therefore absent by
|
|
136
|
+
* design, and the signals that *are* present belong to the examples directory.
|
|
137
|
+
*
|
|
138
|
+
* This matters more than tidiness. terraform-aws-vpc has no estate to ingest:
|
|
139
|
+
* asking it for one and getting thirteen mutually exclusive demonstrations is
|
|
140
|
+
* worse than getting nothing, because nothing is honest.
|
|
141
|
+
*/
|
|
142
|
+
function detectPublishedModules(dir) {
|
|
143
|
+
const rootFiles = (() => {
|
|
144
|
+
try {
|
|
145
|
+
return readdirSync(dir);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
const hasRootModule = rootFiles.includes("variables.tf") && rootFiles.includes("outputs.tf") && rootFiles.some((f) => f.endsWith(".tf") && f !== "variables.tf" && f !== "outputs.tf");
|
|
152
|
+
const modules = [];
|
|
153
|
+
const modulesDir = join(dir, "modules");
|
|
154
|
+
try {
|
|
155
|
+
for (const child of readdirSync(modulesDir)) {
|
|
156
|
+
const full = join(modulesDir, child);
|
|
157
|
+
if (statSync(full).isDirectory() && readdirSync(full).some((f) => f.endsWith(".tf")))
|
|
158
|
+
modules.push(child);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// No modules directory; a single-module repository is still a library.
|
|
163
|
+
}
|
|
164
|
+
return { isLibrary: hasRootModule, modules: modules.sort() };
|
|
165
|
+
}
|
|
166
|
+
const ROOT_SCAN_DEPTH = 4;
|
|
167
|
+
const ENVIRONMENT_DIR = /^(dev|development|test|qa|uat|sit|stag|staging|preprod|pre-prod|prod|production|live|dr)$/i;
|
|
168
|
+
/**
|
|
169
|
+
* Infers an environment from a root module's path.
|
|
170
|
+
*
|
|
171
|
+
* A directory called `environments/prod` is a stronger statement about which
|
|
172
|
+
* environment is being ingested than anything inside the state, and it is
|
|
173
|
+
* available before Terraform is run at all.
|
|
174
|
+
*/
|
|
175
|
+
export function environmentFromPath(relativePath) {
|
|
176
|
+
const segments = relativePath.split(/[\\/]+/).filter(Boolean);
|
|
177
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
178
|
+
if (ENVIRONMENT_DIR.test(segments[i]))
|
|
179
|
+
return segments[i].toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Finds the root modules in a repository.
|
|
185
|
+
*
|
|
186
|
+
* Real estates are not one directory with one state. A repository commonly
|
|
187
|
+
* carries a root module per environment plus separate stacks for shared
|
|
188
|
+
* services, each with its own state, so assuming the current directory is
|
|
189
|
+
* *the* root module fails on the first serious layout it meets.
|
|
190
|
+
*
|
|
191
|
+
* Distinguishing a root from a shared module is the crux: both contain `.tf`
|
|
192
|
+
* files and both may declare `required_providers`. A root is what configures a
|
|
193
|
+
* backend or a provider, is initialised, carries tfvars, or lives under a
|
|
194
|
+
* conventional stacks directory — and never lives under `modules/`.
|
|
195
|
+
*/
|
|
196
|
+
export function discoverRootModules(repoDir) {
|
|
197
|
+
const found = [];
|
|
198
|
+
const visit = (dir, relative, depth) => {
|
|
199
|
+
if (depth > ROOT_SCAN_DEPTH)
|
|
200
|
+
return;
|
|
201
|
+
let entries;
|
|
202
|
+
try {
|
|
203
|
+
entries = readdirSync(dir);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const tfFiles = entries.filter((entry) => entry.endsWith(".tf"));
|
|
209
|
+
const isUnderModules = /(^|[\\/])modules([\\/]|$)/i.test(relative);
|
|
210
|
+
const isDemonstration = NON_ESTATE_SEGMENT.test(relative);
|
|
211
|
+
if (tfFiles.length > 0 && !isUnderModules && !isDemonstration) {
|
|
212
|
+
const signals = [];
|
|
213
|
+
let hasBackend = false;
|
|
214
|
+
let hasLocalBackend = false;
|
|
215
|
+
let hasProvider = false;
|
|
216
|
+
for (const file of tfFiles.slice(0, 25)) {
|
|
217
|
+
let content = "";
|
|
218
|
+
try {
|
|
219
|
+
content = readFileSync(join(dir, file), "utf8");
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
|
|
225
|
+
if (backendMatch) {
|
|
226
|
+
hasBackend = true;
|
|
227
|
+
if (backendMatch[1] === "local")
|
|
228
|
+
hasLocalBackend = true;
|
|
229
|
+
}
|
|
230
|
+
// A provider *configuration*, not a required_providers declaration —
|
|
231
|
+
// shared modules declare requirements but must not configure providers.
|
|
232
|
+
if (/^\s*provider\s+"[a-z0-9_]+"\s*\{/m.test(content))
|
|
233
|
+
hasProvider = true;
|
|
234
|
+
}
|
|
235
|
+
// A `.terraform` directory only proves provider plugins were downloaded.
|
|
236
|
+
// The backend — the thing `terraform show` needs — is initialised when
|
|
237
|
+
// `.terraform/terraform.tfstate` records its configuration. Treating the
|
|
238
|
+
// directory as proof reports a root as ready, the caller runs ingest, and
|
|
239
|
+
// Terraform answers "Backend initialization required" from somewhere the
|
|
240
|
+
// caller was told not to expect it.
|
|
241
|
+
const hasPluginDir = existsSync(join(dir, ".terraform"));
|
|
242
|
+
const initialised = existsSync(join(dir, ".terraform", "terraform.tfstate"));
|
|
243
|
+
const hasTfvars = entries.some((entry) => entry.endsWith(".tfvars") || entry.endsWith(".tfvars.json"));
|
|
244
|
+
const environmentHint = environmentFromPath(relative);
|
|
245
|
+
const underStacksDir = /(^|[\\/])(environments|envs|stacks|live)([\\/]|$)/i.test(relative);
|
|
246
|
+
if (hasBackend)
|
|
247
|
+
signals.push(hasLocalBackend ? "local backend" : "remote backend");
|
|
248
|
+
if (hasProvider)
|
|
249
|
+
signals.push("provider configuration");
|
|
250
|
+
if (initialised)
|
|
251
|
+
signals.push("initialised");
|
|
252
|
+
else if (hasPluginDir)
|
|
253
|
+
signals.push("providers downloaded, backend not initialised");
|
|
254
|
+
if (hasTfvars)
|
|
255
|
+
signals.push("tfvars");
|
|
256
|
+
if (underStacksDir)
|
|
257
|
+
signals.push("stacks directory");
|
|
258
|
+
// A provider block alone no longer qualifies. It is the one signal an
|
|
259
|
+
// example shares with a real estate, and treating it as sufficient is
|
|
260
|
+
// what let demonstration fixtures in. What distinguishes an estate is
|
|
261
|
+
// evidence that state exists or is configured somewhere: a backend, an
|
|
262
|
+
// initialised working directory, or a conventional stacks layout.
|
|
263
|
+
const isEstateRoot = hasBackend || initialised || hasPluginDir || (underStacksDir && (hasTfvars || Boolean(environmentHint)));
|
|
264
|
+
if (isEstateRoot) {
|
|
265
|
+
found.push({ dir, relative: relative || ".", signals, environmentHint, initialised, usesLocalBackend: hasLocalBackend });
|
|
266
|
+
// A root module's subdirectories are its own modules, not further roots.
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const entry of entries) {
|
|
271
|
+
if (IGNORED_DIRS.has(entry))
|
|
272
|
+
continue;
|
|
273
|
+
const full = join(dir, entry);
|
|
274
|
+
try {
|
|
275
|
+
if (!statSync(full).isDirectory())
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
visit(full, relative ? `${relative}/${entry}` : entry, depth + 1);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
visit(repoDir, "", 0);
|
|
285
|
+
return found.sort((a, b) => a.relative.localeCompare(b.relative));
|
|
286
|
+
}
|
|
287
|
+
export function detectInfrastructureProject(dir) {
|
|
288
|
+
const files = collectTerraformFiles(dir);
|
|
289
|
+
const signals = [];
|
|
290
|
+
if (files.length > 0)
|
|
291
|
+
signals.push(`${files.length} Terraform file${files.length === 1 ? "" : "s"}`);
|
|
292
|
+
if (existsSync(join(dir, ".terraform.lock.hcl")))
|
|
293
|
+
signals.push("provider lock file");
|
|
294
|
+
if (existsSync(join(dir, ".terraform")))
|
|
295
|
+
signals.push("initialised working directory");
|
|
296
|
+
let hasRemoteBackend = false;
|
|
297
|
+
let hasProviderBlock = false;
|
|
298
|
+
// The cap guards against a pathological repo, not against a large one: now
|
|
299
|
+
// that the scan reaches the whole tree, a low cap would read only whichever
|
|
300
|
+
// subtree the walk happened to enter first and miss the backend entirely.
|
|
301
|
+
for (const file of files.slice(0, 400)) {
|
|
302
|
+
let content = "";
|
|
303
|
+
try {
|
|
304
|
+
content = readFileSync(file, "utf8");
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// `backend "local"` marks a root module but is emphatically not remote
|
|
310
|
+
// state — a bootstrap stack uses it precisely because it creates the remote
|
|
311
|
+
// backend everything else then uses.
|
|
312
|
+
const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
|
|
313
|
+
if (backendMatch && backendMatch[1] !== "local")
|
|
314
|
+
hasRemoteBackend = true;
|
|
315
|
+
if (/\bprovider\s+"[a-z0-9_]+"\s*\{/.test(content))
|
|
316
|
+
hasProviderBlock = true;
|
|
317
|
+
}
|
|
318
|
+
if (hasRemoteBackend)
|
|
319
|
+
signals.push("remote state backend");
|
|
320
|
+
if (hasProviderBlock)
|
|
321
|
+
signals.push("provider configuration");
|
|
322
|
+
const moduleDirectories = [...new Set(collectModuleDirectories(dir))].sort();
|
|
323
|
+
if (moduleDirectories.length > 0)
|
|
324
|
+
signals.push(`${moduleDirectories.length} reusable modules`);
|
|
325
|
+
// One stray .tf file inside an application repo is not an infrastructure
|
|
326
|
+
// repo. Requiring corroboration keeps the classification from hijacking
|
|
327
|
+
// onboarding for a project that merely ships a snippet of Terraform.
|
|
328
|
+
// Deep layouts keep every .tf file below the two-level scan above, so
|
|
329
|
+
// discovery is consulted as well: a repository whose roots live in
|
|
330
|
+
// infra/environments/<env> is unmistakably an infrastructure repository even
|
|
331
|
+
// though its top two levels contain no Terraform at all.
|
|
332
|
+
const rootModules = discoverRootModules(dir);
|
|
333
|
+
if (rootModules.length > 0) {
|
|
334
|
+
signals.push(`${rootModules.length} root module${rootModules.length === 1 ? "" : "s"}`);
|
|
335
|
+
}
|
|
336
|
+
const library = detectPublishedModules(dir);
|
|
337
|
+
// An estate is claimed only by root modules that survived the tightened
|
|
338
|
+
// acceptance above. A repository with a root module and no estate roots is
|
|
339
|
+
// publishing modules, not running anything.
|
|
340
|
+
const repoKind = rootModules.length > 0 ? "estate" : library.isLibrary ? "module_library" : "unknown";
|
|
341
|
+
if (repoKind === "module_library") {
|
|
342
|
+
signals.push(library.modules.length > 0 ? `publishes ${library.modules.length + 1} modules` : "publishes a reusable module");
|
|
343
|
+
}
|
|
344
|
+
const isInfrastructure = rootModules.length > 0 ||
|
|
345
|
+
repoKind === "module_library" ||
|
|
346
|
+
(files.length > 0 && (files.length >= 3 || hasRemoteBackend || hasProviderBlock || moduleDirectories.length > 0));
|
|
347
|
+
return {
|
|
348
|
+
isInfrastructure,
|
|
349
|
+
rootModules,
|
|
350
|
+
signals,
|
|
351
|
+
iacFileCount: files.length,
|
|
352
|
+
hasRemoteBackend,
|
|
353
|
+
hasProviderBlock,
|
|
354
|
+
moduleDirectories,
|
|
355
|
+
repoKind,
|
|
356
|
+
publishedModules: repoKind === "module_library" ? library.modules : [],
|
|
357
|
+
...detectCiSystem(dir),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The pipeline step to add, for whichever CI system the repo already uses.
|
|
362
|
+
* Onboarding hands this over rather than pointing at documentation: an
|
|
363
|
+
* ingestion command nobody wires up is a graph that lies within a week.
|
|
364
|
+
*/
|
|
365
|
+
export function ciSnippetFor(ciSystem, ciFile = null, executable = "terraform") {
|
|
366
|
+
if (ciSystem === "github") {
|
|
367
|
+
return {
|
|
368
|
+
file: ".github/workflows/terraform.yml (in the apply job)",
|
|
369
|
+
snippet: [
|
|
370
|
+
" - name: Sync architecture graph",
|
|
371
|
+
" run: |",
|
|
372
|
+
" terraform show -json > tfstate.json",
|
|
373
|
+
" npx nexarch@latest ingest-infra --state tfstate.json",
|
|
374
|
+
" env:",
|
|
375
|
+
" NEXARCH_TOKEN: ${{ secrets.NEXARCH_TOKEN }}",
|
|
376
|
+
].join("\n"),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (ciSystem === "azure-pipelines") {
|
|
380
|
+
return {
|
|
381
|
+
file: `${ciFile ?? "azure-pipelines.yml"} (after the apply step)`,
|
|
382
|
+
snippet: [
|
|
383
|
+
" - script: |",
|
|
384
|
+
" terraform show -json > tfstate.json",
|
|
385
|
+
" npx nexarch@latest ingest-infra --state tfstate.json",
|
|
386
|
+
" displayName: Sync architecture graph",
|
|
387
|
+
" env:",
|
|
388
|
+
" NEXARCH_TOKEN: $(NEXARCH_TOKEN)",
|
|
389
|
+
].join("\n"),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (ciSystem === "gitlab") {
|
|
393
|
+
return {
|
|
394
|
+
file: ciFile ?? ".gitlab-ci.yml",
|
|
395
|
+
snippet: [
|
|
396
|
+
"nexarch-ingest:",
|
|
397
|
+
" stage: .post",
|
|
398
|
+
" script:",
|
|
399
|
+
" - terraform show -json > tfstate.json",
|
|
400
|
+
" - npx nexarch@latest ingest-infra --state tfstate.json",
|
|
401
|
+
" variables:",
|
|
402
|
+
" NEXARCH_TOKEN: $NEXARCH_TOKEN",
|
|
403
|
+
].join("\n"),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
// Nothing was detected. Handing over a GitLab job here — the previous
|
|
407
|
+
// behaviour — states as fact something never observed, and a team on another
|
|
408
|
+
// CI system has to work out why the advice does not fit. The two commands are
|
|
409
|
+
// the part that is actually known; the wrapper is not.
|
|
410
|
+
return {
|
|
411
|
+
file: "your IaC pipeline, after the apply step",
|
|
412
|
+
snippet: [`${executable} show -json > tfstate.json`, "npx nexarch@latest ingest-infra --state tfstate.json"].join("\n"),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terraform state projector (ADR: infrastructure-as-code ingestion, v1).
|
|
3
|
+
*
|
|
4
|
+
* Turns `terraform show -json` into the small, safe projection that ingestion
|
|
5
|
+
* is allowed to send. This is the security-critical component of the feature:
|
|
6
|
+
* state files carry generated passwords, storage keys, connection strings and
|
|
7
|
+
* certificate material in clear text, verified against a real estate.
|
|
8
|
+
*
|
|
9
|
+
* Two independent gates, both deny-by-default:
|
|
10
|
+
*
|
|
11
|
+
* 1. The catalogue names, per resource type, the attributes that may be read.
|
|
12
|
+
* Anything not named is dropped. The catalogue is reference data served by
|
|
13
|
+
* the platform, not a list baked into this CLI, so support for a new Azure
|
|
14
|
+
* service is a data change rather than a release.
|
|
15
|
+
*
|
|
16
|
+
* 2. Terraform's own `sensitive_values` map is consulted for every attribute
|
|
17
|
+
* that survives gate 1, and anything flagged there is dropped even when the
|
|
18
|
+
* catalogue names it. A mistake in the curated list is therefore not on its
|
|
19
|
+
* own sufficient to leak a secret.
|
|
20
|
+
*
|
|
21
|
+
* Values are additionally restricted to scalars and shallow string arrays.
|
|
22
|
+
* Nested blocks are never emitted wholesale: a container app's `secret` block or
|
|
23
|
+
* an env var's `value` can hold credentials, so structure is only ever read by a
|
|
24
|
+
* named extractor that has been reviewed for that purpose.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Parses a state document from raw bytes.
|
|
28
|
+
*
|
|
29
|
+
* PowerShell's `>` redirection writes UTF-16 with a BOM, which is what a Windows
|
|
30
|
+
* user gets from `terraform show -json > state.json` and what a naive reader
|
|
31
|
+
* chokes on. Both endiannesses and plain UTF-8 are accepted.
|
|
32
|
+
*/
|
|
33
|
+
export function parseIacStateBuffer(buffer) {
|
|
34
|
+
let text;
|
|
35
|
+
if (buffer[0] === 0xff && buffer[1] === 0xfe)
|
|
36
|
+
text = buffer.toString("utf16le");
|
|
37
|
+
else if (buffer[0] === 0xfe && buffer[1] === 0xff)
|
|
38
|
+
text = Buffer.from(buffer).swap16().toString("utf16le");
|
|
39
|
+
else
|
|
40
|
+
text = buffer.toString("utf8");
|
|
41
|
+
return JSON.parse(text.replace(/^/, ""));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Infers the environment from resource tags.
|
|
45
|
+
*
|
|
46
|
+
* Estates that tag at all tag consistently — the first real one carried
|
|
47
|
+
* `environment` on every resource — so the dominant value of a conventional tag
|
|
48
|
+
* key beats asking a human to pass a flag correctly in a pipeline. Returns null
|
|
49
|
+
* when there is no clear winner, leaving the caller to fall back to the
|
|
50
|
+
* Terraform workspace or an explicit flag.
|
|
51
|
+
*/
|
|
52
|
+
export function inferEnvironmentFromTags(resources) {
|
|
53
|
+
const TAG_KEYS = ["environment", "env", "Environment", "Env"];
|
|
54
|
+
const counts = new Map();
|
|
55
|
+
for (const resource of resources) {
|
|
56
|
+
for (const key of TAG_KEYS) {
|
|
57
|
+
const value = resource.tags[key];
|
|
58
|
+
if (typeof value !== "string" || !value.trim())
|
|
59
|
+
continue;
|
|
60
|
+
const normalised = value.trim().toLowerCase();
|
|
61
|
+
counts.set(normalised, (counts.get(normalised) ?? 0) + 1);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (counts.size === 0)
|
|
66
|
+
return null;
|
|
67
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
68
|
+
const [environment, hits] = ranked[0];
|
|
69
|
+
const total = ranked.reduce((sum, [, n]) => sum + n, 0);
|
|
70
|
+
return { environment, confidence: hits / total };
|
|
71
|
+
}
|
|
72
|
+
/** Maps a free-text environment name onto an ontology environment subtype. */
|
|
73
|
+
export function environmentSubtypeFor(environment) {
|
|
74
|
+
const name = environment.trim().toLowerCase();
|
|
75
|
+
if (/^(prod|production|live)$/.test(name))
|
|
76
|
+
return "env_production";
|
|
77
|
+
if (/^(stag|staging|preprod|pre-prod|uat)$/.test(name))
|
|
78
|
+
return "env_staging";
|
|
79
|
+
if (/^(test|qa|sit)$/.test(name))
|
|
80
|
+
return "env_test";
|
|
81
|
+
if (/^(dr|failover)$/.test(name))
|
|
82
|
+
return "env_dr";
|
|
83
|
+
return "env_development";
|
|
84
|
+
}
|
|
85
|
+
/** Longest value we will carry for any single attribute. */
|
|
86
|
+
const MAX_VALUE_LENGTH = 400;
|
|
87
|
+
const MAX_ARRAY_ITEMS = 12;
|
|
88
|
+
function flattenResources(module, out = []) {
|
|
89
|
+
if (!module)
|
|
90
|
+
return out;
|
|
91
|
+
for (const resource of module.resources ?? [])
|
|
92
|
+
out.push(resource);
|
|
93
|
+
for (const child of module.child_modules ?? [])
|
|
94
|
+
flattenResources(child, out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* True when Terraform flagged this attribute as sensitive. The map mirrors the
|
|
99
|
+
* shape of `values`, so a `true` anywhere beneath the attribute — an env var's
|
|
100
|
+
* value inside a container template, for instance — disqualifies the whole
|
|
101
|
+
* attribute rather than just the leaf.
|
|
102
|
+
*/
|
|
103
|
+
function isSensitive(sensitiveValues, attribute) {
|
|
104
|
+
if (!sensitiveValues || typeof sensitiveValues !== "object" || Array.isArray(sensitiveValues))
|
|
105
|
+
return false;
|
|
106
|
+
const flag = sensitiveValues[attribute];
|
|
107
|
+
if (flag === undefined)
|
|
108
|
+
return false;
|
|
109
|
+
if (flag === true)
|
|
110
|
+
return true;
|
|
111
|
+
return JSON.stringify(flag).includes("true");
|
|
112
|
+
}
|
|
113
|
+
/** Scalars and shallow string arrays only; anything structural is refused. */
|
|
114
|
+
function safeValue(value) {
|
|
115
|
+
if (value === null || value === undefined)
|
|
116
|
+
return null;
|
|
117
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
118
|
+
return value;
|
|
119
|
+
if (typeof value === "string")
|
|
120
|
+
return value.length > MAX_VALUE_LENGTH ? value.slice(0, MAX_VALUE_LENGTH) : value;
|
|
121
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
122
|
+
return value.slice(0, MAX_ARRAY_ITEMS).map((item) => item.slice(0, MAX_VALUE_LENGTH));
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Tags carry the binding convention, so they are read — but a tag whose value
|
|
128
|
+
* Terraform flagged, or which does not look like a label, is left behind.
|
|
129
|
+
*/
|
|
130
|
+
function projectTags(values, sensitiveValues) {
|
|
131
|
+
if (isSensitive(sensitiveValues, "tags"))
|
|
132
|
+
return {};
|
|
133
|
+
const raw = values.tags;
|
|
134
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
135
|
+
return {};
|
|
136
|
+
const tags = {};
|
|
137
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
138
|
+
if (typeof value !== "string")
|
|
139
|
+
continue;
|
|
140
|
+
if (value.length > 200)
|
|
141
|
+
continue;
|
|
142
|
+
tags[key] = value;
|
|
143
|
+
}
|
|
144
|
+
return tags;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* User-assigned identity references. Reviewed extractor: identity ids are
|
|
148
|
+
* resource paths, never credentials, and they are how a workload is tied to the
|
|
149
|
+
* role assignments that define what it may reach.
|
|
150
|
+
*/
|
|
151
|
+
function projectIdentityIds(values) {
|
|
152
|
+
const identity = values.identity;
|
|
153
|
+
if (!Array.isArray(identity))
|
|
154
|
+
return [];
|
|
155
|
+
const ids = [];
|
|
156
|
+
for (const block of identity) {
|
|
157
|
+
if (!block || typeof block !== "object")
|
|
158
|
+
continue;
|
|
159
|
+
const identityIds = block.identity_ids;
|
|
160
|
+
if (!Array.isArray(identityIds))
|
|
161
|
+
continue;
|
|
162
|
+
for (const id of identityIds)
|
|
163
|
+
if (typeof id === "string")
|
|
164
|
+
ids.push(id);
|
|
165
|
+
}
|
|
166
|
+
return ids.slice(0, MAX_ARRAY_ITEMS);
|
|
167
|
+
}
|
|
168
|
+
export function projectIacState(params) {
|
|
169
|
+
const { document, catalogue, environment } = params;
|
|
170
|
+
const byType = new Map(catalogue.map((entry) => [entry.resourceType, entry]));
|
|
171
|
+
const resources = flattenResources(document.values?.root_module);
|
|
172
|
+
const projected = [];
|
|
173
|
+
const unknown = new Map();
|
|
174
|
+
let attributesDroppedNotAllowlisted = 0;
|
|
175
|
+
let attributesDroppedSensitive = 0;
|
|
176
|
+
for (const resource of resources) {
|
|
177
|
+
const resourceType = typeof resource.type === "string" ? resource.type : "";
|
|
178
|
+
if (!resourceType)
|
|
179
|
+
continue;
|
|
180
|
+
const entry = byType.get(resourceType);
|
|
181
|
+
if (!entry) {
|
|
182
|
+
unknown.set(resourceType, (unknown.get(resourceType) ?? 0) + 1);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const values = (resource.values && typeof resource.values === "object" ? resource.values : {});
|
|
186
|
+
const sensitiveValues = resource.sensitive_values;
|
|
187
|
+
const attributes = {};
|
|
188
|
+
for (const [key, value] of Object.entries(values)) {
|
|
189
|
+
if (!entry.attributes.includes(key)) {
|
|
190
|
+
attributesDroppedNotAllowlisted += 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (isSensitive(sensitiveValues, key)) {
|
|
194
|
+
attributesDroppedSensitive += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const safe = safeValue(value);
|
|
198
|
+
if (safe === null) {
|
|
199
|
+
attributesDroppedNotAllowlisted += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
attributes[key] = safe;
|
|
203
|
+
}
|
|
204
|
+
projected.push({
|
|
205
|
+
address: typeof resource.address === "string" ? resource.address : `${resourceType}.${resource.name ?? "unknown"}`,
|
|
206
|
+
resourceType,
|
|
207
|
+
entityTypeCode: entry.entityTypeCode,
|
|
208
|
+
entitySubtypeCode: entry.entitySubtypeCode ?? null,
|
|
209
|
+
role: entry.role,
|
|
210
|
+
mode: typeof resource.mode === "string" ? resource.mode : "managed",
|
|
211
|
+
name: typeof values[entry.nameAttribute ?? "name"] === "string" ? values[entry.nameAttribute ?? "name"] : null,
|
|
212
|
+
attributes,
|
|
213
|
+
tags: projectTags(values, sensitiveValues),
|
|
214
|
+
identityIds: projectIdentityIds(values),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
environment,
|
|
219
|
+
iacToolVersion: typeof document.terraform_version === "string" ? document.terraform_version : null,
|
|
220
|
+
resources: projected,
|
|
221
|
+
unknownTypes: [...unknown.entries()]
|
|
222
|
+
.map(([resourceType, count]) => ({ resourceType, count }))
|
|
223
|
+
.sort((a, b) => b.count - a.count),
|
|
224
|
+
stats: {
|
|
225
|
+
resourcesSeen: resources.length,
|
|
226
|
+
resourcesProjected: projected.length,
|
|
227
|
+
attributesDroppedNotAllowlisted,
|
|
228
|
+
attributesDroppedSensitive,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|