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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|