nexarch 0.13.0 → 0.13.2
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 +9 -3
- package/dist/commands/init-project-infra.js +22 -20
- package/dist/commands/init-project.js +382 -25
- package/dist/index.js +7 -1
- 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
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
2
2
|
import { execFileSync } from "child_process";
|
|
3
|
-
import { resolve } from "path";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
4
|
import { callMcpTool } from "../lib/mcp.js";
|
|
5
|
-
import {
|
|
5
|
+
import { parseIacStateBuffer, projectIacState, inferEnvironmentFromTags, environmentSubtypeFor, } from "../lib/iac-projection.js";
|
|
6
6
|
import { loadIacCatalogue, describeCatalogue } from "../lib/iac-catalogue.js";
|
|
7
|
-
import { discoverRootModules, environmentFromPath } from "../lib/
|
|
7
|
+
import { discoverRootModules, environmentFromPath } from "../lib/iac-detect.js";
|
|
8
|
+
import { iacToolOrThrow } from "../lib/iac-tool.js";
|
|
9
|
+
import { detectAnsibleProject, environmentFromInventoryPath } from "../lib/ansible-detect.js";
|
|
10
|
+
import { parseAnsibleInventory, projectAnsibleInventory } from "../lib/ansible-projection.js";
|
|
8
11
|
const ENTITY_BATCH = 50;
|
|
9
12
|
/**
|
|
10
13
|
* Terraform provider prefix -> the platform it provisions.
|
|
@@ -45,6 +48,16 @@ function detectPlatform(resourceTypes) {
|
|
|
45
48
|
const [topPrefix] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
46
49
|
return PROVIDER_PLATFORMS.find((p) => p.prefix === topPrefix) ?? null;
|
|
47
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* "provisioned" or "configured", by tool.
|
|
53
|
+
*
|
|
54
|
+
* The same distinction `mode: "declared"` carries in the data, said in the
|
|
55
|
+
* prose a human reads on the entity. Ansible did not provision a machine it
|
|
56
|
+
* only installed packages on, and the description should not claim it did.
|
|
57
|
+
*/
|
|
58
|
+
function pastVerbFor(tool) {
|
|
59
|
+
return tool.provisions ? "provisioned" : "configured";
|
|
60
|
+
}
|
|
48
61
|
function readResult(raw) {
|
|
49
62
|
return JSON.parse(raw.content?.[0]?.text ?? "{}");
|
|
50
63
|
}
|
|
@@ -62,12 +75,16 @@ function parseArgs(args) {
|
|
|
62
75
|
return index >= 0 && args[index + 1] ? args[index + 1] : null;
|
|
63
76
|
};
|
|
64
77
|
return {
|
|
65
|
-
|
|
78
|
+
// `--inventory` is the same thing said in Ansible's vocabulary. A reader
|
|
79
|
+
// holding an inventory file should not have to work out that we call it
|
|
80
|
+
// state.
|
|
81
|
+
statePath: get("--state") ?? get("--inventory"),
|
|
66
82
|
environmentOverride: get("--environment"),
|
|
67
83
|
dir: get("--dir") ?? process.cwd(),
|
|
68
84
|
projectRef: get("--project-ref"),
|
|
69
85
|
dryRun: args.includes("--dry-run"),
|
|
70
86
|
asJson: args.includes("--json"),
|
|
87
|
+
tool: get("--tool"),
|
|
71
88
|
};
|
|
72
89
|
}
|
|
73
90
|
/**
|
|
@@ -80,13 +97,14 @@ function parseArgs(args) {
|
|
|
80
97
|
* an agent, and an agent can act on a list of commands but not on a request to
|
|
81
98
|
* choose.
|
|
82
99
|
*/
|
|
83
|
-
function resolveRootModule(dir) {
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
function resolveRootModule(dir, tool) {
|
|
101
|
+
// Both tools read .tf; OpenTofu also reads .tofu, which Terraform will not.
|
|
102
|
+
const hasLocalConfig = existsSync(dir) && readdirSync(dir).some((entry) => entry.endsWith(".tf") || entry.endsWith(".tofu"));
|
|
103
|
+
if (hasLocalConfig)
|
|
86
104
|
return { dir, note: null };
|
|
87
105
|
const roots = discoverRootModules(dir);
|
|
88
106
|
if (roots.length === 1) {
|
|
89
|
-
return { dir: roots[0].dir, note: `no
|
|
107
|
+
return { dir: roots[0].dir, note: `no infrastructure code in the working directory; using the only root module found, ${roots[0].relative}` };
|
|
90
108
|
}
|
|
91
109
|
if (roots.length > 1) {
|
|
92
110
|
// Annotations go on their own line: the caller is usually an agent, and a
|
|
@@ -94,37 +112,37 @@ function resolveRootModule(dir) {
|
|
|
94
112
|
const lines = roots.flatMap((root) => {
|
|
95
113
|
const notes = [
|
|
96
114
|
root.environmentHint ? `${root.environmentHint} environment` : "shared stack",
|
|
97
|
-
root.initialised ? "initialised" :
|
|
115
|
+
root.initialised ? "initialised" : `run \`${tool.executable} init\` here first`,
|
|
98
116
|
].join(", ");
|
|
99
117
|
return [` # ${root.relative} — ${notes}`, ` nexarch ingest-infra --dir ${root.relative}`];
|
|
100
118
|
});
|
|
101
119
|
throw new Error([
|
|
102
|
-
`This repository has ${roots.length}
|
|
103
|
-
`
|
|
120
|
+
`This repository has ${roots.length} ${tool.unitNoun}s, each with its own estate.`,
|
|
121
|
+
` Each is a separate estate, so ingest them one at a time:`,
|
|
104
122
|
...lines,
|
|
105
123
|
].join("\n"));
|
|
106
124
|
}
|
|
107
125
|
throw new Error([
|
|
108
|
-
`No
|
|
126
|
+
`No ${tool.unitNoun} found at or below ${dir}.`,
|
|
109
127
|
` Run this inside the repository that provisions the estate, or pass --dir <path> or --state <file>.`,
|
|
110
128
|
].join("\n"));
|
|
111
129
|
}
|
|
112
|
-
/** Reads state from a file, or asks
|
|
113
|
-
function loadState(statePath, dir) {
|
|
130
|
+
/** Reads state from a file, or asks the IaC tool for it when running inside the repo. */
|
|
131
|
+
function loadState(statePath, dir, tool) {
|
|
114
132
|
if (statePath) {
|
|
115
133
|
const full = resolve(statePath);
|
|
116
134
|
if (!existsSync(full))
|
|
117
135
|
throw new Error(`State file not found: ${full}`);
|
|
118
|
-
return { document:
|
|
136
|
+
return { document: parseIacStateBuffer(readFileSync(full)), source: full };
|
|
119
137
|
}
|
|
120
138
|
try {
|
|
121
|
-
const stdout = execFileSync(
|
|
139
|
+
const stdout = execFileSync(tool.executable, [...tool.estateArgs], {
|
|
122
140
|
cwd: dir,
|
|
123
141
|
maxBuffer: 256 * 1024 * 1024,
|
|
124
142
|
encoding: "buffer",
|
|
125
143
|
stdio: ["ignore", "pipe", "pipe"],
|
|
126
144
|
});
|
|
127
|
-
return { document:
|
|
145
|
+
return { document: parseIacStateBuffer(stdout), source: `${tool.executable} ${tool.estateArgs.join(" ")}` };
|
|
128
146
|
}
|
|
129
147
|
catch (error) {
|
|
130
148
|
// `error.message` is only ever "Command failed: terraform show -json",
|
|
@@ -146,12 +164,12 @@ function loadState(statePath, dir) {
|
|
|
146
164
|
.slice(0, 8)
|
|
147
165
|
.map((line) => ` ${line.trim()}`)
|
|
148
166
|
.join("\n");
|
|
149
|
-
throw new Error(`Could not read
|
|
150
|
-
` Run inside the infrastructure repo with
|
|
151
|
-
`\n
|
|
167
|
+
throw new Error(`Could not read ${tool.displayName} state in ${dir}.\n` +
|
|
168
|
+
` Run inside the infrastructure repo with ${tool.executable} available, or pass --state <file>.\n` +
|
|
169
|
+
`\n ${tool.displayName} said:\n${detail}`);
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
|
-
function resolveEnvironment(projection, override, dir, rootModuleDir) {
|
|
172
|
+
function resolveEnvironment(projection, override, dir, rootModuleDir, tool) {
|
|
155
173
|
if (override)
|
|
156
174
|
return { environment: override.trim().toLowerCase(), how: "--environment flag" };
|
|
157
175
|
// A root module living in environments/prod states which estate is being read
|
|
@@ -164,10 +182,15 @@ function resolveEnvironment(projection, override, dir, rootModuleDir) {
|
|
|
164
182
|
if (inferred && inferred.confidence >= 0.6) {
|
|
165
183
|
return { environment: inferred.environment, how: `inferred from resource tags (${Math.round(inferred.confidence * 100)}% agreement)` };
|
|
166
184
|
}
|
|
185
|
+
// Guarded, not attempted-and-caught: Ansible has no workspace concept, and
|
|
186
|
+
// shelling out to a binary that will never answer the question is a second's
|
|
187
|
+
// wait and a misleading "not available" in the logs.
|
|
167
188
|
try {
|
|
168
|
-
|
|
189
|
+
if (!tool.hasWorkspaces)
|
|
190
|
+
throw new Error("no workspaces");
|
|
191
|
+
const workspace = execFileSync(tool.executable, ["workspace", "show"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
169
192
|
if (workspace && workspace !== "default")
|
|
170
|
-
return { environment: workspace.toLowerCase(), how:
|
|
193
|
+
return { environment: workspace.toLowerCase(), how: `${tool.id} workspace` };
|
|
171
194
|
}
|
|
172
195
|
catch {
|
|
173
196
|
// Terraform not available; fall through to the error below.
|
|
@@ -185,7 +208,7 @@ function resolveEnvironment(projection, override, dir, rootModuleDir) {
|
|
|
185
208
|
else if (projection.stats.resourcesProjected > 0)
|
|
186
209
|
detail.push("none carried an environment or env tag");
|
|
187
210
|
throw new Error(`Could not determine the environment — ${detail.join("; ")}.\n` +
|
|
188
|
-
` Fix by tagging resources with \`environment\`, selecting a named
|
|
211
|
+
` Fix by tagging resources with \`environment\`, selecting a named ${tool.displayName} workspace, or passing --environment <name>.`);
|
|
189
212
|
}
|
|
190
213
|
/** Last non-empty segment of a cloud resource path (ARM id, ARN, self-link) — that resource's name. */
|
|
191
214
|
function lastPathSegment(armId) {
|
|
@@ -194,7 +217,7 @@ function lastPathSegment(armId) {
|
|
|
194
217
|
}
|
|
195
218
|
/** Builds the entity and relationship payloads for the existing upsert contract. */
|
|
196
219
|
export function buildIngestPayload(params) {
|
|
197
|
-
const { projection, environment, platformRef, platformName, catalogue } = params;
|
|
220
|
+
const { projection, environment, platformRef, platformName, catalogue, tool } = params;
|
|
198
221
|
const environmentRef = `environment:${slug(environment)}`;
|
|
199
222
|
const entities = [
|
|
200
223
|
{
|
|
@@ -202,15 +225,15 @@ export function buildIngestPayload(params) {
|
|
|
202
225
|
entityTypeCode: "platform",
|
|
203
226
|
name: platformName,
|
|
204
227
|
description: `Cloud platform hosting the ${environment} environment.`,
|
|
205
|
-
attributes: { source: "
|
|
228
|
+
attributes: { source: "iac_ingest", iac_tool: tool.id },
|
|
206
229
|
},
|
|
207
230
|
{
|
|
208
231
|
entityRef: environmentRef,
|
|
209
232
|
entityTypeCode: "environment",
|
|
210
233
|
entitySubtypeCode: environmentSubtypeFor(environment),
|
|
211
234
|
name: environment,
|
|
212
|
-
description: `The ${environment} environment, as
|
|
213
|
-
attributes: { source: "
|
|
235
|
+
description: `The ${environment} environment, as ${pastVerbFor(tool)} by ${tool.displayName}.`,
|
|
236
|
+
attributes: { source: "iac_ingest", iac_tool: tool.id, iac_tool_version: projection.iacToolVersion },
|
|
214
237
|
},
|
|
215
238
|
];
|
|
216
239
|
const relationships = [
|
|
@@ -242,12 +265,13 @@ export function buildIngestPayload(params) {
|
|
|
242
265
|
entityTypeCode: resource.entityTypeCode,
|
|
243
266
|
...(resource.entitySubtypeCode ? { entitySubtypeCode: resource.entitySubtypeCode } : {}),
|
|
244
267
|
name: resource.name ?? resource.address,
|
|
245
|
-
description: `${resource.role.replace(/_/g, " ")}
|
|
268
|
+
description: `${resource.role.replace(/_/g, " ")} ${pastVerbFor(tool)} by ${tool.displayName} (${resource.resourceType}) in ${environment}.`,
|
|
246
269
|
attributes: {
|
|
247
|
-
source: "
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
270
|
+
source: "iac_ingest",
|
|
271
|
+
iac_tool: tool.id,
|
|
272
|
+
iac_resource_address: resource.address,
|
|
273
|
+
iac_resource_type: resource.resourceType,
|
|
274
|
+
iac_resource_mode: resource.mode,
|
|
251
275
|
infrastructure_role: resource.role,
|
|
252
276
|
environment,
|
|
253
277
|
...resource.attributes,
|
|
@@ -371,7 +395,7 @@ export function buildIngestPayload(params) {
|
|
|
371
395
|
entityTypeCode: canonicalTypeCode,
|
|
372
396
|
name: canonicalTechnologyName(canonicalRef),
|
|
373
397
|
description: "Cloud service referenced by infrastructure ingestion.",
|
|
374
|
-
attributes: { source: "
|
|
398
|
+
attributes: { source: "iac_ingest", iac_tool: tool.id, canonical: true },
|
|
375
399
|
});
|
|
376
400
|
// A managed service belongs to the cloud that offers it. Only from a
|
|
377
401
|
// platform_component: part_of does not accept a technology_component
|
|
@@ -398,36 +422,136 @@ function chunk(items, size) {
|
|
|
398
422
|
out.push(items.slice(i, i + size));
|
|
399
423
|
return out;
|
|
400
424
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
425
|
+
/** Exact MCP inputs used for infrastructure writes; kept inspectable for contract tests. */
|
|
426
|
+
export function buildIngestUpsertPayloads(params) {
|
|
427
|
+
const { entities, relationships, agentContext, policyContext } = params;
|
|
428
|
+
return {
|
|
429
|
+
entityPayloads: chunk(entities, ENTITY_BATCH).map((entities) => ({ entities, agentContext, policyContext })),
|
|
430
|
+
relationshipPayloads: chunk(relationships, ENTITY_BATCH).map((relationships) => ({ relationships, agentContext, policyContext })),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Reads the estate the way this tool exposes it.
|
|
435
|
+
*
|
|
436
|
+
* The two shapes are genuinely different, which is the whole finding of stage 4:
|
|
437
|
+
* Terraform dumps state that is then matched against the resource catalogue,
|
|
438
|
+
* while Ansible enumerates an inventory that has no catalogue at all -- a host
|
|
439
|
+
* is a host. Branching here rather than inside the projector keeps each path
|
|
440
|
+
* readable, and keeps the catalogue fetch off the Ansible path where it would
|
|
441
|
+
* be a wasted round trip.
|
|
442
|
+
*/
|
|
443
|
+
async function readEstate(params) {
|
|
444
|
+
const { tool, dir, statePath, asJson } = params;
|
|
445
|
+
if (tool.id === "ansible") {
|
|
446
|
+
const { inventory, source, inventoryPath } = loadAnsibleInventory(statePath, dir, tool);
|
|
447
|
+
return {
|
|
448
|
+
projection: projectAnsibleInventory({ inventory, environment: "unknown" }),
|
|
449
|
+
source,
|
|
450
|
+
workingDir: dir,
|
|
451
|
+
environmentHint: inventoryPath ? environmentFromInventoryPath(inventoryPath) : null,
|
|
452
|
+
catalogue: [],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// Only resolve a root module when the tool must be run; an explicit --state
|
|
404
456
|
// file is authoritative and needs no repository at all.
|
|
405
457
|
let workingDir = dir;
|
|
406
458
|
if (!statePath) {
|
|
407
|
-
const resolved = resolveRootModule(dir);
|
|
459
|
+
const resolved = resolveRootModule(dir, tool);
|
|
408
460
|
workingDir = resolved.dir;
|
|
409
461
|
if (resolved.note && !asJson)
|
|
410
462
|
console.log(` ${resolved.note}`);
|
|
411
463
|
}
|
|
412
|
-
const { document, source } = loadState(statePath, workingDir);
|
|
464
|
+
const { document, source } = loadState(statePath, workingDir, tool);
|
|
413
465
|
// Fetched once and used for both projection and payload: two different
|
|
414
466
|
// catalogues within one ingest would produce entities whose attributes and
|
|
415
467
|
// whose type mapping disagreed about what a resource is.
|
|
416
468
|
const catalogueResult = await loadIacCatalogue();
|
|
417
469
|
if (!asJson)
|
|
418
470
|
console.log(` ${describeCatalogue(catalogueResult)}`);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
471
|
+
return {
|
|
472
|
+
projection: projectIacState({ document, catalogue: catalogueResult.entries, environment: "unknown" }),
|
|
473
|
+
source,
|
|
474
|
+
workingDir,
|
|
475
|
+
environmentHint: null,
|
|
476
|
+
catalogue: catalogueResult.entries,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Reads an inventory, either from a path or by asking Ansible to enumerate one.
|
|
481
|
+
*
|
|
482
|
+
* A path is parsed directly rather than passed to `ansible-inventory -i`: it
|
|
483
|
+
* makes the common case work without Ansible installed at all, and the parser
|
|
484
|
+
* handles the same JSON, YAML and INI that Ansible itself accepts.
|
|
485
|
+
*/
|
|
486
|
+
function loadAnsibleInventory(inventoryPath, dir, tool) {
|
|
487
|
+
if (inventoryPath) {
|
|
488
|
+
const full = resolve(inventoryPath);
|
|
489
|
+
if (!existsSync(full))
|
|
490
|
+
throw new Error(`Inventory not found: ${full}`);
|
|
491
|
+
return { inventory: parseAnsibleInventory(readFileSync(full, "utf8")), source: full, inventoryPath };
|
|
492
|
+
}
|
|
493
|
+
// No path given: let the repository's own layout choose, so the environment
|
|
494
|
+
// can be read off it. One inventory is unambiguous; several are not, and
|
|
495
|
+
// guessing which environment to ingest is exactly the guess not to make.
|
|
496
|
+
const detection = detectAnsibleProject(dir);
|
|
497
|
+
if (detection.inventoryFiles.length === 1) {
|
|
498
|
+
const relative = detection.inventoryFiles[0];
|
|
499
|
+
const full = join(dir, relative);
|
|
500
|
+
if (existsSync(full) && statSync(full).isFile()) {
|
|
501
|
+
return { inventory: parseAnsibleInventory(readFileSync(full, "utf8")), source: full, inventoryPath: relative };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (detection.inventoryFiles.length > 1) {
|
|
505
|
+
const lines = detection.inventoryFiles.map((file) => ` nexarch ingest-infra --inventory ${file}`);
|
|
506
|
+
throw new Error([
|
|
507
|
+
`This repository has ${detection.inventoryFiles.length} inventories, each describing its own estate.`,
|
|
508
|
+
` Ingest them one at a time:`,
|
|
509
|
+
...lines,
|
|
510
|
+
].join("\n"));
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const stdout = execFileSync(tool.executable, [...tool.estateArgs], {
|
|
514
|
+
cwd: dir,
|
|
515
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
516
|
+
encoding: "buffer",
|
|
517
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
inventory: parseAnsibleInventory(stdout.toString("utf8")),
|
|
521
|
+
source: `${tool.executable} ${tool.estateArgs.join(" ")}`,
|
|
522
|
+
inventoryPath: null,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
const detail = String(error.stderr ?? "").trim() || error.message;
|
|
527
|
+
throw new Error(`Could not read an Ansible inventory in ${dir}.\n` +
|
|
528
|
+
` Pass --inventory <file>, or run this where \`${tool.executable}\` can see one.\n` +
|
|
529
|
+
`\n ${tool.executable} said:\n ${detail.split("\n").slice(0, 6).join("\n ")}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
export async function ingestInfra(args) {
|
|
533
|
+
const { statePath, environmentOverride, dir, projectRef, dryRun, asJson, tool: requestedTool } = parseArgs(args);
|
|
534
|
+
const tool = iacToolOrThrow(dir, requestedTool);
|
|
535
|
+
const { projection, source, workingDir, environmentHint, catalogue } = await readEstate({ tool, dir, statePath, asJson });
|
|
536
|
+
// Both tools emit a valid but empty document when they cannot see state,
|
|
422
537
|
// is a different problem from anything downstream and deserves its own
|
|
423
538
|
// message rather than surfacing later as a confusing tagging complaint.
|
|
539
|
+
// An empty estate needs the diagnosis that fits the tool. Telling someone
|
|
540
|
+
// holding an inventory to run `terraform init` is worse than saying nothing.
|
|
541
|
+
if (projection.stats.resourcesSeen === 0 && !tool.provisions) {
|
|
542
|
+
throw new Error(`No hosts found in the ${tool.unitNoun} (read from ${source}).\n` +
|
|
543
|
+
` An inventory with no hosts describes no estate. Establish which, in this order:\n` +
|
|
544
|
+
` 1. run \`${tool.executable} ${tool.estateArgs.join(" ")}\` here — empty output confirms it\n` +
|
|
545
|
+
` 2. check the inventory is the one you meant, with --inventory <file>\n` +
|
|
546
|
+
` 3. a dynamic inventory plugin may need credentials this shell does not have`);
|
|
547
|
+
}
|
|
424
548
|
if (projection.stats.resourcesSeen === 0) {
|
|
425
|
-
throw new Error(`No resources found in
|
|
426
|
-
`
|
|
427
|
-
` 1. run
|
|
428
|
-
` 2. if there is no .terraform directory, run
|
|
429
|
-
` 3. run
|
|
430
|
-
` 4. if this repository has several
|
|
549
|
+
throw new Error(`No resources found in ${tool.displayName} state (read from ${source}).\n` +
|
|
550
|
+
` ${tool.displayName} ran but reported an empty state. Establish which, in this order:\n` +
|
|
551
|
+
` 1. run \`${tool.executable} state list\` in ${workingDir} — empty output confirms there is no state here\n` +
|
|
552
|
+
` 2. if there is no .terraform directory, run \`${tool.executable} init\`, then retry\n` +
|
|
553
|
+
` 3. run \`${tool.executable} workspace list\` and select the one that was applied\n` +
|
|
554
|
+
` 4. if this repository has several ${tool.unitNoun}s, ingest each separately with --dir\n` +
|
|
431
555
|
` The estate may also simply not have been applied yet.`);
|
|
432
556
|
}
|
|
433
557
|
// Nothing recognised is a coverage problem, and has to be said before the
|
|
@@ -436,7 +560,9 @@ export async function ingestInfra(args) {
|
|
|
436
560
|
// environment — 0 of those carried any tags", which sends the reader off to
|
|
437
561
|
// tag resources that would still not be ingested afterwards. The environment
|
|
438
562
|
// is the next question only once there is something to attach it to.
|
|
439
|
-
|
|
563
|
+
// Ansible has no resource catalogue: every host is projected by definition,
|
|
564
|
+
// so a catalogue-coverage complaint here would be nonsense.
|
|
565
|
+
if (tool.provisions && projection.stats.resourcesProjected === 0) {
|
|
440
566
|
const seen = projection.unknownTypes
|
|
441
567
|
.slice(0, 12)
|
|
442
568
|
.map((entry) => ` ${String(entry.count).padStart(3)} ${entry.resourceType}`)
|
|
@@ -446,7 +572,9 @@ export async function ingestInfra(args) {
|
|
|
446
572
|
`\n Resource types found here:\n${seen}\n` +
|
|
447
573
|
`\n These need adding to the platform catalogue before this root can be ingested.`);
|
|
448
574
|
}
|
|
449
|
-
const { environment, how } =
|
|
575
|
+
const { environment, how } = environmentHint && !environmentOverride
|
|
576
|
+
? { environment: environmentHint, how: `${tool.unitNoun} path` }
|
|
577
|
+
: resolveEnvironment(projection, environmentOverride, workingDir, statePath ? null : workingDir, tool);
|
|
450
578
|
projection.environment = environment;
|
|
451
579
|
// Detected from what is actually in state, not assumed: an estate is read
|
|
452
580
|
// once and the provider it is on follows from that, the same way the
|
|
@@ -458,8 +586,12 @@ export async function ingestInfra(args) {
|
|
|
458
586
|
...projection.resources.map((r) => r.resourceType),
|
|
459
587
|
...projection.unknownTypes.map((u) => u.resourceType),
|
|
460
588
|
]);
|
|
461
|
-
|
|
462
|
-
|
|
589
|
+
// A tool that does not provision genuinely does not know what the hosts run
|
|
590
|
+
// on -- an inventory says `web-01`, not whether it is EC2 or a machine under
|
|
591
|
+
// someone's desk. "Unrecognised cloud platform" would be a worse answer than
|
|
592
|
+
// naming what we do know, which is that these are hosts we configure.
|
|
593
|
+
let platformRef = detectedPlatform?.fallbackRef ?? (tool.provisions ? "platform:unknown" : "platform:managed_hosts");
|
|
594
|
+
let platformName = detectedPlatform?.fallbackName ?? (tool.provisions ? "Unrecognised cloud platform" : "Managed hosts");
|
|
463
595
|
if (!dryRun && detectedPlatform) {
|
|
464
596
|
try {
|
|
465
597
|
const raw = await callMcpTool("nexarch_resolve_reference", { names: detectedPlatform.resolveNames });
|
|
@@ -494,13 +626,13 @@ export async function ingestInfra(args) {
|
|
|
494
626
|
// Coverage reporting must never fail an ingest: the estate is the point.
|
|
495
627
|
}
|
|
496
628
|
}
|
|
497
|
-
const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName, catalogue });
|
|
629
|
+
const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName, catalogue, tool });
|
|
498
630
|
const report = {
|
|
499
631
|
ok: true,
|
|
500
632
|
source,
|
|
501
633
|
environment,
|
|
502
634
|
environmentSource: how,
|
|
503
|
-
|
|
635
|
+
iacToolVersion: projection.iacToolVersion,
|
|
504
636
|
resourcesSeen: projection.stats.resourcesSeen,
|
|
505
637
|
resourcesProjected: projection.stats.resourcesProjected,
|
|
506
638
|
attributesDropped: {
|
|
@@ -536,13 +668,14 @@ export async function ingestInfra(args) {
|
|
|
536
668
|
provider: "n/a",
|
|
537
669
|
};
|
|
538
670
|
const policyContext = { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } };
|
|
671
|
+
const upsertPayloads = buildIngestUpsertPayloads({ entities, relationships, agentContext, policyContext });
|
|
539
672
|
let failed = 0;
|
|
540
|
-
for (const
|
|
541
|
-
const raw = await callMcpTool("nexarch_upsert_entities",
|
|
673
|
+
for (const payload of upsertPayloads.entityPayloads) {
|
|
674
|
+
const raw = await callMcpTool("nexarch_upsert_entities", payload);
|
|
542
675
|
failed += (readResult(raw).summary?.failed ?? 0);
|
|
543
676
|
}
|
|
544
|
-
for (const
|
|
545
|
-
const raw = await callMcpTool("nexarch_upsert_relationships",
|
|
677
|
+
for (const payload of upsertPayloads.relationshipPayloads) {
|
|
678
|
+
const raw = await callMcpTool("nexarch_upsert_relationships", payload);
|
|
546
679
|
failed += (readResult(raw).summary?.failed ?? 0);
|
|
547
680
|
}
|
|
548
681
|
report.written = { entities: entities.length, relationships: relationships.length, failed };
|
|
@@ -554,9 +687,9 @@ export async function ingestInfra(args) {
|
|
|
554
687
|
function printHuman(report, entityCount, relationshipCount) {
|
|
555
688
|
const dropped = report.attributesDropped;
|
|
556
689
|
console.log(`\nInfrastructure ingest — ${report.environment} (${report.environmentSource})`);
|
|
557
|
-
console.log(` Source: ${report.source}${report.
|
|
690
|
+
console.log(` Source: ${report.source}${report.iacToolVersion ? ` · ${report.iacTool} ${report.iacToolVersion}` : ""}`);
|
|
558
691
|
console.log(` Projected ${report.resourcesProjected} of ${report.resourcesSeen} resources to the contract surface.`);
|
|
559
|
-
console.log(` Dropped ${dropped.notAllowlisted} attributes not on the allowlist, ${dropped.flaggedSensitive} flagged sensitive by
|
|
692
|
+
console.log(` Dropped ${dropped.notAllowlisted} attributes not on the allowlist, ${dropped.flaggedSensitive} flagged sensitive by the tool.`);
|
|
560
693
|
const unknown = report.unknownTypes;
|
|
561
694
|
if (unknown.length > 0) {
|
|
562
695
|
console.log(`\n Not yet in the catalogue (reported so coverage can grow):`);
|
|
@@ -607,11 +607,17 @@ function injectInitProjectReportingContract(path) {
|
|
|
607
607
|
"completed task — it is a description of a task you have not finished.",
|
|
608
608
|
"",
|
|
609
609
|
"For an infrastructure repository, `nextSteps` holds one `ingest-infra` command per",
|
|
610
|
-
"
|
|
611
|
-
"nothing it provisions.",
|
|
610
|
+
"unit of estate — a Terraform or OpenTofu root module, each with its own state.",
|
|
611
|
+
"Until they run, the graph knows the repository exists but nothing it provisions.",
|
|
612
|
+
"",
|
|
613
|
+
"**One exception: a possible duplicate.** If init-project returns `status: \"review_required\"`",
|
|
614
|
+
"or prints `STOP — POSSIBLE DUPLICATE`, an application already in the workspace looks like",
|
|
615
|
+
"one it would register, and it wrote nothing. Tell the human which one you think matches and",
|
|
616
|
+
"why, wait for their answer, then re-run init-project with the command for their choice",
|
|
617
|
+
"(`decision.commands` in the output). Only then continue with enrichment.",
|
|
612
618
|
"",
|
|
613
619
|
"Stop early only when a step genuinely cannot run — missing cloud credentials, a root",
|
|
614
|
-
"module that
|
|
620
|
+
"module that has not been initialised, or an environment you should not touch without",
|
|
615
621
|
"asking. When that happens, say which step stopped you and why, then ask. Do not",
|
|
616
622
|
"silently return a skeleton.",
|
|
617
623
|
"",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { basename } from "path";
|
|
2
2
|
import { createInterface } from "readline";
|
|
3
3
|
import { callMcpTool } from "../lib/mcp.js";
|
|
4
|
-
import { detectInfrastructureProject, ciSnippetFor } from "../lib/
|
|
5
|
-
import { environmentSubtypeFor } from "../lib/
|
|
4
|
+
import { detectInfrastructureProject, ciSnippetFor } from "../lib/iac-detect.js";
|
|
5
|
+
import { environmentSubtypeFor } from "../lib/iac-projection.js";
|
|
6
6
|
import { ingestInfra } from "./ingest-infra.js";
|
|
7
|
+
import { iacToolOrThrow } from "../lib/iac-tool.js";
|
|
7
8
|
function readResult(raw) {
|
|
8
9
|
return JSON.parse(raw.content?.[0]?.text ?? "{}");
|
|
9
10
|
}
|
|
@@ -35,22 +36,22 @@ async function confirm(question) {
|
|
|
35
36
|
* and an agent reporting "skeleton_only" and stopping, which is exactly what
|
|
36
37
|
* happened when the handoff lived only in prose at the end of a real run.
|
|
37
38
|
*/
|
|
38
|
-
function nextStepsFor(projectRef, detection) {
|
|
39
|
+
function nextStepsFor(projectRef, detection, toolId) {
|
|
39
40
|
return detection.rootModules.map((root) => ({
|
|
40
41
|
rootModule: root.relative,
|
|
41
42
|
environment: root.environmentHint,
|
|
42
43
|
initialised: root.initialised,
|
|
43
|
-
command: `nexarch ingest-infra --dir ${root.relative} --project-ref ${projectRef}`,
|
|
44
|
+
command: `nexarch ingest-infra --dir ${root.relative} --project-ref ${projectRef} --tool ${toolId}`,
|
|
44
45
|
}));
|
|
45
46
|
}
|
|
46
47
|
/** Prints the per-root ingest commands, the part that actually populates the graph. */
|
|
47
|
-
function printNextSteps(steps, registered) {
|
|
48
|
+
function printNextSteps(steps, registered, executable) {
|
|
48
49
|
if (steps.length === 0)
|
|
49
50
|
return;
|
|
50
51
|
console.log(`\n${registered ? "Registered" : "Would register"} the repository — the estate itself is not in the graph yet.`);
|
|
51
52
|
console.log(`Each of the ${steps.length} root modules holds its own state and needs its own ingest:\n`);
|
|
52
53
|
for (const step of steps) {
|
|
53
|
-
const notes = [step.environment ? `${step.environment} environment` : "shared stack", step.initialised ? "initialised" :
|
|
54
|
+
const notes = [step.environment ? `${step.environment} environment` : "shared stack", step.initialised ? "initialised" : `run \`${executable} init\` here first`].join(", ");
|
|
54
55
|
console.log(` # ${step.rootModule} — ${notes}`);
|
|
55
56
|
console.log(` ${step.command}`);
|
|
56
57
|
}
|
|
@@ -64,9 +65,9 @@ function printNextSteps(steps, registered) {
|
|
|
64
65
|
* made — not even the policy read, since a dry run should be inert.
|
|
65
66
|
*/
|
|
66
67
|
function reportDryRun(params) {
|
|
67
|
-
const { asJson, projectRef, detection, entities, relationships } = params;
|
|
68
|
+
const { asJson, projectRef, detection, toolId, entities, relationships } = params;
|
|
68
69
|
const isLibrary = detection.repoKind === "module_library";
|
|
69
|
-
const nextSteps = isLibrary ? [] : nextStepsFor(projectRef, detection);
|
|
70
|
+
const nextSteps = isLibrary ? [] : nextStepsFor(projectRef, detection, toolId);
|
|
70
71
|
if (asJson) {
|
|
71
72
|
process.stdout.write(`${JSON.stringify({
|
|
72
73
|
ok: true,
|
|
@@ -107,11 +108,12 @@ function reportDryRun(params) {
|
|
|
107
108
|
// The dry run used to stop here, which made registration look like the whole
|
|
108
109
|
// job: a reader saw four entities and no hint that seven ingest runs were the
|
|
109
110
|
// actual work. A preview has to preview the handoff too.
|
|
110
|
-
printNextSteps(nextSteps, false);
|
|
111
|
+
printNextSteps(nextSteps, false, params.executable);
|
|
111
112
|
console.log(`\nNothing was written. Re-run without --dry-run to register, then run the commands above.`);
|
|
112
113
|
}
|
|
113
114
|
export async function runInfrastructureOnboarding(options, detection) {
|
|
114
|
-
const { dir, nameOverride, asJson, nonInteractive, skipIngest, dryRun } = options;
|
|
115
|
+
const { dir, nameOverride, asJson, nonInteractive, skipIngest, dryRun, tool: requestedTool } = options;
|
|
116
|
+
const tool = iacToolOrThrow(dir, requestedTool);
|
|
115
117
|
const displayName = nameOverride ?? basename(dir);
|
|
116
118
|
const projectRef = `project:${slugify(displayName)}`;
|
|
117
119
|
if (!asJson) {
|
|
@@ -125,7 +127,7 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
125
127
|
// at registration is what stops the project being an orphan: without it the
|
|
126
128
|
// graph holds a node that provisions nothing, and no reader can get from an
|
|
127
129
|
// environment back to the code that defines it. The root module paths carry
|
|
128
|
-
// this before any state is read, so it costs no
|
|
130
|
+
// this before any state is read, so it costs no run of the IaC tool.
|
|
129
131
|
const environments = [...new Set(detection.rootModules.map((root) => root.environmentHint).filter((hint) => Boolean(hint)))];
|
|
130
132
|
const entities = [
|
|
131
133
|
{
|
|
@@ -138,14 +140,14 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
138
140
|
description: `Infrastructure-as-code repository provisioning the estate (${detection.signals.join(", ")}).`,
|
|
139
141
|
attributes: {
|
|
140
142
|
source: "nexarch_cli_init_project",
|
|
141
|
-
iac_tool:
|
|
142
|
-
|
|
143
|
+
iac_tool: tool.id,
|
|
144
|
+
iac_file_count: detection.iacFileCount,
|
|
143
145
|
has_remote_backend: detection.hasRemoteBackend,
|
|
144
146
|
root_module_count: detection.rootModules.length,
|
|
145
147
|
repo_kind: detection.repoKind,
|
|
146
148
|
...(detection.publishedModules.length > 0 ? { published_modules: detection.publishedModules.slice(0, 40) } : {}),
|
|
147
149
|
...(detection.ciSystem ? { ci_system: detection.ciSystem } : {}),
|
|
148
|
-
...(detection.moduleDirectories.length > 0 ? {
|
|
150
|
+
...(detection.moduleDirectories.length > 0 ? { iac_modules: detection.moduleDirectories.slice(0, 25) } : {}),
|
|
149
151
|
},
|
|
150
152
|
},
|
|
151
153
|
...environments.map((environment) => ({
|
|
@@ -154,7 +156,7 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
154
156
|
entitySubtypeCode: environmentSubtypeFor(environment),
|
|
155
157
|
name: environment,
|
|
156
158
|
description: `Environment defined by the ${displayName} infrastructure repository.`,
|
|
157
|
-
attributes: { source: "nexarch_cli_init_project", iac_tool:
|
|
159
|
+
attributes: { source: "nexarch_cli_init_project", iac_tool: tool.id },
|
|
158
160
|
})),
|
|
159
161
|
];
|
|
160
162
|
const relationships = environments.map((environment) => ({
|
|
@@ -164,7 +166,7 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
164
166
|
attributes: { source: "nexarch_cli_init_project" },
|
|
165
167
|
}));
|
|
166
168
|
if (dryRun) {
|
|
167
|
-
reportDryRun({ asJson, projectRef, detection, entities, relationships });
|
|
169
|
+
reportDryRun({ asJson, projectRef, detection, toolId: tool.id, executable: tool.executable, entities, relationships });
|
|
168
170
|
return;
|
|
169
171
|
}
|
|
170
172
|
const policiesRaw = await callMcpTool("nexarch_get_applied_policies", {});
|
|
@@ -198,9 +200,9 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
198
200
|
// failure — is what lets an agent finish the job unaided.
|
|
199
201
|
const roots = detection.rootModules;
|
|
200
202
|
const isLibrary = detection.repoKind === "module_library";
|
|
201
|
-
const nextSteps = isLibrary ? [] : nextStepsFor(projectRef, detection);
|
|
203
|
+
const nextSteps = isLibrary ? [] : nextStepsFor(projectRef, detection, tool.id);
|
|
202
204
|
if (!asJson && !isLibrary && roots.length > 1)
|
|
203
|
-
printNextSteps(nextSteps, true);
|
|
205
|
+
printNextSteps(nextSteps, true, tool.executable);
|
|
204
206
|
if (!asJson && isLibrary) {
|
|
205
207
|
console.log(`\nThis repository publishes reusable modules; it does not provision an estate.`);
|
|
206
208
|
console.log(`There is no state to read, so there is nothing to ingest — and that is the`);
|
|
@@ -217,7 +219,7 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
217
219
|
const shouldIngest = nonInteractive ? false : await confirm("\nRead the current state and populate the graph now?");
|
|
218
220
|
if (shouldIngest) {
|
|
219
221
|
try {
|
|
220
|
-
await ingestInfra(["--dir", dir, "--project-ref", projectRef]);
|
|
222
|
+
await ingestInfra(["--dir", dir, "--project-ref", projectRef, "--tool", tool.id]);
|
|
221
223
|
ingested = true;
|
|
222
224
|
}
|
|
223
225
|
catch (error) {
|
|
@@ -232,7 +234,7 @@ export async function runInfrastructureOnboarding(options, detection) {
|
|
|
232
234
|
// come from the command that did the work, not from a convention it half
|
|
233
235
|
// remembers.
|
|
234
236
|
const outstanding = isLibrary ? [] : ingested ? nextSteps.filter((step) => step.rootModule !== "." && roots.length > 1) : nextSteps;
|
|
235
|
-
const { file, snippet } = ciSnippetFor(detection.ciSystem, detection.ciFile);
|
|
237
|
+
const { file, snippet } = ciSnippetFor(detection.ciSystem, detection.ciFile, tool.executable);
|
|
236
238
|
if (asJson) {
|
|
237
239
|
process.stdout.write(`${JSON.stringify({
|
|
238
240
|
ok: true,
|