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
|
@@ -5,7 +5,7 @@ import { basename, join, relative, resolve as resolvePath } from "node:path";
|
|
|
5
5
|
import { requireCredentials } from "../lib/credentials.js";
|
|
6
6
|
import { callMcpTool } from "../lib/mcp.js";
|
|
7
7
|
import { buildVersionAttributes } from "../lib/version-normalization.js";
|
|
8
|
-
import { detectInfrastructureProject } from "../lib/
|
|
8
|
+
import { detectInfrastructureProject } from "../lib/iac-detect.js";
|
|
9
9
|
import { runInfrastructureOnboarding } from "./init-project-infra.js";
|
|
10
10
|
import { detectDataProject } from "../lib/data-project-detect.js";
|
|
11
11
|
import { runDataProjectOnboarding } from "./init-project-data.js";
|
|
@@ -1238,6 +1238,63 @@ export function scoreApplicationCandidate(app, projectName, repoUrl, homepageUrl
|
|
|
1238
1238
|
return null;
|
|
1239
1239
|
return { entityRef, name: app.name, score: Math.min(1, score), reasons };
|
|
1240
1240
|
}
|
|
1241
|
+
/**
|
|
1242
|
+
* For each detected name, the entity_refs of what declares it.
|
|
1243
|
+
*
|
|
1244
|
+
* Sent with name resolution so that a name not yet in the catalogue is recorded
|
|
1245
|
+
* against the thing that depends on it. Without it, adding `elkjs` to the
|
|
1246
|
+
* catalogue later added it to every workspace that had seen it -- attached to
|
|
1247
|
+
* nothing, because the sighting only knew the repository, and on a first scan
|
|
1248
|
+
* not even that.
|
|
1249
|
+
*
|
|
1250
|
+
* Only application-like packages are dependents: the re-resolve job writes the
|
|
1251
|
+
* edge directly, and a shared library depending on a technology is not an edge
|
|
1252
|
+
* the ontology draws. A monorepo's root manifest is repository tooling, not a
|
|
1253
|
+
* deployable, so it has no dependent; a single-package repository's root is
|
|
1254
|
+
* the application.
|
|
1255
|
+
*/
|
|
1256
|
+
export function dependentRefsForNames(params) {
|
|
1257
|
+
const refs = new Map();
|
|
1258
|
+
const declare = (name, ref) => {
|
|
1259
|
+
if (!refs.has(name))
|
|
1260
|
+
refs.set(name, new Set());
|
|
1261
|
+
refs.get(name).add(ref);
|
|
1262
|
+
};
|
|
1263
|
+
for (const sp of params.subPackages) {
|
|
1264
|
+
if (!isApplicationLikeEntityType(sp.entityType) || !sp.externalKey)
|
|
1265
|
+
continue;
|
|
1266
|
+
for (const dep of sp.depSpecs)
|
|
1267
|
+
declare(dep.name, sp.externalKey);
|
|
1268
|
+
}
|
|
1269
|
+
if (params.rootDependentRef) {
|
|
1270
|
+
for (const name of params.rootDepNames)
|
|
1271
|
+
declare(name, params.rootDependentRef);
|
|
1272
|
+
}
|
|
1273
|
+
return Object.fromEntries([...refs].map(([name, set]) => [name, [...set].sort()]));
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Existing applications that resemble a repository, for a monorepo.
|
|
1277
|
+
*
|
|
1278
|
+
* Scored on the repository, not on each deployable: per-deployable name
|
|
1279
|
+
* scoring reports the wrong thing. A CLI whose npm name is the product name
|
|
1280
|
+
* scores as a duplicate of the product's website, and short names like "web"
|
|
1281
|
+
* match anything they happen to be a substring of.
|
|
1282
|
+
*
|
|
1283
|
+
* `ownRefs` are this repository's own deployables. They exist on a re-run and
|
|
1284
|
+
* every one contains the repository name, so without excluding them a second
|
|
1285
|
+
* scan reports the repository as a duplicate of itself.
|
|
1286
|
+
*/
|
|
1287
|
+
export function similarApplicationsForRepository(apps, params) {
|
|
1288
|
+
return apps
|
|
1289
|
+
.filter((app) => {
|
|
1290
|
+
const ref = app.entityRef ?? app.externalKey;
|
|
1291
|
+
return Boolean(ref) && !params.ownRefs.has(ref);
|
|
1292
|
+
})
|
|
1293
|
+
.map((app) => scoreApplicationCandidate(app, params.repositoryName, params.repoUrl, params.homepageUrl))
|
|
1294
|
+
.filter((match) => Boolean(match))
|
|
1295
|
+
.sort((a, b) => b.score - a.score)
|
|
1296
|
+
.slice(0, 5);
|
|
1297
|
+
}
|
|
1241
1298
|
// ─── Main command ─────────────────────────────────────────────────────────────
|
|
1242
1299
|
export async function initProject(args) {
|
|
1243
1300
|
const asJson = parseFlag(args, "--json");
|
|
@@ -1307,6 +1364,7 @@ export async function initProject(args) {
|
|
|
1307
1364
|
nonInteractive: parseFlag(args, "--non-interactive"),
|
|
1308
1365
|
skipIngest: parseFlag(args, "--no-ingest"),
|
|
1309
1366
|
dryRun,
|
|
1367
|
+
tool: parseOptionValue(args, "--tool"),
|
|
1310
1368
|
}, infrastructure);
|
|
1311
1369
|
}
|
|
1312
1370
|
return;
|
|
@@ -1325,6 +1383,7 @@ export async function initProject(args) {
|
|
|
1325
1383
|
nonInteractive: parseFlag(args, "--non-interactive"),
|
|
1326
1384
|
skipIngest: parseFlag(args, "--no-ingest"),
|
|
1327
1385
|
dryRun,
|
|
1386
|
+
tool: parseOptionValue(args, "--tool"),
|
|
1328
1387
|
}, infrastructure);
|
|
1329
1388
|
return;
|
|
1330
1389
|
}
|
|
@@ -1399,11 +1458,17 @@ export async function initProject(args) {
|
|
|
1399
1458
|
console.log("\nResolving against reference library…");
|
|
1400
1459
|
const allResolveResults = [];
|
|
1401
1460
|
const BATCH_SIZE = 200;
|
|
1461
|
+
const dependentRefsByName = dependentRefsForNames({
|
|
1462
|
+
subPackages,
|
|
1463
|
+
rootDepNames,
|
|
1464
|
+
rootDependentRef: isMonorepo ? null : projectExternalKey,
|
|
1465
|
+
});
|
|
1402
1466
|
logProgress("resolve.start", `count=${detectedNames.length}, batchSize=${BATCH_SIZE}`);
|
|
1403
1467
|
for (let i = 0; i < detectedNames.length; i += BATCH_SIZE) {
|
|
1404
1468
|
const batch = detectedNames.slice(i, i + BATCH_SIZE);
|
|
1405
1469
|
logProgress("resolve.batch", `${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(detectedNames.length / BATCH_SIZE)} size=${batch.length}`);
|
|
1406
|
-
const
|
|
1470
|
+
const dependentRefs = Object.fromEntries(batch.filter((name) => dependentRefsByName[name]?.length).map((name) => [name, dependentRefsByName[name]]));
|
|
1471
|
+
const raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId, projectRef: projectEntityKey, dependentRefs }, { batchSize: batch.length });
|
|
1407
1472
|
const data = parseToolText(raw);
|
|
1408
1473
|
allResolveResults.push(...data.results);
|
|
1409
1474
|
}
|
|
@@ -1546,6 +1611,37 @@ export async function initProject(args) {
|
|
|
1546
1611
|
}
|
|
1547
1612
|
}
|
|
1548
1613
|
}
|
|
1614
|
+
// The branch above is gated on !isMonorepo, so for a monorepo -- including
|
|
1615
|
+
// this repository -- the duplicate check never ran at all. Paul's 16
|
|
1616
|
+
// September test registered web, mcp-gateway and nexarch-cli into a
|
|
1617
|
+
// workspace that already held "nexarch.ai website" from onboarding, and
|
|
1618
|
+
// nothing was flagged: item 17's homepage scoring simply never executed.
|
|
1619
|
+
//
|
|
1620
|
+
// Scored at REPOSITORY level, not per deployable. Name scoring per deployable
|
|
1621
|
+
// is worse than silence here: the CLI's npm name is literally "nexarch", so it
|
|
1622
|
+
// scores 0.5 against the website and would be reported as its duplicate, and
|
|
1623
|
+
// "web" matches only because it is a substring of "website". The repository
|
|
1624
|
+
// name is the honest signal, and which deployable (if any) is the same system
|
|
1625
|
+
// is a judgement for a human -- the step below says so.
|
|
1626
|
+
if (projectConstruct && isMonorepo && !forceCreateApplication) {
|
|
1627
|
+
// This repository's own deployables exist on a re-run, and every one of
|
|
1628
|
+
// them contains the repository name. Without excluding them, a second run
|
|
1629
|
+
// reports the repo as a duplicate of itself.
|
|
1630
|
+
const ownRefs = new Set(subPackages.map((sp) => sp.externalKey));
|
|
1631
|
+
const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500, phase: "monorepo.similar" });
|
|
1632
|
+
similarApplications = similarApplicationsForRepository(parseToolText(appsRaw).entities ?? [], {
|
|
1633
|
+
repositoryName: displayName,
|
|
1634
|
+
repoUrl,
|
|
1635
|
+
homepageUrl: declaredHomepage,
|
|
1636
|
+
ownRefs,
|
|
1637
|
+
});
|
|
1638
|
+
if (similarApplications.length > 0 && !asJson) {
|
|
1639
|
+
console.log("\nExisting applications resemble this repository — one of its deployables may already be registered:");
|
|
1640
|
+
for (const match of similarApplications) {
|
|
1641
|
+
console.log(` - ${match.name} (${match.entityRef}) score=${match.score.toFixed(2)} [${match.reasons.join(", ")}]`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1549
1645
|
logProgress(projectConstruct ? "project.target" : "application.target", projectConstruct ? (isMonorepo ? projectEntityKey : `${projectEntityKey} + ${projectExternalKey}`) : projectExternalKey);
|
|
1550
1646
|
// In refresh mode, snapshot the current graph state for this project before writing,
|
|
1551
1647
|
// so we can diff what changed and surface stale relationships to the agent.
|
|
@@ -1925,13 +2021,36 @@ export async function initProject(args) {
|
|
|
1925
2021
|
// this application already exists under another name, everything after this
|
|
1926
2022
|
// enriches a duplicate. The scan has always computed these matches; until
|
|
1927
2023
|
// now it printed them only in human mode, so the agent never saw them.
|
|
1928
|
-
|
|
2024
|
+
const matchList = similarApplications
|
|
2025
|
+
.map((m) => `${m.name} (${m.entityRef}, score ${m.score.toFixed(2)}, ${m.reasons.join(", ")})`)
|
|
2026
|
+
.join("; ");
|
|
2027
|
+
if (similarApplications.length > 0 && isMonorepo) {
|
|
2028
|
+
// --application-ref does not apply to a monorepo (the CLI says so above),
|
|
2029
|
+
// so the single-repo remedy would hand the agent a command that does
|
|
2030
|
+
// nothing. The resolution here is a merge, which a human does on the
|
|
2031
|
+
// application page.
|
|
2032
|
+
pendingSteps.push({
|
|
2033
|
+
step: stepNum++,
|
|
2034
|
+
action: "review_similar_applications",
|
|
2035
|
+
instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
|
|
2036
|
+
+ ` workspace resemble${similarApplications.length === 1 ? "s" : ""} this repository: ${matchList}.`
|
|
2037
|
+
+ " One of the deployables just registered may be the same system — for example a web app registered"
|
|
2038
|
+
+ " earlier from its public website. Tell the human which deployable you think matches and why, before"
|
|
2039
|
+
+ " enriching anything. The new deployables are PROPOSED, so nothing is broken yet; if one is a"
|
|
2040
|
+
+ " duplicate, the human merges the two with \"Merge with…\" on the application's page.",
|
|
2041
|
+
notes: [
|
|
2042
|
+
"Do not report the registration as complete without mentioning these.",
|
|
2043
|
+
"The match is on the repository, not a specific deployable: say which one you believe it is, and why.",
|
|
2044
|
+
"A human decides which is the same application — do not merge or decline on your own judgement.",
|
|
2045
|
+
],
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
else if (similarApplications.length > 0) {
|
|
1929
2049
|
pendingSteps.push({
|
|
1930
2050
|
step: stepNum++,
|
|
1931
2051
|
action: "review_similar_applications",
|
|
1932
2052
|
instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
|
|
1933
|
-
+ ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered
|
|
1934
|
-
+ ` ${similarApplications.map((m) => `${m.name} (${m.entityRef}, score ${m.score.toFixed(2)}, ${m.reasons.join(", ")})`).join("; ")}.`
|
|
2053
|
+
+ ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered: ${matchList}.`
|
|
1935
2054
|
+ " Tell the human about these before enriching anything. This registration was created as a"
|
|
1936
2055
|
+ " PROPOSED application and is not in the canonical graph yet, so nothing is broken — but if one of"
|
|
1937
2056
|
+ " these is the same application, it should be mapped rather than duplicated.",
|
package/dist/index.js
CHANGED
|
@@ -172,6 +172,7 @@ Usage:
|
|
|
172
172
|
--batch-size <n> upsert batch size (default: 10)
|
|
173
173
|
--profile include timing/profile data in JSON output
|
|
174
174
|
--dry-run preview without writing
|
|
175
|
+
--tool <terraform|opentofu> select the IaC state CLI
|
|
175
176
|
--json
|
|
176
177
|
nexarch update-project
|
|
177
178
|
Re-scan a previously registered project directory, refresh
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { join, relative, sep } from "path";
|
|
3
|
+
import { environmentFromPath } from "./iac-detect.js";
|
|
4
|
+
const SCAN_DEPTH = 8;
|
|
5
|
+
const IGNORED = new Set([".git", "node_modules", ".venv", "venv", "dist", "build", ".next"]);
|
|
6
|
+
const YAML = /\.ya?ml$/i;
|
|
7
|
+
function walk(dir, depth = 0, files = []) {
|
|
8
|
+
if (depth > SCAN_DEPTH)
|
|
9
|
+
return files;
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return files;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (IGNORED.has(entry))
|
|
19
|
+
continue;
|
|
20
|
+
const full = join(dir, entry);
|
|
21
|
+
try {
|
|
22
|
+
if (statSync(full).isDirectory())
|
|
23
|
+
walk(full, depth + 1, files);
|
|
24
|
+
else
|
|
25
|
+
files.push(full);
|
|
26
|
+
}
|
|
27
|
+
catch { /* unreadable paths are not evidence */ }
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
function hasPlaybookShape(file) {
|
|
32
|
+
if (!YAML.test(file))
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
const text = readFileSync(file, "utf8");
|
|
36
|
+
return /^\s*-?\s*hosts\s*:/m.test(text) && /^\s*(tasks|roles|pre_tasks|post_tasks|handlers)\s*:/m.test(text);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function normalise(repoDir, file) {
|
|
43
|
+
return relative(repoDir, file).split(sep).join("/");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The environment an inventory is for, from its path.
|
|
47
|
+
*
|
|
48
|
+
* Terraform gets this from a root module directory or a workspace. Ansible has
|
|
49
|
+
* neither, and this is the gap the plan called out as the hard part: the
|
|
50
|
+
* environment lives in how the inventory is filed. `inventories/production/hosts`
|
|
51
|
+
* and `inventory/prod.ini` both say it plainly, so the filename is consulted as
|
|
52
|
+
* well as the directories -- which is where `environmentFromPath` alone stops,
|
|
53
|
+
* since a Terraform root module is always a directory.
|
|
54
|
+
*
|
|
55
|
+
* The vocabulary is deliberately shared with `environmentFromPath` rather than
|
|
56
|
+
* copied: two lists of environment names drift, and then the same repository
|
|
57
|
+
* reports different environments depending on which tool read it.
|
|
58
|
+
*/
|
|
59
|
+
export function environmentFromInventoryPath(relativePath) {
|
|
60
|
+
const fromDirectories = environmentFromPath(relativePath);
|
|
61
|
+
if (fromDirectories)
|
|
62
|
+
return fromDirectories;
|
|
63
|
+
const base = (relativePath.split("/").at(-1) ?? "").replace(/\.(ini|ya?ml|json)$/i, "");
|
|
64
|
+
return base ? environmentFromPath(base) : null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Detects Ansible from corroborating repository evidence.
|
|
68
|
+
*
|
|
69
|
+
* A YAML file containing `hosts:` is not enough: application configuration
|
|
70
|
+
* regularly has the same vocabulary. Like IaC detection, a playbook needs a
|
|
71
|
+
* second operational signal (inventory, roles, configuration, or collection)
|
|
72
|
+
* before it can change onboarding behaviour.
|
|
73
|
+
*/
|
|
74
|
+
export function detectAnsibleProject(repoDir) {
|
|
75
|
+
const files = walk(repoDir);
|
|
76
|
+
const relativeFiles = files.map((file) => normalise(repoDir, file));
|
|
77
|
+
const playbookFiles = files.filter(hasPlaybookShape).map((file) => normalise(repoDir, file)).sort();
|
|
78
|
+
const inventoryFiles = relativeFiles.filter((file) => {
|
|
79
|
+
const base = file.split("/").at(-1) ?? "";
|
|
80
|
+
return /(^|\/)(inventory|inventories)(\/|$)/i.test(file) || /^(hosts|inventory)(\.ini|\.ya?ml)?$/i.test(base);
|
|
81
|
+
}).sort();
|
|
82
|
+
const roleDirectories = relativeFiles
|
|
83
|
+
.filter((file) => /(^|\/)roles\/[^/]+\/(tasks|handlers|defaults|vars)\/main\.ya?ml$/i.test(file))
|
|
84
|
+
.map((file) => file.replace(/\/(tasks|handlers|defaults|vars)\/main\.ya?ml$/i, ""))
|
|
85
|
+
.filter((value, index, values) => values.indexOf(value) === index)
|
|
86
|
+
.sort();
|
|
87
|
+
const collectionDirectories = relativeFiles
|
|
88
|
+
.filter((file) => /(^|\/)collections\/requirements\.ya?ml$/i.test(file) || /(^|\/)galaxy\.ya?ml$/i.test(file))
|
|
89
|
+
.map((file) => file.replace(/\/(requirements|galaxy)\.ya?ml$/i, ""))
|
|
90
|
+
.filter((value, index, values) => values.indexOf(value) === index)
|
|
91
|
+
.sort();
|
|
92
|
+
const hasConfig = existsSync(join(repoDir, "ansible.cfg"));
|
|
93
|
+
const signals = [];
|
|
94
|
+
if (playbookFiles.length)
|
|
95
|
+
signals.push(`${playbookFiles.length} playbook${playbookFiles.length === 1 ? "" : "s"}`);
|
|
96
|
+
if (inventoryFiles.length)
|
|
97
|
+
signals.push(`${inventoryFiles.length} inventory file${inventoryFiles.length === 1 ? "" : "s"}`);
|
|
98
|
+
if (roleDirectories.length)
|
|
99
|
+
signals.push(`${roleDirectories.length} role${roleDirectories.length === 1 ? "" : "s"}`);
|
|
100
|
+
if (collectionDirectories.length)
|
|
101
|
+
signals.push(`${collectionDirectories.length} collection declaration${collectionDirectories.length === 1 ? "" : "s"}`);
|
|
102
|
+
if (hasConfig)
|
|
103
|
+
signals.push("ansible.cfg");
|
|
104
|
+
const environments = [...new Set(inventoryFiles.map(environmentFromInventoryPath).filter((e) => Boolean(e)))].sort();
|
|
105
|
+
if (environments.length)
|
|
106
|
+
signals.push(`environments: ${environments.join(", ")}`);
|
|
107
|
+
const corroborated = inventoryFiles.length > 0 || roleDirectories.length > 0 || collectionDirectories.length > 0 || hasConfig;
|
|
108
|
+
return { isAnsible: playbookFiles.length > 0 && corroborated, signals, playbookFiles, inventoryFiles, environments, roleDirectories, collectionDirectories };
|
|
109
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
export const DEFAULT_ANSIBLE_HOST_MAPPING = {
|
|
3
|
+
entityTypeCode: "platform_component",
|
|
4
|
+
entitySubtypeCode: null,
|
|
5
|
+
role: "compute_host",
|
|
6
|
+
resourceType: "ansible_host",
|
|
7
|
+
};
|
|
8
|
+
/** Ansible's own bookkeeping groups: every host is in them, so they classify nothing. */
|
|
9
|
+
const BOOKKEEPING_GROUPS = new Set(["all", "ungrouped"]);
|
|
10
|
+
function hostNamesOf(raw) {
|
|
11
|
+
const hosts = raw.hosts;
|
|
12
|
+
const names = Array.isArray(hosts) ? hosts : hosts && typeof hosts === "object" ? Object.keys(hosts) : [];
|
|
13
|
+
return names.filter((name) => typeof name === "string" && Boolean(name.trim()));
|
|
14
|
+
}
|
|
15
|
+
function childNamesOf(raw) {
|
|
16
|
+
const children = raw.children;
|
|
17
|
+
const names = Array.isArray(children) ? children : children && typeof children === "object" ? Object.keys(children) : [];
|
|
18
|
+
return names.filter((name) => typeof name === "string" && Boolean(name.trim()));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolves group membership, following `children`.
|
|
22
|
+
*
|
|
23
|
+
* Nested groups are how real inventories express environment: `prod` holds no
|
|
24
|
+
* hosts of its own, only `children: [web, db]`. Reading `hosts` alone gives a
|
|
25
|
+
* host the group it was listed under and loses every ancestor -- which is to
|
|
26
|
+
* say, it loses the environment, the one thing the group tree is usually for.
|
|
27
|
+
*
|
|
28
|
+
* Shared by both parsers on purpose. JSON and INI express the same tree in
|
|
29
|
+
* different syntax, and resolving it twice is how the two formats come to
|
|
30
|
+
* disagree about the same estate.
|
|
31
|
+
*
|
|
32
|
+
* The seen set is against cycles, which Ansible rejects at runtime but which
|
|
33
|
+
* can sit in a file and must not hang the scan.
|
|
34
|
+
*/
|
|
35
|
+
function resolveMemberships(tree) {
|
|
36
|
+
const hostsUnder = (group, seen = new Set()) => {
|
|
37
|
+
if (seen.has(group))
|
|
38
|
+
return [];
|
|
39
|
+
seen.add(group);
|
|
40
|
+
return [
|
|
41
|
+
...(tree.hosts.get(group) ?? []),
|
|
42
|
+
...(tree.children.get(group) ?? []).flatMap((child) => hostsUnder(child, seen)),
|
|
43
|
+
];
|
|
44
|
+
};
|
|
45
|
+
const memberships = new Map();
|
|
46
|
+
for (const group of new Set([...tree.hosts.keys(), ...tree.children.keys()])) {
|
|
47
|
+
for (const name of hostsUnder(group)) {
|
|
48
|
+
const memberOf = memberships.get(name) ?? new Set();
|
|
49
|
+
if (!BOOKKEEPING_GROUPS.has(group))
|
|
50
|
+
memberOf.add(group);
|
|
51
|
+
memberships.set(name, memberOf);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...memberships.entries()]
|
|
55
|
+
.map(([name, groups]) => ({ name, groups: [...groups].sort() }))
|
|
56
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
57
|
+
}
|
|
58
|
+
function groupsFromObject(value) {
|
|
59
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
60
|
+
return [];
|
|
61
|
+
const tree = { hosts: new Map(), children: new Map() };
|
|
62
|
+
for (const [group, raw] of Object.entries(value)) {
|
|
63
|
+
if (group === "_meta" || !raw || typeof raw !== "object" || Array.isArray(raw))
|
|
64
|
+
continue;
|
|
65
|
+
tree.hosts.set(group, hostNamesOf(raw));
|
|
66
|
+
tree.children.set(group, childNamesOf(raw));
|
|
67
|
+
}
|
|
68
|
+
return resolveMemberships(tree);
|
|
69
|
+
}
|
|
70
|
+
function parseIni(text) {
|
|
71
|
+
const tree = { hosts: new Map(), children: new Map() };
|
|
72
|
+
let group = "ungrouped";
|
|
73
|
+
// "hosts", "children" or "vars" — an INI section header says which, and a
|
|
74
|
+
// `:children` section is the nesting the JSON form spells as `children`.
|
|
75
|
+
let section = "hosts";
|
|
76
|
+
const push = (map, key, value) => {
|
|
77
|
+
map.set(key, [...(map.get(key) ?? []), value]);
|
|
78
|
+
};
|
|
79
|
+
for (const line of text.split(/\r?\n/)) {
|
|
80
|
+
const clean = line.replace(/\s[;#].*$/, "").trim();
|
|
81
|
+
const header = clean.match(/^\[([^\]]+)\]$/);
|
|
82
|
+
if (header) {
|
|
83
|
+
const suffix = /:(children|vars)$/.exec(header[1])?.[1];
|
|
84
|
+
section = suffix === "children" ? "children" : suffix === "vars" ? "vars" : "hosts";
|
|
85
|
+
group = header[1].replace(/:(children|vars)$/, "");
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!clean || section === "vars" || clean.startsWith("#") || clean.startsWith(";"))
|
|
89
|
+
continue;
|
|
90
|
+
// `web-01 ansible_host=10.0.0.5` is an ordinary host line: inline variables
|
|
91
|
+
// are how INI inventories are usually written. Skipping every line
|
|
92
|
+
// containing `=` dropped exactly those hosts, which is most of them. The
|
|
93
|
+
// `:vars` section is already excluded above, so the only thing that rule
|
|
94
|
+
// caught was the common case.
|
|
95
|
+
const name = clean.split(/\s+/)[0];
|
|
96
|
+
if (!name || name.includes("=") || name.includes(":"))
|
|
97
|
+
continue;
|
|
98
|
+
push(section === "children" ? tree.children : tree.hosts, group, name);
|
|
99
|
+
}
|
|
100
|
+
return resolveMemberships(tree);
|
|
101
|
+
}
|
|
102
|
+
/** Parses `ansible-inventory --list --export` JSON/YAML, or a conventional INI inventory. */
|
|
103
|
+
export function parseAnsibleInventory(input) {
|
|
104
|
+
if (input && typeof input === "object")
|
|
105
|
+
return { hosts: groupsFromObject(input) };
|
|
106
|
+
const text = input.trim();
|
|
107
|
+
if (!text)
|
|
108
|
+
return { hosts: [] };
|
|
109
|
+
// `{` only. An INI inventory conventionally opens with a group header, so
|
|
110
|
+
// treating a leading `[` as JSON sent every one of them to JSON.parse and
|
|
111
|
+
// threw on the most common inventory format there is. A JSON array is not
|
|
112
|
+
// valid `ansible-inventory --list` output anyway.
|
|
113
|
+
if (text.startsWith("{"))
|
|
114
|
+
return { hosts: groupsFromObject(JSON.parse(text)) };
|
|
115
|
+
if (/^\s*\w[\w-]*\s*:/m.test(text) && !/^\s*\[/m.test(text))
|
|
116
|
+
return { hosts: groupsFromObject(parse(text)) };
|
|
117
|
+
return { hosts: parseIni(text) };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Projects only inventory identity and group membership. In particular,
|
|
121
|
+
* `_meta.hostvars`, inline `ansible_*` variables, vault values, and connection
|
|
122
|
+
* settings are intentionally not present in either attributes or tags.
|
|
123
|
+
*/
|
|
124
|
+
export function projectAnsibleInventory(params) {
|
|
125
|
+
const mapping = params.mapping ?? DEFAULT_ANSIBLE_HOST_MAPPING;
|
|
126
|
+
const resources = params.inventory.hosts.map((host) => ({
|
|
127
|
+
address: `ansible:${host.name}`,
|
|
128
|
+
resourceType: mapping.resourceType,
|
|
129
|
+
entityTypeCode: mapping.entityTypeCode,
|
|
130
|
+
entitySubtypeCode: mapping.entitySubtypeCode ?? null,
|
|
131
|
+
role: mapping.role,
|
|
132
|
+
// Not "managed". Ansible configures hosts that already exist -- the
|
|
133
|
+
// inventory is a list of machines someone else provisioned. Recording them
|
|
134
|
+
// as managed would have the graph claim we created a server we only
|
|
135
|
+
// installed packages on, and `IacTool.provisions` exists to carry exactly
|
|
136
|
+
// this distinction.
|
|
137
|
+
mode: "declared",
|
|
138
|
+
name: host.name,
|
|
139
|
+
attributes: { inventory_hostname: host.name, ansible_groups: [...host.groups].sort() },
|
|
140
|
+
tags: {},
|
|
141
|
+
identityIds: [],
|
|
142
|
+
}));
|
|
143
|
+
return {
|
|
144
|
+
environment: params.environment,
|
|
145
|
+
iacToolVersion: null,
|
|
146
|
+
resources,
|
|
147
|
+
unknownTypes: [],
|
|
148
|
+
stats: { resourcesSeen: resources.length, resourcesProjected: resources.length, attributesDroppedNotAllowlisted: 0, attributesDroppedSensitive: 0 },
|
|
149
|
+
};
|
|
150
|
+
}
|