@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -12,6 +12,7 @@ import {
12
12
  import type { ModuleManifest } from '@/manifest/schema';
13
13
  import { and, eq } from 'drizzle-orm';
14
14
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
15
+ import { upsertModuleConfig } from './module-config';
15
16
 
16
17
  const TEST_DB_PATH = './test-infra-resolver.db';
17
18
 
@@ -268,26 +269,9 @@ describe('resolveInfrastructureVariables - Proxmox Container Service', () => {
268
269
  });
269
270
 
270
271
  // Create IPAM-allocated config (mimics what IPAM does)
271
- await db.insert(moduleConfigs).values([
272
- {
273
- moduleId,
274
- key: 'vmid',
275
- value: '100',
276
- valueJson: null,
277
- },
278
- {
279
- moduleId,
280
- key: 'target_ip',
281
- value: '10.0.10.5',
282
- valueJson: null,
283
- },
284
- {
285
- moduleId,
286
- key: 'hostname',
287
- value: 'homebridge',
288
- valueJson: null,
289
- },
290
- ]);
272
+ upsertModuleConfig(db, moduleId, 'vmid', '100');
273
+ upsertModuleConfig(db, moduleId, 'target_ip', '10.0.10.5');
274
+ upsertModuleConfig(db, moduleId, 'hostname', 'homebridge');
291
275
 
292
276
  // Resolve variables (no Terraform outputs = Proxmox)
293
277
  const result = await resolveInfrastructureVariables(moduleId, manifest, null, db);
@@ -422,12 +406,7 @@ describe('resolveInfrastructureVariables - Digital Ocean Container Service', ()
422
406
  });
423
407
 
424
408
  // Create hostname config (user-configured)
425
- await db.insert(moduleConfigs).values({
426
- moduleId,
427
- key: 'hostname',
428
- value: 'dns-ext',
429
- valueJson: null,
430
- });
409
+ upsertModuleConfig(db, moduleId, 'hostname', 'dns-ext');
431
410
 
432
411
  // Terraform outputs (wrapped format)
433
412
  const terraformOutputs = {
@@ -502,12 +481,7 @@ describe('resolveInfrastructureVariables - User Override', () => {
502
481
  });
503
482
 
504
483
  // User manually set ip.primary
505
- await db.insert(moduleConfigs).values({
506
- moduleId,
507
- key: 'ip.primary',
508
- value: '198.51.100.50', // User override
509
- valueJson: null,
510
- });
484
+ upsertModuleConfig(db, moduleId, 'ip.primary', '198.51.100.50');
511
485
 
512
486
  const result = await resolveInfrastructureVariables(moduleId, manifest, null, db);
513
487
 
@@ -7,6 +7,7 @@ import {
7
7
  extractTerraformProperties,
8
8
  } from '@/infrastructure/property-extractor';
9
9
  import type { ModuleManifest } from '@/manifest/schema';
10
+ import { upsertModuleConfig } from '@/services/module-config';
10
11
  import type { Machine } from '@/types/infrastructure';
11
12
  import { and, eq } from 'drizzle-orm';
12
13
 
@@ -190,19 +191,8 @@ export async function resolveInfrastructureVariables(
190
191
  continue;
191
192
  }
192
193
 
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
- });
194
+ // Store in moduleConfigs (always via the typed-storage helper)
195
+ upsertModuleConfig(db, moduleId, variable.name, value);
206
196
 
207
197
  resolved[variable.name] = value;
208
198
  }
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Machine Detector
3
- * Auto-detects machine information via SSH
3
+ * Auto-detects machine information, either over SSH (remote machines) or
4
+ * locally (the management box adding itself — 127.0.0.1, no SSH needed).
5
+ *
6
+ * The detection logic is identical either way; only command EXECUTION
7
+ * differs. Each detector takes a `CommandRunner` so the SSH and local
8
+ * paths share the same parsing.
4
9
  */
5
10
 
6
11
  import { execSync } from 'node:child_process';
@@ -18,6 +23,9 @@ export class DetectionError extends Error {
18
23
  }
19
24
  }
20
25
 
26
+ /** Runs a shell command and returns trimmed stdout (throws on failure). */
27
+ export type CommandRunner = (command: string) => string;
28
+
21
29
  /**
22
30
  * Execute SSH command and return output
23
31
  */
@@ -38,11 +46,31 @@ function sshExec(ip: string, user: string, keyPath: string, command: string): st
38
46
  }
39
47
  }
40
48
 
