@celilo/cli 0.1.0
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/README.md +1566 -0
- package/bin/celilo +16 -0
- package/drizzle/0000_complex_puma.sql +179 -0
- package/drizzle/0001_dizzy_wolfpack.sql +2 -0
- package/drizzle/0002_web_routes.sql +16 -0
- package/drizzle/0003_backup_storage.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +1151 -0
- package/drizzle/meta/0001_snapshot.json +1167 -0
- package/drizzle/meta/0002_snapshot.json +1257 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +64 -0
- package/schemas/system_config.json +106 -0
- package/src/__integration__/container-services-cli.integration.test.ts +246 -0
- package/src/ansible/dependencies.test.ts +309 -0
- package/src/ansible/dependencies.ts +896 -0
- package/src/ansible/inventory.test.ts +463 -0
- package/src/ansible/inventory.ts +445 -0
- package/src/ansible/secrets.ts +222 -0
- package/src/ansible/validation.test.ts +92 -0
- package/src/ansible/validation.ts +272 -0
- package/src/api-clients/digitalocean.ts +94 -0
- package/src/api-clients/proxmox.ts +655 -0
- package/src/capabilities/logging-wrapper.test.ts +217 -0
- package/src/capabilities/lookup.test.ts +149 -0
- package/src/capabilities/lookup.ts +89 -0
- package/src/capabilities/public-web-helpers.test.ts +198 -0
- package/src/capabilities/public-web-publish.test.ts +458 -0
- package/src/capabilities/registration.test.ts +395 -0
- package/src/capabilities/registration.ts +200 -0
- package/src/capabilities/route-validation.test.ts +121 -0
- package/src/capabilities/route-validation.ts +96 -0
- package/src/capabilities/secret-ref.test.ts +313 -0
- package/src/capabilities/secret-validation.ts +157 -0
- package/src/capabilities/secrets.test.ts +750 -0
- package/src/capabilities/secrets.ts +244 -0
- package/src/capabilities/validation.test.ts +613 -0
- package/src/capabilities/validation.ts +160 -0
- package/src/capabilities/well-known.test.ts +238 -0
- package/src/capabilities/well-known.ts +222 -0
- package/src/cli/cli.test.ts +654 -0
- package/src/cli/command-registry.ts +742 -0
- package/src/cli/command-tree-parser.test.ts +180 -0
- package/src/cli/command-tree-parser.ts +193 -0
- package/src/cli/commands/backup-create.ts +137 -0
- package/src/cli/commands/backup-delete.ts +74 -0
- package/src/cli/commands/backup-import.ts +97 -0
- package/src/cli/commands/backup-list.ts +132 -0
- package/src/cli/commands/backup-name.ts +73 -0
- package/src/cli/commands/backup-prune.ts +98 -0
- package/src/cli/commands/backup-restore.ts +122 -0
- package/src/cli/commands/capability-info.ts +121 -0
- package/src/cli/commands/capability-list.ts +47 -0
- package/src/cli/commands/completion.ts +87 -0
- package/src/cli/commands/hook-run.ts +176 -0
- package/src/cli/commands/ipam.ts +607 -0
- package/src/cli/commands/machine-add.ts +235 -0
- package/src/cli/commands/machine-earmark.ts +82 -0
- package/src/cli/commands/machine-list.ts +77 -0
- package/src/cli/commands/machine-remove.ts +90 -0
- package/src/cli/commands/machine-status.ts +131 -0
- package/src/cli/commands/module-audit.ts +51 -0
- package/src/cli/commands/module-build.ts +60 -0
- package/src/cli/commands/module-config.ts +170 -0
- package/src/cli/commands/module-deploy.ts +71 -0
- package/src/cli/commands/module-generate.ts +236 -0
- package/src/cli/commands/module-health.ts +108 -0
- package/src/cli/commands/module-import.ts +80 -0
- package/src/cli/commands/module-list.ts +43 -0
- package/src/cli/commands/module-logs.ts +73 -0
- package/src/cli/commands/module-remove.ts +162 -0
- package/src/cli/commands/module-show.ts +208 -0
- package/src/cli/commands/module-status.ts +131 -0
- package/src/cli/commands/module-types.ts +189 -0
- package/src/cli/commands/module-upgrade.ts +192 -0
- package/src/cli/commands/package.ts +68 -0
- package/src/cli/commands/secret-list.ts +99 -0
- package/src/cli/commands/secret-set.ts +134 -0
- package/src/cli/commands/service-add-digitalocean.ts +133 -0
- package/src/cli/commands/service-add-proxmox.ts +342 -0
- package/src/cli/commands/service-config-get.ts +83 -0
- package/src/cli/commands/service-config-set.ts +145 -0
- package/src/cli/commands/service-list.ts +74 -0
- package/src/cli/commands/service-reconfigure.ts +230 -0
- package/src/cli/commands/service-remove.ts +103 -0
- package/src/cli/commands/service-verify.ts +240 -0
- package/src/cli/commands/status.ts +216 -0
- package/src/cli/commands/storage-add-local.ts +106 -0
- package/src/cli/commands/storage-add-s3.ts +114 -0
- package/src/cli/commands/storage-list.ts +72 -0
- package/src/cli/commands/storage-remove.ts +54 -0
- package/src/cli/commands/storage-set-default.ts +44 -0
- package/src/cli/commands/storage-verify.ts +54 -0
- package/src/cli/commands/system-config.ts +168 -0
- package/src/cli/commands/system-init.ts +314 -0
- package/src/cli/commands/system-secret-get.ts +98 -0
- package/src/cli/commands/system-secret-set.ts +76 -0
- package/src/cli/commands/system-vault-password.ts +34 -0
- package/src/cli/completion.test.ts +37 -0
- package/src/cli/completion.ts +482 -0
- package/src/cli/fuel-gauge.test.ts +208 -0
- package/src/cli/fuel-gauge.ts +405 -0
- package/src/cli/generate-zsh-completion.test.ts +95 -0
- package/src/cli/generate-zsh-completion.ts +497 -0
- package/src/cli/index.ts +1583 -0
- package/src/cli/interactive-config.test.ts +201 -0
- package/src/cli/interactive-config.ts +62 -0
- package/src/cli/parser.test.ts +227 -0
- package/src/cli/parser.ts +244 -0
- package/src/cli/prompts.test.ts +33 -0
- package/src/cli/prompts.ts +121 -0
- package/src/cli/types.ts +38 -0
- package/src/cli/validators.test.ts +235 -0
- package/src/cli/validators.ts +188 -0
- package/src/config/env.ts +41 -0
- package/src/config/paths.test.ts +172 -0
- package/src/config/paths.ts +108 -0
- package/src/db/client.ts +190 -0
- package/src/db/migrate.ts +30 -0
- package/src/db/schema.test.ts +221 -0
- package/src/db/schema.ts +434 -0
- package/src/hooks/capability-loader-firewall.test.ts +246 -0
- package/src/hooks/capability-loader.test.ts +100 -0
- package/src/hooks/capability-loader.ts +520 -0
- package/src/hooks/define-hook.test.ts +488 -0
- package/src/hooks/executor.test.ts +462 -0
- package/src/hooks/executor.ts +469 -0
- package/src/hooks/logger.test.ts +54 -0
- package/src/hooks/logger.ts +95 -0
- package/src/hooks/test-fixtures/failing-hook.ts +13 -0
- package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
- package/src/hooks/test-fixtures/success-hook.ts +20 -0
- package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
- package/src/hooks/test-fixtures/void-hook.ts +13 -0
- package/src/hooks/types.ts +89 -0
- package/src/infrastructure/property-extractor.test.ts +194 -0
- package/src/infrastructure/property-extractor.ts +151 -0
- package/src/ipam/allocator.test.ts +442 -0
- package/src/ipam/allocator.ts +369 -0
- package/src/ipam/auto-allocator.test.ts +247 -0
- package/src/ipam/auto-allocator.ts +270 -0
- package/src/ipam/subnet-parser.test.ts +107 -0
- package/src/ipam/subnet-parser.ts +136 -0
- package/src/manifest/contracts/index.ts +61 -0
- package/src/manifest/contracts/v1.ts +118 -0
- package/src/manifest/json-schema-roundtrip.test.ts +99 -0
- package/src/manifest/schema.ts +367 -0
- package/src/manifest/template-validator.test.ts +231 -0
- package/src/manifest/template-validator.ts +322 -0
- package/src/manifest/validate.test.ts +1180 -0
- package/src/manifest/validate.ts +415 -0
- package/src/module/import.test.ts +355 -0
- package/src/module/import.ts +676 -0
- package/src/module/packaging/audit.ts +169 -0
- package/src/module/packaging/build.ts +228 -0
- package/src/module/packaging/checksum.ts +41 -0
- package/src/module/packaging/extract.ts +234 -0
- package/src/module/packaging/signature.ts +47 -0
- package/src/secrets/encryption.test.ts +284 -0
- package/src/secrets/encryption.ts +162 -0
- package/src/secrets/generators.test.ts +112 -0
- package/src/secrets/generators.ts +127 -0
- package/src/secrets/master-key.test.ts +159 -0
- package/src/secrets/master-key.ts +114 -0
- package/src/secrets/storage.test.ts +115 -0
- package/src/secrets/storage.ts +106 -0
- package/src/secrets/vault.test.ts +35 -0
- package/src/secrets/vault.ts +42 -0
- package/src/services/backup-create.ts +532 -0
- package/src/services/backup-metadata.ts +198 -0
- package/src/services/backup-restore.ts +229 -0
- package/src/services/backup-retention.ts +84 -0
- package/src/services/backup-storage.ts +281 -0
- package/src/services/build-stream.test.ts +122 -0
- package/src/services/build-stream.ts +201 -0
- package/src/services/config-interview.ts +694 -0
- package/src/services/container-service.test.ts +298 -0
- package/src/services/container-service.ts +401 -0
- package/src/services/cross-module-data-manager.test.ts +405 -0
- package/src/services/cross-module-data-manager.ts +412 -0
- package/src/services/deploy-ansible.ts +88 -0
- package/src/services/deploy-planner.ts +153 -0
- package/src/services/deploy-preflight.ts +274 -0
- package/src/services/deploy-ssh.ts +131 -0
- package/src/services/deploy-terraform.test.ts +55 -0
- package/src/services/deploy-terraform.ts +445 -0
- package/src/services/deploy-validation.ts +311 -0
- package/src/services/dns-auto-register.ts +211 -0
- package/src/services/health-runner.ts +184 -0
- package/src/services/infrastructure-selector.test.ts +485 -0
- package/src/services/infrastructure-selector.ts +245 -0
- package/src/services/infrastructure-variable-resolver.test.ts +751 -0
- package/src/services/infrastructure-variable-resolver.ts +234 -0
- package/src/services/machine-detector.ts +328 -0
- package/src/services/machine-pool.test.ts +405 -0
- package/src/services/machine-pool.ts +316 -0
- package/src/services/manifest-validation.ts +120 -0
- package/src/services/module-build.test.ts +290 -0
- package/src/services/module-build.ts +431 -0
- package/src/services/module-config.test.ts +237 -0
- package/src/services/module-config.ts +298 -0
- package/src/services/module-deploy.ts +862 -0
- package/src/services/module-types-drift.test.ts +73 -0
- package/src/services/module-types-generator.test.ts +288 -0
- package/src/services/module-types-generator.ts +189 -0
- package/src/services/proxmox-state-recovery.ts +140 -0
- package/src/services/schema-validation.ts +155 -0
- package/src/services/secret-schema-loader.test.ts +311 -0
- package/src/services/secret-schema-loader.ts +239 -0
- package/src/services/ssh-key-manager.test.ts +283 -0
- package/src/services/ssh-key-manager.ts +193 -0
- package/src/services/storage-providers/local.ts +105 -0
- package/src/services/storage-providers/s3.ts +182 -0
- package/src/services/storage-providers/types.ts +24 -0
- package/src/services/system-config-schema-types.ts +25 -0
- package/src/services/system-config-validator.test.ts +160 -0
- package/src/services/system-config-validator.ts +74 -0
- package/src/services/system-init.test.ts +153 -0
- package/src/services/system-init.ts +253 -0
- package/src/services/terraform-safety.ts +174 -0
- package/src/services/zone-detector.test.ts +110 -0
- package/src/services/zone-detector.ts +102 -0
- package/src/services/zone-policy.test.ts +97 -0
- package/src/services/zone-policy.ts +126 -0
- package/src/templates/generator.test.ts +645 -0
- package/src/templates/generator.ts +1119 -0
- package/src/templates/types.ts +62 -0
- package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
- package/src/test-utils/cli-context-interactive.test.ts +152 -0
- package/src/test-utils/cli-context-server.test.ts +66 -0
- package/src/test-utils/cli-context.test.ts +273 -0
- package/src/test-utils/cli-context.ts +677 -0
- package/src/test-utils/cli-result.test.ts +282 -0
- package/src/test-utils/cli-result.ts +241 -0
- package/src/test-utils/cli.ts +55 -0
- package/src/test-utils/completion-harness.test.ts +126 -0
- package/src/test-utils/completion-harness.ts +82 -0
- package/src/test-utils/database.test.ts +182 -0
- package/src/test-utils/database.ts +126 -0
- package/src/test-utils/filesystem.test.ts +208 -0
- package/src/test-utils/filesystem.ts +142 -0
- package/src/test-utils/fixtures.test.ts +123 -0
- package/src/test-utils/fixtures.ts +160 -0
- package/src/test-utils/golden-diff.ts +197 -0
- package/src/test-utils/index.ts +77 -0
- package/src/test-utils/integration.ts +81 -0
- package/src/test-utils/module-fixtures.ts +468 -0
- package/src/test-utils/modules.test.ts +144 -0
- package/src/test-utils/modules.ts +183 -0
- package/src/test-utils/setup-test-db.ts +90 -0
- package/src/test-utils/value-extractor.test.ts +231 -0
- package/src/test-utils/value-extractor.ts +228 -0
- package/src/types/infrastructure.ts +157 -0
- package/src/utils/shell.test.ts +365 -0
- package/src/utils/shell.ts +159 -0
- package/src/validation/schemas.ts +166 -0
- package/src/variables/ansible-resolver.test.ts +142 -0
- package/src/variables/ansible-resolver.ts +69 -0
- package/src/variables/capability-self-ref.test.ts +220 -0
- package/src/variables/context.test.ts +1265 -0
- package/src/variables/context.ts +624 -0
- package/src/variables/declarative-derivation.test.ts +743 -0
- package/src/variables/declarative-derivation.ts +200 -0
- package/src/variables/parser.test.ts +231 -0
- package/src/variables/parser.ts +76 -0
- package/src/variables/resolver.test.ts +458 -0
- package/src/variables/resolver.ts +282 -0
- package/src/variables/types.ts +59 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { DbClient } from '@/db/client';
|
|
2
|
+
import { containerServices, machines, moduleConfigs, moduleInfrastructure } from '@/db/schema';
|
|
3
|
+
import {
|
|
4
|
+
type ProxmoxProviderConfig,
|
|
5
|
+
extractMachineProperties,
|
|
6
|
+
extractProxmoxProperties,
|
|
7
|
+
extractTerraformProperties,
|
|
8
|
+
} from '@/infrastructure/property-extractor';
|
|
9
|
+
import type { ModuleManifest } from '@/manifest/schema';
|
|
10
|
+
import type { Machine } from '@/types/infrastructure';
|
|
11
|
+
import { and, eq } from 'drizzle-orm';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Result of resolving infrastructure variables
|
|
15
|
+
*/
|
|
16
|
+
export interface InfrastructureVariableResolution {
|
|
17
|
+
/** Variables that were resolved (name → value) */
|
|
18
|
+
resolved: Record<string, string>;
|
|
19
|
+
/** Variables that were skipped (user override or optional missing) */
|
|
20
|
+
skipped: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve infrastructure variables for a module and store in moduleConfigs.
|
|
25
|
+
* Called during deploy, after Terraform runs (if applicable).
|
|
26
|
+
*
|
|
27
|
+
* Resolution priority:
|
|
28
|
+
* 1. User-configured value (moduleConfigs) - ALWAYS wins
|
|
29
|
+
* 2. Infrastructure-derived value (from machine, IPAM, or Terraform output)
|
|
30
|
+
* 3. Required check: throw if required variable has no value
|
|
31
|
+
* 4. Optional: skip if no value available
|
|
32
|
+
*
|
|
33
|
+
* @param moduleId - Module identifier
|
|
34
|
+
* @param manifest - Module manifest
|
|
35
|
+
* @param terraformOutputs - Optional Terraform outputs (for Digital Ocean)
|
|
36
|
+
* @param db - Database connection
|
|
37
|
+
* @returns Resolution result with resolved and skipped variables
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveInfrastructureVariables(
|
|
40
|
+
moduleId: string,
|
|
41
|
+
manifest: ModuleManifest,
|
|
42
|
+
terraformOutputs: Record<string, unknown> | null,
|
|
43
|
+
db: DbClient,
|
|
44
|
+
): Promise<InfrastructureVariableResolution> {
|
|
45
|
+
// Find infrastructure variables in manifest
|
|
46
|
+
const infraVars = manifest.variables?.owns?.filter((v) => v.source === 'infrastructure') ?? [];
|
|
47
|
+
|
|
48
|
+
if (infraVars.length === 0) {
|
|
49
|
+
return { resolved: {}, skipped: [] }; // No infrastructure variables to resolve
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Query infrastructure selection
|
|
53
|
+
const infraSelection = await db
|
|
54
|
+
.select()
|
|
55
|
+
.from(moduleInfrastructure)
|
|
56
|
+
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
57
|
+
.get();
|
|
58
|
+
|
|
59
|
+
if (!infraSelection) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`No infrastructure selected for module ${moduleId}. ` +
|
|
62
|
+
`Run 'celilo module generate ${moduleId}' first.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Extract properties based on infrastructure type
|
|
67
|
+
let properties: Record<string, string>;
|
|
68
|
+
|
|
69
|
+
if (infraSelection.infrastructureType === 'machine') {
|
|
70
|
+
if (!infraSelection.machineId) {
|
|
71
|
+
throw new Error('Machine infrastructure selected but machineId is null');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Load machine from database
|
|
75
|
+
const machineRow = await db
|
|
76
|
+
.select()
|
|
77
|
+
.from(machines)
|
|
78
|
+
.where(eq(machines.id, infraSelection.machineId))
|
|
79
|
+
.get();
|
|
80
|
+
|
|
81
|
+
if (!machineRow) {
|
|
82
|
+
throw new Error(`Machine not found: ${infraSelection.machineId}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Cast to Machine type (interfaces zone field is stored as string in DB)
|
|
86
|
+
const machine: Machine = {
|
|
87
|
+
...machineRow,
|
|
88
|
+
zone: machineRow.zone as Machine['zone'],
|
|
89
|
+
hardware: machineRow.hardware || { cpu_cores: 0, memory_mb: 0, disk_gb: 0 },
|
|
90
|
+
role: (machineRow.role as Machine['role']) || 'host',
|
|
91
|
+
interfaces: (machineRow.interfaces || []) as Machine['interfaces'],
|
|
92
|
+
assignedModuleIds: Array.isArray(machineRow.assignedModuleIds)
|
|
93
|
+
? machineRow.assignedModuleIds
|
|
94
|
+
: [],
|
|
95
|
+
earmarkedModule: machineRow.earmarkedModule ?? undefined,
|
|
96
|
+
createdAt: new Date(machineRow.createdAt),
|
|
97
|
+
updatedAt: new Date(machineRow.updatedAt),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
properties = extractMachineProperties(machine);
|
|
101
|
+
} else if (infraSelection.infrastructureType === 'container_service') {
|
|
102
|
+
if (!infraSelection.serviceId) {
|
|
103
|
+
throw new Error('Container service infrastructure selected but serviceId is null');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Load container service to get provider config
|
|
107
|
+
const service = await db
|
|
108
|
+
.select()
|
|
109
|
+
.from(containerServices)
|
|
110
|
+
.where(eq(containerServices.id, infraSelection.serviceId))
|
|
111
|
+
.get();
|
|
112
|
+
|
|
113
|
+
if (!service) {
|
|
114
|
+
throw new Error(`Container service not found: ${infraSelection.serviceId}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Container service - check provider type
|
|
118
|
+
if (service.providerName === 'digitalocean') {
|
|
119
|
+
// Digital Ocean - use Terraform outputs
|
|
120
|
+
if (!terraformOutputs) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'Terraform outputs not found for Digital Ocean service. ' +
|
|
123
|
+
'Deploy may have failed or outputs not yet available.',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const hostname = (await getModuleConfig(moduleId, 'hostname', db)) || moduleId;
|
|
127
|
+
properties = extractTerraformProperties(terraformOutputs, hostname);
|
|
128
|
+
} else if (service.providerName === 'proxmox') {
|
|
129
|
+
// Proxmox - use IPAM allocation + service provider config
|
|
130
|
+
const vmid = await getModuleConfig(moduleId, 'vmid', db);
|
|
131
|
+
const containerIp = await getModuleConfig(moduleId, 'container_ip', db);
|
|
132
|
+
const hostname = (await getModuleConfig(moduleId, 'hostname', db)) || moduleId;
|
|
133
|
+
|
|
134
|
+
if (!vmid || !containerIp) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`IPAM allocation not found for module ${moduleId}. ` +
|
|
137
|
+
`Run 'celilo module generate ${moduleId}' first.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Extract Proxmox provider config from service
|
|
142
|
+
const providerConfig = service.providerConfig as unknown as ProxmoxProviderConfig;
|
|
143
|
+
|
|
144
|
+
properties = extractProxmoxProperties(
|
|
145
|
+
Number.parseInt(vmid, 10),
|
|
146
|
+
containerIp,
|
|
147
|
+
hostname,
|
|
148
|
+
providerConfig,
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Unsupported container service provider: ${service.providerName}. Supported providers: proxmox, digitalocean`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
throw new Error(`Unknown infrastructure type: ${infraSelection.infrastructureType}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve each infrastructure variable
|
|
160
|
+
const resolved: Record<string, string> = {};
|
|
161
|
+
const skipped: string[] = [];
|
|
162
|
+
|
|
163
|
+
// When Terraform just ran, outputs are authoritative — always overwrite stale config.
|
|
164
|
+
// When using machines (no Terraform), respect user overrides in module_configs.
|
|
165
|
+
const terraformJustRan = terraformOutputs !== null;
|
|
166
|
+
|
|
167
|
+
for (const variable of infraVars) {
|
|
168
|
+
// Check if user manually set this variable
|
|
169
|
+
const userValue = await getModuleConfig(moduleId, variable.name, db);
|
|
170
|
+
|
|
171
|
+
// For machine-based infrastructure, user overrides win (stable infrastructure)
|
|
172
|
+
// For Terraform-based infrastructure, fresh outputs win (may have been recreated)
|
|
173
|
+
if (userValue !== null && !terraformJustRan) {
|
|
174
|
+
resolved[variable.name] = userValue;
|
|
175
|
+
skipped.push(variable.name); // Track as skipped (user override)
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Variable name IS the property name (no derive_from)
|
|
180
|
+
const value = properties[variable.name];
|
|
181
|
+
|
|
182
|
+
if (!value) {
|
|
183
|
+
if (variable.required) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Required infrastructure property '${variable.name}' not available ` +
|
|
186
|
+
`for module '${moduleId}'. Available properties: ${Object.keys(properties).join(', ')}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
skipped.push(variable.name); // Track as skipped (optional, no value)
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Store in moduleConfigs
|
|
194
|
+
await db
|
|
195
|
+
.insert(moduleConfigs)
|
|
196
|
+
.values({
|
|
197
|
+
moduleId,
|
|
198
|
+
key: variable.name,
|
|
199
|
+
value,
|
|
200
|
+
valueJson: null,
|
|
201
|
+
})
|
|
202
|
+
.onConflictDoUpdate({
|
|
203
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
204
|
+
set: { value, updatedAt: new Date() },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
resolved[variable.name] = value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { resolved, skipped };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get module configuration value from database.
|
|
215
|
+
* Returns null if not found.
|
|
216
|
+
*
|
|
217
|
+
* @param moduleId - Module identifier
|
|
218
|
+
* @param key - Configuration key
|
|
219
|
+
* @param db - Database connection
|
|
220
|
+
* @returns Configuration value or null
|
|
221
|
+
*/
|
|
222
|
+
async function getModuleConfig(
|
|
223
|
+
moduleId: string,
|
|
224
|
+
key: string,
|
|
225
|
+
db: DbClient,
|
|
226
|
+
): Promise<string | null> {
|
|
227
|
+
const result = await db
|
|
228
|
+
.select()
|
|
229
|
+
.from(moduleConfigs)
|
|
230
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
|
|
231
|
+
.get();
|
|
232
|
+
|
|
233
|
+
return result?.value ?? null;
|
|
234
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine Detector
|
|
3
|
+
* Auto-detects machine information via SSH
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import type { NetworkZone } from '../db/schema';
|
|
8
|
+
import type { DetectedMachineInfo, MachineRole, NetworkInterface } from '../types/infrastructure';
|
|
9
|
+
import { detectZoneFromIp } from './zone-detector';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detection error
|
|
13
|
+
*/
|
|
14
|
+
export class DetectionError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'DetectionError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Execute SSH command and return output
|
|
23
|
+
*/
|
|
24
|
+
function sshExec(ip: string, user: string, keyPath: string, command: string): string {
|
|
25
|
+
try {
|
|
26
|
+
const output = execSync(
|
|
27
|
+
`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -i "${keyPath}" ${user}@${ip} "${command}"`,
|
|
28
|
+
{
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
timeout: 10000, // 10 second timeout
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
return output.trim();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
37
|
+
throw new DetectionError(`SSH command failed: ${message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect hostname
|
|
43
|
+
*/
|
|
44
|
+
function detectHostname(ip: string, user: string, keyPath: string): string {
|
|
45
|
+
const output = sshExec(ip, user, keyPath, 'hostname');
|
|
46
|
+
if (!output) {
|
|
47
|
+
throw new DetectionError('Failed to detect hostname: empty output');
|
|
48
|
+
}
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Detect CPU cores
|
|
54
|
+
*/
|
|
55
|
+
function detectCpuCores(ip: string, user: string, keyPath: string): number {
|
|
56
|
+
const output = sshExec(ip, user, keyPath, 'nproc || grep -c ^processor /proc/cpuinfo');
|
|
57
|
+
const cores = Number.parseInt(output, 10);
|
|
58
|
+
if (Number.isNaN(cores) || cores <= 0) {
|
|
59
|
+
throw new DetectionError(`Invalid CPU cores detected: ${output}`);
|
|
60
|
+
}
|
|
61
|
+
return cores;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect memory in MB
|
|
66
|
+
*/
|
|
67
|
+
function detectMemory(ip: string, user: string, keyPath: string): number {
|
|
68
|
+
// Get memory line, parse in JavaScript
|
|
69
|
+
const output = sshExec(ip, user, keyPath, 'grep MemTotal /proc/meminfo');
|
|
70
|
+
|
|
71
|
+
// Parse "MemTotal: 1048576 kB" -> extract number
|
|
72
|
+
const match = output.match(/MemTotal:\s+(\d+)\s+kB/);
|
|
73
|
+
if (!match) {
|
|
74
|
+
throw new DetectionError(`Invalid memory format: ${output}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const memoryKb = Number.parseInt(match[1], 10);
|
|
78
|
+
if (Number.isNaN(memoryKb) || memoryKb <= 0) {
|
|
79
|
+
throw new DetectionError(`Invalid memory value: ${match[1]}`);
|
|
80
|
+
}
|
|
81
|
+
return Math.floor(memoryKb / 1024);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detect disk space in GB
|
|
86
|
+
*/
|
|
87
|
+
function detectDisk(ip: string, user: string, keyPath: string): number {
|
|
88
|
+
// Get root filesystem size, parse in JavaScript
|
|
89
|
+
const output = sshExec(ip, user, keyPath, 'df -BG / | tail -1');
|
|
90
|
+
|
|
91
|
+
// Parse "Filesystem 1G-blocks Used Available Use% Mounted" -> extract second field
|
|
92
|
+
// Example: "/dev/sda1 20G 5G 14G 27% /"
|
|
93
|
+
const parts = output.split(/\s+/).filter((part) => part.length > 0);
|
|
94
|
+
if (parts.length < 2) {
|
|
95
|
+
throw new DetectionError(`Invalid df output format: ${output}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Second field should be size in format like "20G"
|
|
99
|
+
const sizeStr = parts[1];
|
|
100
|
+
const match = sizeStr.match(/^(\d+)G$/);
|
|
101
|
+
if (!match) {
|
|
102
|
+
throw new DetectionError(`Invalid disk size format: ${sizeStr}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const diskGb = Number.parseInt(match[1], 10);
|
|
106
|
+
if (Number.isNaN(diskGb) || diskGb <= 0) {
|
|
107
|
+
throw new DetectionError(`Invalid disk size value: ${match[1]}`);
|
|
108
|
+
}
|
|
109
|
+
return diskGb;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect CPU architecture (arm64, x64, etc.)
|
|
114
|
+
*/
|
|
115
|
+
function detectArch(ip: string, user: string, keyPath: string): string {
|
|
116
|
+
try {
|
|
117
|
+
const output = sshExec(ip, user, keyPath, 'dpkg --print-architecture 2>/dev/null || uname -m');
|
|
118
|
+
const raw = output.trim();
|
|
119
|
+
// Normalize: aarch64 → arm64, x86_64 → amd64
|
|
120
|
+
if (raw === 'aarch64' || raw === 'arm64') return 'arm64';
|
|
121
|
+
if (raw === 'x86_64' || raw === 'amd64') return 'amd64';
|
|
122
|
+
return raw;
|
|
123
|
+
} catch {
|
|
124
|
+
return 'unknown';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Detect OS information
|
|
130
|
+
*/
|
|
131
|
+
function detectOsInfo(ip: string, user: string, keyPath: string): string {
|
|
132
|
+
try {
|
|
133
|
+
// Try /etc/os-release first (most modern systems)
|
|
134
|
+
const output = sshExec(
|
|
135
|
+
ip,
|
|
136
|
+
user,
|
|
137
|
+
keyPath,
|
|
138
|
+
"cat /etc/os-release | grep PRETTY_NAME | cut -d'\"' -f2",
|
|
139
|
+
);
|
|
140
|
+
if (output) {
|
|
141
|
+
return output;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Fall back to uname if /etc/os-release not available
|
|
145
|
+
try {
|
|
146
|
+
const output = sshExec(ip, user, keyPath, 'uname -s -r');
|
|
147
|
+
return output || 'Unknown Linux';
|
|
148
|
+
} catch {
|
|
149
|
+
return 'Unknown Linux';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return 'Unknown Linux';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse `ip -j addr show` JSON output into NetworkInterface list
|
|
157
|
+
*/
|
|
158
|
+
function parseIpJsonOutput(output: string): Array<{ name: string; ipAddress: string }> {
|
|
159
|
+
const interfaces: Array<{ name: string; ipAddress: string }> = [];
|
|
160
|
+
const parsed = JSON.parse(output);
|
|
161
|
+
|
|
162
|
+
for (const iface of parsed) {
|
|
163
|
+
if (iface.ifname === 'lo') continue;
|
|
164
|
+
|
|
165
|
+
const addrInfo = iface.addr_info;
|
|
166
|
+
if (!Array.isArray(addrInfo)) continue;
|
|
167
|
+
|
|
168
|
+
for (const addr of addrInfo) {
|
|
169
|
+
if (addr.family === 'inet' && addr.local) {
|
|
170
|
+
interfaces.push({ name: iface.ifname, ipAddress: addr.local });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return interfaces;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Parse text `ip addr show` output as fallback
|
|
180
|
+
*/
|
|
181
|
+
function parseIpTextOutput(output: string): Array<{ name: string; ipAddress: string }> {
|
|
182
|
+
const interfaces: Array<{ name: string; ipAddress: string }> = [];
|
|
183
|
+
let currentIface = '';
|
|
184
|
+
|
|
185
|
+
for (const line of output.split('\n')) {
|
|
186
|
+
// Interface line: "2: eth0: <BROADCAST..."
|
|
187
|
+
const ifaceMatch = line.match(/^\d+:\s+(\S+?):/);
|
|
188
|
+
if (ifaceMatch) {
|
|
189
|
+
currentIface = ifaceMatch[1];
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// IPv4 address line: " inet 192.168.0.254/24 ..."
|
|
194
|
+
const inetMatch = line.match(/^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
195
|
+
if (inetMatch && currentIface && currentIface !== 'lo') {
|
|
196
|
+
interfaces.push({ name: currentIface, ipAddress: inetMatch[1] });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return interfaces;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Detect all network interfaces on a machine via SSH
|
|
205
|
+
* Classifies each interface's zone by matching IP against configured subnets
|
|
206
|
+
*/
|
|
207
|
+
export async function detectNetworkInterfaces(
|
|
208
|
+
ip: string,
|
|
209
|
+
sshUser: string,
|
|
210
|
+
sshKeyPath: string,
|
|
211
|
+
): Promise<{ interfaces: NetworkInterface[]; role: MachineRole }> {
|
|
212
|
+
let rawInterfaces: Array<{ name: string; ipAddress: string }>;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const jsonOutput = sshExec(ip, sshUser, sshKeyPath, 'ip -j addr show');
|
|
216
|
+
rawInterfaces = parseIpJsonOutput(jsonOutput);
|
|
217
|
+
} catch {
|
|
218
|
+
try {
|
|
219
|
+
const textOutput = sshExec(ip, sshUser, sshKeyPath, 'ip addr show');
|
|
220
|
+
rawInterfaces = parseIpTextOutput(textOutput);
|
|
221
|
+
} catch {
|
|
222
|
+
// Can't detect interfaces - return single interface from known IP
|
|
223
|
+
const zone = await detectZoneFromIp(ip);
|
|
224
|
+
return {
|
|
225
|
+
interfaces: [{ name: 'unknown', ipAddress: ip, zone }],
|
|
226
|
+
role: 'host',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (rawInterfaces.length === 0) {
|
|
232
|
+
const zone = await detectZoneFromIp(ip);
|
|
233
|
+
return {
|
|
234
|
+
interfaces: [{ name: 'unknown', ipAddress: ip, zone }],
|
|
235
|
+
role: 'host',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Filter out virtual/container interfaces — these are not real network
|
|
240
|
+
// interfaces and should not affect router classification.
|
|
241
|
+
// docker0, veth*, br-* are Docker artifacts; virbr* is libvirt.
|
|
242
|
+
const physicalInterfaces = rawInterfaces.filter(
|
|
243
|
+
(iface) =>
|
|
244
|
+
!iface.name.startsWith('docker') &&
|
|
245
|
+
!iface.name.startsWith('veth') &&
|
|
246
|
+
!iface.name.startsWith('br-') &&
|
|
247
|
+
!iface.name.startsWith('virbr'),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Match each interface IP to a zone
|
|
251
|
+
const interfaces: NetworkInterface[] = [];
|
|
252
|
+
for (const iface of physicalInterfaces) {
|
|
253
|
+
const zone: NetworkZone | 'unknown' = await detectZoneFromIp(iface.ipAddress);
|
|
254
|
+
interfaces.push({ name: iface.name, ipAddress: iface.ipAddress, zone });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Classify: router if interfaces span multiple distinct zones
|
|
258
|
+
const uniqueZones = new Set(interfaces.map((i) => i.zone).filter((z) => z !== 'unknown'));
|
|
259
|
+
const role: MachineRole = uniqueZones.size > 1 ? 'router' : 'host';
|
|
260
|
+
|
|
261
|
+
return { interfaces, role };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Detect machine information via SSH
|
|
266
|
+
*
|
|
267
|
+
* @param ip - Machine IP address
|
|
268
|
+
* @param sshUser - SSH username
|
|
269
|
+
* @param sshKeyPath - Path to SSH private key file
|
|
270
|
+
* @returns Detected machine information
|
|
271
|
+
* @throws DetectionError if detection fails
|
|
272
|
+
*/
|
|
273
|
+
export async function detectMachineInfo(
|
|
274
|
+
ip: string,
|
|
275
|
+
sshUser: string,
|
|
276
|
+
sshKeyPath: string,
|
|
277
|
+
): Promise<DetectedMachineInfo> {
|
|
278
|
+
// Validate inputs
|
|
279
|
+
if (!ip) {
|
|
280
|
+
throw new DetectionError('IP address is required');
|
|
281
|
+
}
|
|
282
|
+
if (!sshUser) {
|
|
283
|
+
throw new DetectionError('SSH user is required');
|
|
284
|
+
}
|
|
285
|
+
if (!sshKeyPath) {
|
|
286
|
+
throw new DetectionError('SSH key path is required');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Detect all information
|
|
290
|
+
const hostname = detectHostname(ip, sshUser, sshKeyPath);
|
|
291
|
+
const cpu_cores = detectCpuCores(ip, sshUser, sshKeyPath);
|
|
292
|
+
const memory_mb = detectMemory(ip, sshUser, sshKeyPath);
|
|
293
|
+
const disk_gb = detectDisk(ip, sshUser, sshKeyPath);
|
|
294
|
+
const arch = detectArch(ip, sshUser, sshKeyPath);
|
|
295
|
+
const osInfo = detectOsInfo(ip, sshUser, sshKeyPath);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
hostname,
|
|
299
|
+
osInfo,
|
|
300
|
+
hardware: {
|
|
301
|
+
cpu_cores,
|
|
302
|
+
memory_mb,
|
|
303
|
+
disk_gb,
|
|
304
|
+
arch,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Test SSH connectivity to a machine
|
|
311
|
+
*
|
|
312
|
+
* @param ip - Machine IP address
|
|
313
|
+
* @param sshUser - SSH username
|
|
314
|
+
* @param sshKeyPath - Path to SSH private key file
|
|
315
|
+
* @returns True if SSH connection successful
|
|
316
|
+
*/
|
|
317
|
+
export async function testSshConnection(
|
|
318
|
+
ip: string,
|
|
319
|
+
sshUser: string,
|
|
320
|
+
sshKeyPath: string,
|
|
321
|
+
): Promise<boolean> {
|
|
322
|
+
try {
|
|
323
|
+
sshExec(ip, sshUser, sshKeyPath, 'echo test');
|
|
324
|
+
return true;
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|