nexarch 0.12.5 → 0.12.7
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 +319 -0
- package/dist/commands/init-project-infra.js +170 -0
- package/dist/commands/init-project.js +53 -84
- package/dist/index.js +12 -3
- package/dist/lib/credentials.js +33 -1
- package/dist/lib/terraform-catalogue-seed.js +206 -0
- package/dist/lib/terraform-detect.js +338 -0
- package/dist/lib/terraform-projection.js +231 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { appliedPolicies } from "./commands/applied-policies.js";
|
|
|
26
26
|
import { governanceSummary } from "./commands/governance-summary.js";
|
|
27
27
|
import { proposalsStart } from "./commands/proposals-start.js";
|
|
28
28
|
import { registerRuntime } from "./commands/register-runtime.js";
|
|
29
|
+
import { ingestInfra } from "./commands/ingest-infra.js";
|
|
29
30
|
const [, , command, ...args] = process.argv;
|
|
30
31
|
const commands = {
|
|
31
32
|
login,
|
|
@@ -37,6 +38,7 @@ const commands = {
|
|
|
37
38
|
"init-agent": initAgent,
|
|
38
39
|
"agent-identify": agentIdentify,
|
|
39
40
|
"init-project": initProject,
|
|
41
|
+
"ingest-infra": ingestInfra,
|
|
40
42
|
"update-project": (args) => initProject(["--refresh", ...args]),
|
|
41
43
|
"update-entity": updateEntity,
|
|
42
44
|
"add-relationship": addRelationship,
|
|
@@ -102,15 +104,22 @@ Usage:
|
|
|
102
104
|
config files against the reference library, write entities and
|
|
103
105
|
relationships to the architecture graph, and log unresolved
|
|
104
106
|
names as reference candidates.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
Monorepos register a project entity plus one proposed
|
|
108
|
+
application per deployable package (sourced_from the project).
|
|
109
|
+
Single-package repos register the repo's one application; when
|
|
110
|
+
similar applications exist they are listed, and the new
|
|
111
|
+
application is created as proposed for review at activation.
|
|
112
|
+
Terraform repositories take a different path: no dependency
|
|
113
|
+
scan and no applications. They register a project plus the
|
|
114
|
+
environments their root modules define, and hand over the
|
|
115
|
+
per-root ingest-infra commands that read the actual estate.
|
|
107
116
|
Options: --dir <path> (default: cwd)
|
|
108
117
|
--name <name> override project name
|
|
109
118
|
--entity-type <code> (default: application)
|
|
110
119
|
--application-ref <entityRef> force mapping target
|
|
111
120
|
--create-application force new application entity
|
|
112
121
|
--auto-map-application auto-map only when high confidence
|
|
113
|
-
--non-interactive
|
|
122
|
+
--non-interactive deprecated (mapping no longer prompts)
|
|
114
123
|
--batch-size <n> upsert batch size (default: 10)
|
|
115
124
|
--profile include timing/profile data in JSON output
|
|
116
125
|
--dry-run preview without writing
|
package/dist/lib/credentials.js
CHANGED
|
@@ -4,7 +4,35 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "fs";
|
|
|
4
4
|
function credentialsPath() {
|
|
5
5
|
return join(homedir(), ".nexarch", "credentials.json");
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Credentials supplied by the environment, for CI.
|
|
9
|
+
*
|
|
10
|
+
* A pipeline cannot run `nexarch login` — that flow needs a browser and a
|
|
11
|
+
* human — so unattended commands such as `ingest-infra` take a service
|
|
12
|
+
* credential from the environment instead. `NEXARCH_TOKEN` is the token issued
|
|
13
|
+
* by `init-agent`; the company is carried by the token itself, so
|
|
14
|
+
* `NEXARCH_COMPANY_ID` is only needed for legacy static tokens.
|
|
15
|
+
*
|
|
16
|
+
* No local expiry check: the gateway is the authority on whether a service
|
|
17
|
+
* credential is still valid, and a stale clock in a runner should not be able
|
|
18
|
+
* to fail a build with a misleading "session expired".
|
|
19
|
+
*/
|
|
20
|
+
function credentialsFromEnvironment() {
|
|
21
|
+
const token = process.env.NEXARCH_TOKEN?.trim();
|
|
22
|
+
if (!token)
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
token,
|
|
26
|
+
email: process.env.NEXARCH_ACTOR ?? "ci",
|
|
27
|
+
company: "",
|
|
28
|
+
companyId: process.env.NEXARCH_COMPANY_ID?.trim() ?? "",
|
|
29
|
+
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
7
32
|
export function loadCredentials() {
|
|
33
|
+
const fromEnv = credentialsFromEnvironment();
|
|
34
|
+
if (fromEnv)
|
|
35
|
+
return fromEnv;
|
|
8
36
|
const path = credentialsPath();
|
|
9
37
|
if (!existsSync(path))
|
|
10
38
|
return null;
|
|
@@ -35,8 +63,12 @@ export function removeCredentials() {
|
|
|
35
63
|
export function requireCredentials() {
|
|
36
64
|
const creds = loadCredentials();
|
|
37
65
|
if (!creds) {
|
|
38
|
-
throw new Error("Not logged in. Run `nexarch login` to authenticate.");
|
|
66
|
+
throw new Error("Not logged in. Run `nexarch login` to authenticate, or set NEXARCH_TOKEN for unattended use.");
|
|
39
67
|
}
|
|
68
|
+
// Environment-supplied service credentials are validated by the gateway, not
|
|
69
|
+
// by this process; only the stored interactive session carries a local expiry.
|
|
70
|
+
if (process.env.NEXARCH_TOKEN?.trim())
|
|
71
|
+
return creds;
|
|
40
72
|
if (new Date(creds.expiresAt) < new Date()) {
|
|
41
73
|
throw new Error("Your session has expired. Run `nexarch login` to re-authenticate.");
|
|
42
74
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed catalogue for Terraform resource types.
|
|
3
|
+
*
|
|
4
|
+
* The catalogue proper is platform reference data (see the infrastructure
|
|
5
|
+
* ingestion ADR) so that supporting a new Azure service is a data change served
|
|
6
|
+
* to every tenant, not a CLI release. This list is the bootstrap: what the
|
|
7
|
+
* platform is seeded with, and what the CLI falls back to when it cannot reach
|
|
8
|
+
* the registry — the same pattern the Claude Code skill template already uses.
|
|
9
|
+
*
|
|
10
|
+
* Two rules when adding an entry:
|
|
11
|
+
*
|
|
12
|
+
* - Name only attributes an application author must conform to. This is the
|
|
13
|
+
* contract surface, not an inventory: Azure Resource Graph already knows
|
|
14
|
+
* what exists, in more detail, in real time.
|
|
15
|
+
* - Never name an attribute that can carry a credential. Terraform's
|
|
16
|
+
* `sensitive_values` gate will drop it anyway, but the list should not be
|
|
17
|
+
* relying on the second gate to be correct.
|
|
18
|
+
*/
|
|
19
|
+
const COMMON = ["name", "location", "resource_group_name"];
|
|
20
|
+
export const TERRAFORM_CATALOGUE_SEED = [
|
|
21
|
+
// ---------------------------------------------------------------- compute
|
|
22
|
+
{
|
|
23
|
+
resourceType: "azurerm_container_app",
|
|
24
|
+
entityTypeCode: "platform_component",
|
|
25
|
+
entitySubtypeCode: "platform_runtime",
|
|
26
|
+
role: "compute_host",
|
|
27
|
+
bindable: true,
|
|
28
|
+
attributes: [...COMMON, "container_app_environment_id", "revision_mode", "workload_profile_name"],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
resourceType: "azurerm_container_app_job",
|
|
32
|
+
entityTypeCode: "platform_component",
|
|
33
|
+
entitySubtypeCode: "platform_runtime",
|
|
34
|
+
role: "compute_host",
|
|
35
|
+
bindable: true,
|
|
36
|
+
attributes: [...COMMON, "container_app_environment_id", "replica_timeout_in_seconds", "workload_profile_name"],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
resourceType: "azurerm_container_app_environment",
|
|
40
|
+
entityTypeCode: "platform_component",
|
|
41
|
+
entitySubtypeCode: "platform_runtime",
|
|
42
|
+
role: "compute_environment",
|
|
43
|
+
attributes: [...COMMON, "default_domain", "infrastructure_subnet_id", "internal_load_balancer_enabled", "static_ip_address", "zone_redundancy_enabled"],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
resourceType: "azurerm_linux_web_app",
|
|
47
|
+
entityTypeCode: "platform_component",
|
|
48
|
+
entitySubtypeCode: "platform_runtime",
|
|
49
|
+
role: "compute_host",
|
|
50
|
+
bindable: true,
|
|
51
|
+
attributes: [...COMMON, "service_plan_id", "https_only", "default_hostname"],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
resourceType: "azurerm_service_plan",
|
|
55
|
+
entityTypeCode: "platform_component",
|
|
56
|
+
entitySubtypeCode: "platform_runtime",
|
|
57
|
+
role: "compute_environment",
|
|
58
|
+
attributes: [...COMMON, "sku_name", "os_type", "worker_count"],
|
|
59
|
+
},
|
|
60
|
+
// ------------------------------------------------------------------- data
|
|
61
|
+
{
|
|
62
|
+
resourceType: "azurerm_postgresql_flexible_server",
|
|
63
|
+
entityTypeCode: "data_store",
|
|
64
|
+
entitySubtypeCode: "store_relational",
|
|
65
|
+
role: "data_store",
|
|
66
|
+
attributes: [...COMMON, "fqdn", "version", "sku_name", "storage_mb", "storage_tier", "public_network_access_enabled", "delegated_subnet_id", "private_dns_zone_id", "backup_retention_days", "geo_redundant_backup_enabled", "zone"],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
resourceType: "azurerm_postgresql_flexible_server_database",
|
|
70
|
+
entityTypeCode: "data_store",
|
|
71
|
+
entitySubtypeCode: "store_relational",
|
|
72
|
+
role: "data_store",
|
|
73
|
+
attributes: ["name", "server_id", "charset", "collation"],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
resourceType: "azurerm_storage_account",
|
|
77
|
+
entityTypeCode: "data_store",
|
|
78
|
+
entitySubtypeCode: "store_object",
|
|
79
|
+
role: "data_store",
|
|
80
|
+
attributes: [...COMMON, "account_kind", "account_tier", "account_replication_type", "access_tier", "https_traffic_only_enabled", "min_tls_version", "public_network_access_enabled", "allow_nested_items_to_be_public", "shared_access_key_enabled", "primary_blob_endpoint", "primary_location", "secondary_location"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
resourceType: "azurerm_storage_container",
|
|
84
|
+
entityTypeCode: "data_store",
|
|
85
|
+
entitySubtypeCode: "store_object",
|
|
86
|
+
role: "data_store",
|
|
87
|
+
attributes: ["name", "container_access_type", "storage_account_id", "has_immutability_policy"],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
resourceType: "azurerm_cosmosdb_account",
|
|
91
|
+
entityTypeCode: "data_store",
|
|
92
|
+
entitySubtypeCode: "store_document",
|
|
93
|
+
role: "data_store",
|
|
94
|
+
attributes: [...COMMON, "kind", "offer_type", "public_network_access_enabled", "endpoint"],
|
|
95
|
+
},
|
|
96
|
+
// ---------------------------------------------------------------- secrets
|
|
97
|
+
{
|
|
98
|
+
resourceType: "azurerm_key_vault",
|
|
99
|
+
entityTypeCode: "platform_component",
|
|
100
|
+
entitySubtypeCode: "platform_service",
|
|
101
|
+
role: "secret_store",
|
|
102
|
+
attributes: [...COMMON, "vault_uri", "sku_name", "enable_rbac_authorization", "rbac_authorization_enabled", "public_network_access_enabled", "purge_protection_enabled", "soft_delete_retention_days"],
|
|
103
|
+
},
|
|
104
|
+
// -------------------------------------------------------------- messaging
|
|
105
|
+
{
|
|
106
|
+
resourceType: "azurerm_servicebus_namespace",
|
|
107
|
+
entityTypeCode: "platform_component",
|
|
108
|
+
entitySubtypeCode: "platform_service",
|
|
109
|
+
role: "messaging",
|
|
110
|
+
attributes: [...COMMON, "sku", "public_network_access_enabled"],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
resourceType: "azurerm_servicebus_queue",
|
|
114
|
+
entityTypeCode: "platform_component",
|
|
115
|
+
entitySubtypeCode: "platform_service",
|
|
116
|
+
role: "messaging",
|
|
117
|
+
attributes: ["name", "namespace_id", "max_delivery_count", "requires_session"],
|
|
118
|
+
},
|
|
119
|
+
// ---------------------------------------------------------------- network
|
|
120
|
+
{
|
|
121
|
+
resourceType: "azurerm_virtual_network",
|
|
122
|
+
entityTypeCode: "platform_component",
|
|
123
|
+
entitySubtypeCode: "platform_service",
|
|
124
|
+
role: "network_boundary",
|
|
125
|
+
attributes: [...COMMON, "address_space", "guid"],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
resourceType: "azurerm_subnet",
|
|
129
|
+
entityTypeCode: "platform_component",
|
|
130
|
+
entitySubtypeCode: "platform_service",
|
|
131
|
+
role: "network_boundary",
|
|
132
|
+
attributes: ["name", "resource_group_name", "virtual_network_name", "address_prefixes", "default_outbound_access_enabled", "private_endpoint_network_policies"],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
resourceType: "azurerm_private_endpoint",
|
|
136
|
+
entityTypeCode: "platform_component",
|
|
137
|
+
entitySubtypeCode: "platform_service",
|
|
138
|
+
role: "network_boundary",
|
|
139
|
+
attributes: [...COMMON, "subnet_id"],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
resourceType: "azurerm_private_dns_zone",
|
|
143
|
+
entityTypeCode: "platform_component",
|
|
144
|
+
entitySubtypeCode: "platform_service",
|
|
145
|
+
role: "network_boundary",
|
|
146
|
+
attributes: ["name", "resource_group_name", "number_of_record_sets"],
|
|
147
|
+
},
|
|
148
|
+
// ---------------------------------------------------------------- identity
|
|
149
|
+
{
|
|
150
|
+
resourceType: "azurerm_user_assigned_identity",
|
|
151
|
+
entityTypeCode: "platform_component",
|
|
152
|
+
entitySubtypeCode: "platform_control_plane",
|
|
153
|
+
role: "identity",
|
|
154
|
+
attributes: [...COMMON, "client_id", "principal_id", "tenant_id"],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
resourceType: "azurerm_role_assignment",
|
|
158
|
+
entityTypeCode: "policy_control",
|
|
159
|
+
role: "identity",
|
|
160
|
+
attributes: ["name", "principal_id", "principal_type", "role_definition_name", "scope"],
|
|
161
|
+
},
|
|
162
|
+
// ---------------------------------------------------------------- registry
|
|
163
|
+
{
|
|
164
|
+
resourceType: "azurerm_container_registry",
|
|
165
|
+
entityTypeCode: "platform_component",
|
|
166
|
+
entitySubtypeCode: "platform_service",
|
|
167
|
+
role: "registry",
|
|
168
|
+
attributes: [...COMMON, "login_server", "sku", "admin_enabled", "public_network_access_enabled"],
|
|
169
|
+
},
|
|
170
|
+
// ----------------------------------------------------------------- edge/AI
|
|
171
|
+
{
|
|
172
|
+
resourceType: "azurerm_cdn_frontdoor_profile",
|
|
173
|
+
entityTypeCode: "platform_component",
|
|
174
|
+
entitySubtypeCode: "platform_service",
|
|
175
|
+
role: "edge",
|
|
176
|
+
attributes: [...COMMON, "sku_name", "response_timeout_seconds"],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
resourceType: "azurerm_cdn_frontdoor_endpoint",
|
|
180
|
+
entityTypeCode: "platform_component",
|
|
181
|
+
entitySubtypeCode: "platform_service",
|
|
182
|
+
role: "edge",
|
|
183
|
+
attributes: ["name", "host_name", "enabled", "cdn_frontdoor_profile_id"],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
resourceType: "azurerm_cdn_frontdoor_firewall_policy",
|
|
187
|
+
entityTypeCode: "policy_control",
|
|
188
|
+
role: "edge",
|
|
189
|
+
attributes: [...COMMON, "sku_name", "mode", "enabled"],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
resourceType: "azurerm_cognitive_account",
|
|
193
|
+
entityTypeCode: "platform_component",
|
|
194
|
+
entitySubtypeCode: "platform_service",
|
|
195
|
+
role: "compute_host",
|
|
196
|
+
attributes: [...COMMON, "kind", "sku_name", "endpoint", "custom_subdomain_name", "public_network_access_enabled", "local_auth_enabled"],
|
|
197
|
+
},
|
|
198
|
+
// ----------------------------------------------------------- observability
|
|
199
|
+
{
|
|
200
|
+
resourceType: "azurerm_log_analytics_workspace",
|
|
201
|
+
entityTypeCode: "platform_component",
|
|
202
|
+
entitySubtypeCode: "platform_service",
|
|
203
|
+
role: "observability",
|
|
204
|
+
attributes: [...COMMON, "sku", "retention_in_days", "workspace_id"],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
@@ -0,0 +1,338 @@
|
|
|
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
|
+
function collectTerraformFiles(dir, depth = 0, out = []) {
|
|
6
|
+
if (depth > SCAN_DEPTH)
|
|
7
|
+
return out;
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = readdirSync(dir);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (IGNORED_DIRS.has(entry))
|
|
17
|
+
continue;
|
|
18
|
+
const full = join(dir, entry);
|
|
19
|
+
let isDir = false;
|
|
20
|
+
try {
|
|
21
|
+
isDir = statSync(full).isDirectory();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (isDir)
|
|
27
|
+
collectTerraformFiles(full, depth + 1, out);
|
|
28
|
+
else if (entry.endsWith(".tf"))
|
|
29
|
+
out.push(full);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function detectCiSystem(dir) {
|
|
34
|
+
if (existsSync(join(dir, ".gitlab-ci.yml")))
|
|
35
|
+
return { ciSystem: "gitlab", ciFile: ".gitlab-ci.yml" };
|
|
36
|
+
if (existsSync(join(dir, ".github", "workflows")))
|
|
37
|
+
return { ciSystem: "github", ciFile: ".github/workflows" };
|
|
38
|
+
const seen = [];
|
|
39
|
+
const visit = (current, depth) => {
|
|
40
|
+
if (depth > 3 || seen.length > 0)
|
|
41
|
+
return;
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = readdirSync(current);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (IGNORED_DIRS.has(entry))
|
|
51
|
+
continue;
|
|
52
|
+
const full = join(current, entry);
|
|
53
|
+
let isDir = false;
|
|
54
|
+
try {
|
|
55
|
+
isDir = statSync(full).isDirectory();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (isDir) {
|
|
61
|
+
visit(full, depth + 1);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const relative = full.slice(dir.length + 1).split(sep).join("/");
|
|
65
|
+
if (/azure-pipelines.*\.ya?ml$/i.test(entry)) {
|
|
66
|
+
seen.push({ ciSystem: "azure-pipelines", ciFile: relative });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (entry === ".gitlab-ci.yml") {
|
|
70
|
+
seen.push({ ciSystem: "gitlab", ciFile: relative });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
visit(dir, 0);
|
|
76
|
+
return seen[0] ?? { ciSystem: null, ciFile: null };
|
|
77
|
+
}
|
|
78
|
+
/** Collects reusable module directories wherever they live, not only at the root. */
|
|
79
|
+
function collectModuleDirectories(dir, depth = 0, out = []) {
|
|
80
|
+
if (depth > 3)
|
|
81
|
+
return out;
|
|
82
|
+
let entries;
|
|
83
|
+
try {
|
|
84
|
+
entries = readdirSync(dir);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (IGNORED_DIRS.has(entry))
|
|
91
|
+
continue;
|
|
92
|
+
const full = join(dir, entry);
|
|
93
|
+
try {
|
|
94
|
+
if (!statSync(full).isDirectory())
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (entry === "modules") {
|
|
101
|
+
// The children of a modules directory are the reusable building blocks:
|
|
102
|
+
// network, database, keyvault, and so on — the vocabulary of the estate.
|
|
103
|
+
try {
|
|
104
|
+
for (const child of readdirSync(full)) {
|
|
105
|
+
if (statSync(join(full, child)).isDirectory())
|
|
106
|
+
out.push(child);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Unreadable modules directory is not fatal.
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
collectModuleDirectories(full, depth + 1, out);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
const ROOT_SCAN_DEPTH = 4;
|
|
119
|
+
const ENVIRONMENT_DIR = /^(dev|development|test|qa|uat|sit|stag|staging|preprod|pre-prod|prod|production|live|dr)$/i;
|
|
120
|
+
/**
|
|
121
|
+
* Infers an environment from a root module's path.
|
|
122
|
+
*
|
|
123
|
+
* A directory called `environments/prod` is a stronger statement about which
|
|
124
|
+
* environment is being ingested than anything inside the state, and it is
|
|
125
|
+
* available before Terraform is run at all.
|
|
126
|
+
*/
|
|
127
|
+
export function environmentFromPath(relativePath) {
|
|
128
|
+
const segments = relativePath.split(/[\\/]+/).filter(Boolean);
|
|
129
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
130
|
+
if (ENVIRONMENT_DIR.test(segments[i]))
|
|
131
|
+
return segments[i].toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Finds the root modules in a repository.
|
|
137
|
+
*
|
|
138
|
+
* Real estates are not one directory with one state. A repository commonly
|
|
139
|
+
* carries a root module per environment plus separate stacks for shared
|
|
140
|
+
* services, each with its own state, so assuming the current directory is
|
|
141
|
+
* *the* root module fails on the first serious layout it meets.
|
|
142
|
+
*
|
|
143
|
+
* Distinguishing a root from a shared module is the crux: both contain `.tf`
|
|
144
|
+
* files and both may declare `required_providers`. A root is what configures a
|
|
145
|
+
* backend or a provider, is initialised, carries tfvars, or lives under a
|
|
146
|
+
* conventional stacks directory — and never lives under `modules/`.
|
|
147
|
+
*/
|
|
148
|
+
export function discoverRootModules(repoDir) {
|
|
149
|
+
const found = [];
|
|
150
|
+
const visit = (dir, relative, depth) => {
|
|
151
|
+
if (depth > ROOT_SCAN_DEPTH)
|
|
152
|
+
return;
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = readdirSync(dir);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const tfFiles = entries.filter((entry) => entry.endsWith(".tf"));
|
|
161
|
+
const isUnderModules = /(^|[\\/])modules([\\/]|$)/i.test(relative);
|
|
162
|
+
if (tfFiles.length > 0 && !isUnderModules) {
|
|
163
|
+
const signals = [];
|
|
164
|
+
let hasBackend = false;
|
|
165
|
+
let hasLocalBackend = false;
|
|
166
|
+
let hasProvider = false;
|
|
167
|
+
for (const file of tfFiles.slice(0, 25)) {
|
|
168
|
+
let content = "";
|
|
169
|
+
try {
|
|
170
|
+
content = readFileSync(join(dir, file), "utf8");
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
|
|
176
|
+
if (backendMatch) {
|
|
177
|
+
hasBackend = true;
|
|
178
|
+
if (backendMatch[1] === "local")
|
|
179
|
+
hasLocalBackend = true;
|
|
180
|
+
}
|
|
181
|
+
// A provider *configuration*, not a required_providers declaration —
|
|
182
|
+
// shared modules declare requirements but must not configure providers.
|
|
183
|
+
if (/^\s*provider\s+"[a-z0-9_]+"\s*\{/m.test(content))
|
|
184
|
+
hasProvider = true;
|
|
185
|
+
}
|
|
186
|
+
const initialised = existsSync(join(dir, ".terraform"));
|
|
187
|
+
const hasTfvars = entries.some((entry) => entry.endsWith(".tfvars") || entry.endsWith(".tfvars.json"));
|
|
188
|
+
const environmentHint = environmentFromPath(relative);
|
|
189
|
+
const underStacksDir = /(^|[\\/])(environments|envs|stacks|live)([\\/]|$)/i.test(relative);
|
|
190
|
+
if (hasBackend)
|
|
191
|
+
signals.push(hasLocalBackend ? "local backend" : "remote backend");
|
|
192
|
+
if (hasProvider)
|
|
193
|
+
signals.push("provider configuration");
|
|
194
|
+
if (initialised)
|
|
195
|
+
signals.push("initialised");
|
|
196
|
+
if (hasTfvars)
|
|
197
|
+
signals.push("tfvars");
|
|
198
|
+
if (underStacksDir)
|
|
199
|
+
signals.push("stacks directory");
|
|
200
|
+
if (hasBackend || hasProvider || initialised || (underStacksDir && hasTfvars) || (underStacksDir && environmentHint)) {
|
|
201
|
+
found.push({ dir, relative: relative || ".", signals, environmentHint, initialised, usesLocalBackend: hasLocalBackend });
|
|
202
|
+
// A root module's subdirectories are its own modules, not further roots.
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (IGNORED_DIRS.has(entry))
|
|
208
|
+
continue;
|
|
209
|
+
const full = join(dir, entry);
|
|
210
|
+
try {
|
|
211
|
+
if (!statSync(full).isDirectory())
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
visit(full, relative ? `${relative}/${entry}` : entry, depth + 1);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
visit(repoDir, "", 0);
|
|
221
|
+
return found.sort((a, b) => a.relative.localeCompare(b.relative));
|
|
222
|
+
}
|
|
223
|
+
export function detectInfrastructureProject(dir) {
|
|
224
|
+
const files = collectTerraformFiles(dir);
|
|
225
|
+
const signals = [];
|
|
226
|
+
if (files.length > 0)
|
|
227
|
+
signals.push(`${files.length} Terraform file${files.length === 1 ? "" : "s"}`);
|
|
228
|
+
if (existsSync(join(dir, ".terraform.lock.hcl")))
|
|
229
|
+
signals.push("provider lock file");
|
|
230
|
+
if (existsSync(join(dir, ".terraform")))
|
|
231
|
+
signals.push("initialised working directory");
|
|
232
|
+
let hasRemoteBackend = false;
|
|
233
|
+
let hasProviderBlock = false;
|
|
234
|
+
// The cap guards against a pathological repo, not against a large one: now
|
|
235
|
+
// that the scan reaches the whole tree, a low cap would read only whichever
|
|
236
|
+
// subtree the walk happened to enter first and miss the backend entirely.
|
|
237
|
+
for (const file of files.slice(0, 400)) {
|
|
238
|
+
let content = "";
|
|
239
|
+
try {
|
|
240
|
+
content = readFileSync(file, "utf8");
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// `backend "local"` marks a root module but is emphatically not remote
|
|
246
|
+
// state — a bootstrap stack uses it precisely because it creates the remote
|
|
247
|
+
// backend everything else then uses.
|
|
248
|
+
const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
|
|
249
|
+
if (backendMatch && backendMatch[1] !== "local")
|
|
250
|
+
hasRemoteBackend = true;
|
|
251
|
+
if (/\bprovider\s+"[a-z0-9_]+"\s*\{/.test(content))
|
|
252
|
+
hasProviderBlock = true;
|
|
253
|
+
}
|
|
254
|
+
if (hasRemoteBackend)
|
|
255
|
+
signals.push("remote state backend");
|
|
256
|
+
if (hasProviderBlock)
|
|
257
|
+
signals.push("provider configuration");
|
|
258
|
+
const moduleDirectories = [...new Set(collectModuleDirectories(dir))].sort();
|
|
259
|
+
if (moduleDirectories.length > 0)
|
|
260
|
+
signals.push(`${moduleDirectories.length} reusable modules`);
|
|
261
|
+
// One stray .tf file inside an application repo is not an infrastructure
|
|
262
|
+
// repo. Requiring corroboration keeps the classification from hijacking
|
|
263
|
+
// onboarding for a project that merely ships a snippet of Terraform.
|
|
264
|
+
// Deep layouts keep every .tf file below the two-level scan above, so
|
|
265
|
+
// discovery is consulted as well: a repository whose roots live in
|
|
266
|
+
// infra/environments/<env> is unmistakably an infrastructure repository even
|
|
267
|
+
// though its top two levels contain no Terraform at all.
|
|
268
|
+
const rootModules = discoverRootModules(dir);
|
|
269
|
+
if (rootModules.length > 0) {
|
|
270
|
+
signals.push(`${rootModules.length} root module${rootModules.length === 1 ? "" : "s"}`);
|
|
271
|
+
}
|
|
272
|
+
const isInfrastructure = rootModules.length > 0 || (files.length > 0 && (files.length >= 3 || hasRemoteBackend || hasProviderBlock || moduleDirectories.length > 0));
|
|
273
|
+
return {
|
|
274
|
+
isInfrastructure,
|
|
275
|
+
rootModules,
|
|
276
|
+
signals,
|
|
277
|
+
terraformFileCount: files.length,
|
|
278
|
+
hasRemoteBackend,
|
|
279
|
+
hasProviderBlock,
|
|
280
|
+
moduleDirectories,
|
|
281
|
+
...detectCiSystem(dir),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The pipeline step to add, for whichever CI system the repo already uses.
|
|
286
|
+
* Onboarding hands this over rather than pointing at documentation: an
|
|
287
|
+
* ingestion command nobody wires up is a graph that lies within a week.
|
|
288
|
+
*/
|
|
289
|
+
export function ciSnippetFor(ciSystem, ciFile = null) {
|
|
290
|
+
if (ciSystem === "github") {
|
|
291
|
+
return {
|
|
292
|
+
file: ".github/workflows/terraform.yml (in the apply job)",
|
|
293
|
+
snippet: [
|
|
294
|
+
" - name: Sync architecture graph",
|
|
295
|
+
" run: |",
|
|
296
|
+
" terraform show -json > tfstate.json",
|
|
297
|
+
" npx nexarch@latest ingest-infra --state tfstate.json",
|
|
298
|
+
" env:",
|
|
299
|
+
" NEXARCH_TOKEN: ${{ secrets.NEXARCH_TOKEN }}",
|
|
300
|
+
].join("\n"),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (ciSystem === "azure-pipelines") {
|
|
304
|
+
return {
|
|
305
|
+
file: `${ciFile ?? "azure-pipelines.yml"} (after the apply step)`,
|
|
306
|
+
snippet: [
|
|
307
|
+
" - script: |",
|
|
308
|
+
" terraform show -json > tfstate.json",
|
|
309
|
+
" npx nexarch@latest ingest-infra --state tfstate.json",
|
|
310
|
+
" displayName: Sync architecture graph",
|
|
311
|
+
" env:",
|
|
312
|
+
" NEXARCH_TOKEN: $(NEXARCH_TOKEN)",
|
|
313
|
+
].join("\n"),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (ciSystem === "gitlab") {
|
|
317
|
+
return {
|
|
318
|
+
file: ciFile ?? ".gitlab-ci.yml",
|
|
319
|
+
snippet: [
|
|
320
|
+
"nexarch-ingest:",
|
|
321
|
+
" stage: .post",
|
|
322
|
+
" script:",
|
|
323
|
+
" - terraform show -json > tfstate.json",
|
|
324
|
+
" - npx nexarch@latest ingest-infra --state tfstate.json",
|
|
325
|
+
" variables:",
|
|
326
|
+
" NEXARCH_TOKEN: $NEXARCH_TOKEN",
|
|
327
|
+
].join("\n"),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// Nothing was detected. Handing over a GitLab job here — the previous
|
|
331
|
+
// behaviour — states as fact something never observed, and a team on another
|
|
332
|
+
// CI system has to work out why the advice does not fit. The two commands are
|
|
333
|
+
// the part that is actually known; the wrapper is not.
|
|
334
|
+
return {
|
|
335
|
+
file: "your Terraform pipeline, after the apply step",
|
|
336
|
+
snippet: ["terraform show -json > tfstate.json", "npx nexarch@latest ingest-infra --state tfstate.json"].join("\n"),
|
|
337
|
+
};
|
|
338
|
+
}
|