@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.
Files changed (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. 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
+ }