49
+ /** CommandRunner that SSHes to a remote machine. */
50
+ function sshRunner(ip: string, user: string, keyPath: string): CommandRunner {
51
+ return (command) => sshExec(ip, user, keyPath, command);
52
+ }
53
+
54
+ /** CommandRunner that runs the command locally (for the self/localhost box). */
55
+ export const localRunner: CommandRunner = (command) => {
56
+ try {
57
+ return execSync(command, {
58
+ encoding: 'utf8',
59
+ stdio: ['pipe', 'pipe', 'pipe'],
60
+ timeout: 10000,
61
+ shell: '/bin/bash',
62
+ }).trim();
63
+ } catch (error) {
64
+ const message = error instanceof Error ? error.message : 'Unknown error';
65
+ throw new DetectionError(`Local command failed: ${message}`);
66
+ }
67
+ };
68
+
41
69
  /**
42
70
  * Detect hostname
43
71
  */
44
- function detectHostname(ip: string, user: string, keyPath: string): string {
45
- const output = sshExec(ip, user, keyPath, 'hostname');
72
+ function detectHostname(run: CommandRunner): string {
73
+ const output = run('hostname');
46
74
  if (!output) {
47
75
  throw new DetectionError('Failed to detect hostname: empty output');
48
76
  }
@@ -52,8 +80,8 @@ function detectHostname(ip: string, user: string, keyPath: string): string {
52
80
  /**
53
81
  * Detect CPU cores
54
82
  */
55
- function detectCpuCores(ip: string, user: string, keyPath: string): number {
56
- const output = sshExec(ip, user, keyPath, 'nproc || grep -c ^processor /proc/cpuinfo');
83
+ function detectCpuCores(run: CommandRunner): number {
84
+ const output = run('nproc || grep -c ^processor /proc/cpuinfo');
57
85
  const cores = Number.parseInt(output, 10);
58
86
  if (Number.isNaN(cores) || cores <= 0) {
59
87
  throw new DetectionError(`Invalid CPU cores detected: ${output}`);
@@ -64,9 +92,9 @@ function detectCpuCores(ip: string, user: string, keyPath: string): number {
64
92
  /**
65
93
  * Detect memory in MB
66
94
  */
67
- function detectMemory(ip: string, user: string, keyPath: string): number {
95
+ function detectMemory(run: CommandRunner): number {
68
96
  // Get memory line, parse in JavaScript
69
- const output = sshExec(ip, user, keyPath, 'grep MemTotal /proc/meminfo');
97
+ const output = run('grep MemTotal /proc/meminfo');
70
98
 
71
99
  // Parse "MemTotal: 1048576 kB" -> extract number
72
100
  const match = output.match(/MemTotal:\s+(\d+)\s+kB/);
@@ -84,9 +112,9 @@ function detectMemory(ip: string, user: string, keyPath: string): number {
84
112
  /**
85
113
  * Detect disk space in GB
86
114
  */
87
- function detectDisk(ip: string, user: string, keyPath: string): number {
115
+ function detectDisk(run: CommandRunner): number {
88
116
  // Get root filesystem size, parse in JavaScript
89
- const output = sshExec(ip, user, keyPath, 'df -BG / | tail -1');
117
+ const output = run('df -BG / | tail -1');
90
118
 
91
119
  // Parse "Filesystem 1G-blocks Used Available Use% Mounted" -> extract second field
92
120
  // Example: "/dev/sda1 20G 5G 14G 27% /"
@@ -112,9 +140,9 @@ function detectDisk(ip: string, user: string, keyPath: string): number {
112
140
  /**
113
141
  * Detect CPU architecture (arm64, x64, etc.)
114
142
  */
115
- function detectArch(ip: string, user: string, keyPath: string): string {
143
+ function detectArch(run: CommandRunner): string {
116
144
  try {
117
- const output = sshExec(ip, user, keyPath, 'dpkg --print-architecture 2>/dev/null || uname -m');
145
+ const output = run('dpkg --print-architecture 2>/dev/null || uname -m');
118
146
  const raw = output.trim();
119
147
  // Normalize: aarch64 → arm64, x86_64 → amd64
120
148
  if (raw === 'aarch64' || raw === 'arm64') return 'arm64';
@@ -128,22 +156,17 @@ function detectArch(ip: string, user: string, keyPath: string): string {
128
156
  /**
129
157
  * Detect OS information
130
158
  */
131
- function detectOsInfo(ip: string, user: string, keyPath: string): string {
159
+ function detectOsInfo(run: CommandRunner): string {
132
160
  try {
133
161
  // 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
- );
162
+ const output = run("cat /etc/os-release | grep PRETTY_NAME | cut -d'\"' -f2");
140
163
  if (output) {
141
164
  return output;
142
165
  }
143
166
  } catch {
144
167
  // Fall back to uname if /etc/os-release not available
145
168
  try {
146
- const output = sshExec(ip, user, keyPath, 'uname -s -r');
169
+ const output = run('uname -s -r');
147
170
  return output || 'Unknown Linux';
148
171
  } catch {
149
172
  return 'Unknown Linux';
@@ -201,37 +224,36 @@ function parseIpTextOutput(output: string): Array<{ name: string; ipAddress: str
201
224
  }
202
225
 
203
226
  /**
204
- * Detect all network interfaces on a machine via SSH
205
- * Classifies each interface's zone by matching IP against configured subnets
227
+ * Detect network interfaces using the given runner. `fallbackIp` is used
228
+ * to synthesize a single interface when detection yields nothing.
206
229
  */
207
- export async function detectNetworkInterfaces(
208
- ip: string,
209
- sshUser: string,
210
- sshKeyPath: string,
230
+ async function detectNetworkInterfacesWith(
231
+ run: CommandRunner,
232
+ fallbackIp: string,
211
233
  ): Promise<{ interfaces: NetworkInterface[]; role: MachineRole }> {
212
234
  let rawInterfaces: Array<{ name: string; ipAddress: string }>;
213
235
 
214
236
  try {
215
- const jsonOutput = sshExec(ip, sshUser, sshKeyPath, 'ip -j addr show');
237
+ const jsonOutput = run('ip -j addr show');
216
238
  rawInterfaces = parseIpJsonOutput(jsonOutput);
217
239
  } catch {
218
240
  try {
219
- const textOutput = sshExec(ip, sshUser, sshKeyPath, 'ip addr show');
241
+ const textOutput = run('ip addr show');
220
242
  rawInterfaces = parseIpTextOutput(textOutput);
221
243
  } catch {
222
244
  // Can't detect interfaces - return single interface from known IP
223
- const zone = await detectZoneFromIp(ip);
245
+ const zone = await detectZoneFromIp(fallbackIp);
224
246
  return {
225
- interfaces: [{ name: 'unknown', ipAddress: ip, zone }],
247
+ interfaces: [{ name: 'unknown', ipAddress: fallbackIp, zone }],
226
248
  role: 'host',
227
249
  };
228
250
  }
229
251
  }
230
252
 
231
253
  if (rawInterfaces.length === 0) {
232
- const zone = await detectZoneFromIp(ip);
254
+ const zone = await detectZoneFromIp(fallbackIp);
233
255
  return {
234
- interfaces: [{ name: 'unknown', ipAddress: ip, zone }],
256
+ interfaces: [{ name: 'unknown', ipAddress: fallbackIp, zone }],
235
257
  role: 'host',
236
258
  };
237
259
  }
@@ -261,6 +283,50 @@ export async function detectNetworkInterfaces(
261
283
  return { interfaces, role };
262
284
  }
263
285
 
286
+ /**
287
+ * Detect all network interfaces on a remote machine via SSH.
288
+ */
289
+ export async function detectNetworkInterfaces(
290
+ ip: string,
291
+ sshUser: string,
292
+ sshKeyPath: string,
293
+ ): Promise<{ interfaces: NetworkInterface[]; role: MachineRole }> {
294
+ return detectNetworkInterfacesWith(sshRunner(ip, sshUser, sshKeyPath), ip);
295
+ }
296
+
297
+ /**
298
+ * Detect network interfaces on the local (management) box — no SSH.
299
+ */
300
+ export async function detectNetworkInterfacesLocal(): Promise<{
301
+ interfaces: NetworkInterface[];
302
+ role: MachineRole;
303
+ }> {
304
+ return detectNetworkInterfacesWith(localRunner, '127.0.0.1');
305
+ }
306
+
307
+ /**
308
+ * Detect machine information using the given runner.
309
+ */
310
+ function detectMachineInfoWith(run: CommandRunner): DetectedMachineInfo {
311
+ const hostname = detectHostname(run);
312
+ const cpu_cores = detectCpuCores(run);
313
+ const memory_mb = detectMemory(run);
314
+ const disk_gb = detectDisk(run);
315
+ const arch = detectArch(run);
316
+ const osInfo = detectOsInfo(run);
317
+
318
+ return {
319
+ hostname,
320
+ osInfo,
321
+ hardware: {
322
+ cpu_cores,
323
+ memory_mb,
324
+ disk_gb,
325
+ arch,
326
+ },
327
+ };
328
+ }
329
+
264
330
  /**
265
331
  * Detect machine information via SSH
266
332
  *
@@ -286,24 +352,14 @@ export async function detectMachineInfo(
286
352
  throw new DetectionError('SSH key path is required');
287
353
  }
288
354
 
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);
355
+ return detectMachineInfoWith(sshRunner(ip, sshUser, sshKeyPath));
356
+ }
296
357
 
297
- return {
298
- hostname,
299
- osInfo,
300
- hardware: {
301
- cpu_cores,
302
- memory_mb,
303
- disk_gb,
304
- arch,
305
- },
306
- };
358
+ /**
359
+ * Detect machine information on the local (management) box — no SSH.
360
+ */
361
+ export async function detectMachineInfoLocal(): Promise<DetectedMachineInfo> {
362
+ return detectMachineInfoWith(localRunner);
307
363
  }
308
364
 
309
365
  /**
@@ -1,7 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { eq } from 'drizzle-orm';
2
+ import { and, eq, inArray } from 'drizzle-orm';
3
3
  import { getDb } from '../db/client';
4
- import { type NetworkZone, machines, moduleInfrastructure } from '../db/schema';
4
+ import {
5
+ type NetworkZone,
6
+ containerServices,
7
+ ipAllocations,
8
+ machines,
9
+ moduleInfrastructure,
10
+ } from '../db/schema';
5
11
  import { decryptSecret, encryptSecret } from '../secrets/encryption';
6
12
  import { getOrCreateMasterKey } from '../secrets/master-key';
7
13
  import type {
@@ -70,6 +76,7 @@ export async function addMachine(
70
76
  interfaces: (machine.interfaces ?? []) as NetworkInterface[],
71
77
  assignedModuleIds: machine.assignedModuleIds,
72
78
  earmarkedModule: machine.earmarkedModule || null,
79
+ apiOnly: false, // defaults to false; toggled later via separate flow
73
80
  createdAt: now,
74
81
  updatedAt: now,
75
82
  };
@@ -95,6 +102,7 @@ function rowToMachine(row: typeof machines.$inferSelect): Machine {
95
102
  interfaces,
96
103
  assignedModuleIds,
97
104
  earmarkedModule: row.earmarkedModule ?? undefined,
105
+ apiOnly: row.apiOnly,
98
106
  createdAt: new Date(row.createdAt),
99
107
  updatedAt: new Date(row.updatedAt),
100
108
  };
@@ -231,6 +239,141 @@ export async function listMachines(filters?: MachineFilters): Promise<Machine[]>
231
239
  return results.map(rowToMachine);
232
240
  }
233
241
 
242
+ /**
243
+ * Return every machine in any of the given zones — the discovery
244
+ * query the aspect runner uses to find fan-out targets per
245
+ * v2/CELILO_BASE.md.
246
+ *
247
+ * Filters:
248
+ * - `excludeApiOnly` (default `true`): drops machines marked
249
+ * `api_only` (the greenwave / ISP-modem case). Aspects use
250
+ * Ansible, so api_only systems are unreachable. v2/CELILO_BASE.md
251
+ * D8.
252
+ * - `excludeHostnames` (default `[]`): drops named hostnames.
253
+ * Aspect authors that need to skip the system running their own
254
+ * primary deploy pass it here; the framework doesn't auto-skip
255
+ * (v2/CELILO_BASE.md D3).
256
+ *
257
+ * NOTE on container_service systems: this Phase 1 implementation
258
+ * covers MACHINE-based systems only (the machines table). LXCs/VMs
259
+ * provisioned via container_service live in
260
+ * `module_infrastructure` and inherit zone from `ip_allocations`;
261
+ * supporting them is a future iteration. For Phase 1's
262
+ * aspect-fanout test (and forgejo's e2e), all targets are machine
263
+ * pool entries, so this is sufficient.
264
+ */
265
+ export async function getSystemsByZone(
266
+ zones: string[],
267
+ options: { excludeApiOnly?: boolean; excludeHostnames?: string[] } = {},
268
+ ): Promise<Machine[]> {
269
+ if (zones.length === 0) return [];
270
+ const db = getDb();
271
+ const excludeApiOnly = options.excludeApiOnly ?? true;
272
+ const excludeHostnames = new Set(options.excludeHostnames ?? []);
273
+
274
+ const rows = await db
275
+ .select()
276
+ .from(machines)
277
+ .where(inArray(machines.zone, zones as NetworkZone[]));
278
+
279
+ return rows
280
+ .map(rowToMachine)
281
+ .filter((m) => !(excludeApiOnly && m.apiOnly))
282
+ .filter((m) => !excludeHostnames.has(m.hostname));
283
+ }
284
+
285
+ /**
286
+ * A container_service-provisioned system (Proxmox LXC, Digital
287
+ * Ocean droplet, etc.) — the complement to a machine-pool entry.
288
+ * Surfaced by `getContainerSystemsByZone` for SC5 Proxmox
289
+ * reconciliation: each LXC's owning module has terraform state we
290
+ * may need to update.
291
+ *
292
+ * `containerMetadata` is the raw JSON from the db (vmid, droplet
293
+ * ID, container_ip, etc. — provider-specific shape); callers
294
+ * inspect it as needed.
295
+ */
296
+ export interface ContainerSystem {
297
+ /** UUID of the module_infrastructure row. */
298
+ infrastructureId: string;
299
+ /** Module that owns this provisioned system (and its terraform state). */
300
+ moduleId: string;
301
+ /** Container service this system was provisioned through. */
302
+ serviceId: string;
303
+ providerName: 'proxmox' | 'digitalocean' | 'aws' | 'gcp' | 'azure';
304
+ /** Zone from `ip_allocations` (the authoritative zone for the LXC's IP). */
305
+ zone: NetworkZone;
306
+ /** Container IP in CIDR (e.g., "10.0.20.42/24"). May be null if allocation absent. */
307
+ containerIp: string | null;
308
+ containerMetadata: Record<string, unknown> | null;
309
+ apiOnly: boolean;
310
+ }
311
+
312
+ /**
313
+ * Enumerate container_service-provisioned systems whose IP-allocation
314
+ * zone matches the input. Used by SC5's Proxmox reconciler to find
315
+ * LXCs whose terraform config may need rewriting when an aspect
316
+ * declares `proxmox_reconcile.tfvars`.
317
+ *
318
+ * Filters api_only systems by default. The aspect runner reads the
319
+ * same row to decide if Ansible should even reach the system.
320
+ *
321
+ * Phase 1 simulator note: cele2e test environments add systems via
322
+ * `celilo machine add`, which writes to the machines table — NOT
323
+ * module_infrastructure. So this function returns [] for those
324
+ * tests. It only matters in production where container_service
325
+ * (Proxmox) is real.
326
+ */
327
+ export async function getContainerSystemsByZone(
328
+ zones: string[],
329
+ options: { excludeApiOnly?: boolean } = {},
330
+ ): Promise<ContainerSystem[]> {
331
+ if (zones.length === 0) return [];
332
+ const db = getDb();
333
+ const excludeApiOnly = options.excludeApiOnly ?? true;
334
+
335
+ // ip_allocations is the authoritative source for which LXC sits
336
+ // in which zone (Proxmox doesn't expose zone directly; celilo's
337
+ // IPAM is what assigned it). Join it to module_infrastructure to
338
+ // get the rest of the metadata.
339
+ const rows = await db
340
+ .select({
341
+ infraId: moduleInfrastructure.id,
342
+ moduleId: moduleInfrastructure.moduleId,
343
+ serviceId: moduleInfrastructure.serviceId,
344
+ containerMetadata: moduleInfrastructure.containerMetadata,
345
+ apiOnly: moduleInfrastructure.apiOnly,
346
+ ipZone: ipAllocations.zone,
347
+ containerIp: ipAllocations.containerIp,
348
+ providerName: containerServices.providerName,
349
+ })
350
+ .from(moduleInfrastructure)
351
+ .innerJoin(ipAllocations, eq(ipAllocations.moduleId, moduleInfrastructure.moduleId))
352
+ .innerJoin(containerServices, eq(containerServices.id, moduleInfrastructure.serviceId))
353
+ .where(
354
+ and(
355
+ eq(moduleInfrastructure.infrastructureType, 'container_service'),
356
+ // ip_allocations.zone is narrower than NetworkZone (no 'external');
357
+ // safe to cast — any 'external' input would just match zero rows.
358
+ inArray(ipAllocations.zone, zones as Array<'dmz' | 'app' | 'secure' | 'internal'>),
359
+ ),
360
+ );
361
+
362
+ return rows
363
+ .filter((r) => !(excludeApiOnly && r.apiOnly))
364
+ .filter((r): r is typeof r & { serviceId: string } => r.serviceId !== null)
365
+ .map((r) => ({
366
+ infrastructureId: r.infraId,
367
+ moduleId: r.moduleId,
368
+ serviceId: r.serviceId,
369
+ providerName: r.providerName,
370
+ zone: r.ipZone,
371
+ containerIp: r.containerIp ?? null,
372
+ containerMetadata: r.containerMetadata ?? null,
373
+ apiOnly: r.apiOnly,
374
+ }));
375
+ }
376
+
234
377
  /**
235
378
  * Remove a machine from the pool
236
379
  */