nexarch 0.12.6 → 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 +19 -0
- package/dist/index.js +198 -192
- 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
|
@@ -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
|
+
}
|