@celilo/cli 0.26.1 → 1.0.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 (48) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0025_port_forward_owner.sql +29 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +3 -3
  6. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  7. package/src/ansible/dependencies.test.ts +233 -289
  8. package/src/ansible/dependencies.ts +151 -83
  9. package/src/cli/commands/alerts-sweep.ts +14 -3
  10. package/src/cli/commands/machine-add.ts +0 -1
  11. package/src/cli/commands/machine-list.ts +10 -4
  12. package/src/cli/commands/machine-remove.ts +13 -7
  13. package/src/cli/commands/machine-status.ts +9 -11
  14. package/src/cli/commands/module-remove.ts +26 -23
  15. package/src/cli/commands/system-audit.ts +5 -1
  16. package/src/cli/commands/system-update.ts +10 -2
  17. package/src/db/schema.ts +30 -10
  18. package/src/hooks/capability-loader.ts +65 -13
  19. package/src/hooks/define-hook.test.ts +4 -6
  20. package/src/hooks/executor.ts +2 -1
  21. package/src/hooks/types.ts +9 -17
  22. package/src/infrastructure/property-extractor.test.ts +0 -2
  23. package/src/manifest/contracts/index.ts +20 -0
  24. package/src/manifest/contracts/v1.ts +33 -1
  25. package/src/manifest/schema.ts +48 -58
  26. package/src/services/alerting/sweep-runner.test.ts +5 -1
  27. package/src/services/alerting/sweep-runner.ts +14 -8
  28. package/src/services/aspect-runner.test.ts +0 -1
  29. package/src/services/audit/undeployed-modules.ts +18 -1
  30. package/src/services/consumer-cleanup.test.ts +347 -0
  31. package/src/services/consumer-cleanup.ts +244 -0
  32. package/src/services/infrastructure-selector.test.ts +0 -7
  33. package/src/services/infrastructure-selector.ts +24 -25
  34. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  35. package/src/services/infrastructure-variable-resolver.ts +0 -3
  36. package/src/services/machine-pool.test.ts +53 -85
  37. package/src/services/machine-pool.ts +68 -84
  38. package/src/services/module-deploy.ts +17 -39
  39. package/src/services/module-validator/index.test.ts +9 -0
  40. package/src/services/port-forwards.test.ts +93 -40
  41. package/src/services/port-forwards.ts +74 -48
  42. package/src/services/ssh-key-manager.test.ts +0 -10
  43. package/src/services/trusted-sources.test.ts +52 -13
  44. package/src/services/trusted-sources.ts +25 -15
  45. package/src/test-utils/cli-context.ts +15 -2
  46. package/src/types/infrastructure.ts +11 -1
  47. package/src/services/web-route-cleanup.test.ts +0 -250
  48. package/src/services/web-route-cleanup.ts +0 -144
@@ -7,7 +7,7 @@ import type {
7
7
  ResourceRequirements,
8
8
  } from '../types/infrastructure';
9
9
  import { listContainerServices } from './container-service';
10
- import { getModuleResourcesOnMachine, listMachines } from './machine-pool';
10
+ import { getModulesOnMachine, listMachines } from './machine-pool';
11
11
 
12
12
  /**
13
13
  * Infrastructure selection error
@@ -49,25 +49,24 @@ function getResourceRequirements(module: Module): ResourceRequirements {
49
49
  }
50
50
 
51
51
  /**
52
- * Check if a machine has capacity for the given requirements
52
+ * Is this machine big enough for the module at all?
53
+ *
54
+ * Deliberately NOT multi-tenant capacity accounting (celilo#773). It used to
55
+ * subtract an "already allocated" figure that was hard-coded to zero behind a
56
+ * TODO, so the subtraction never changed an answer and the check has always
57
+ * been exactly this comparison. Saying so is the honest version; a gate that
58
+ * cannot fail reads as protection that is not there (Rule 7.6).
59
+ *
60
+ * Real accounting needs a decision nobody has made — whether a module's draw is
61
+ * its manifest MINIMUM (`requires.system`) or its deployed size, which on a
62
+ * pool machine celilo does not own. Until then the occupancy filter, which IS
63
+ * now correct, is what keeps two modules off one box.
53
64
  */
54
- async function machineHasCapacity(
55
- machine: Machine,
56
- requirements: ResourceRequirements,
57
- ): Promise<boolean> {
58
- // Get already-allocated resources
59
- const allocated = await getModuleResourcesOnMachine(machine.id);
60
-
61
- // Calculate remaining capacity
62
- const remainingCpu = machine.hardware.cpu_cores - allocated.cpu;
63
- const remainingMemory = machine.hardware.memory_mb - allocated.memory;
64
- const remainingDisk = machine.hardware.disk_gb - allocated.disk;
65
-
66
- // Check if machine has enough remaining resources
65
+ function machineHasCapacity(machine: Machine, requirements: ResourceRequirements): boolean {
67
66
  return (
68
- remainingCpu >= requirements.cpu &&
69
- remainingMemory >= requirements.memory &&
70
- remainingDisk >= requirements.disk
67
+ machine.hardware.cpu_cores >= requirements.cpu &&
68
+ machine.hardware.memory_mb >= requirements.memory &&
69
+ machine.hardware.disk_gb >= requirements.disk
71
70
  );
72
71
  }
73
72
 
@@ -181,11 +180,12 @@ export async function selectInfrastructure(module: Module): Promise<Infrastructu
181
180
  continue;
182
181
  }
183
182
  // Skip machines already assigned to other modules
184
- if (machine.assignedModuleIds.length > 0 && !machine.assignedModuleIds.includes(moduleId)) {
183
+ const occupants = getModulesOnMachine(machine.id);
184
+ if (occupants.length > 0 && !occupants.includes(moduleId)) {
185
185
  rejections.push({
186
186
  hostname: machine.hostname,
187
187
  ipAddress: machine.ipAddress,
188
- reason: `already assigned to module(s): ${machine.assignedModuleIds.join(', ')}`,
188
+ reason: `already assigned to module(s): ${occupants.join(', ')}`,
189
189
  });
190
190
  continue;
191
191
  }
@@ -208,14 +208,13 @@ export async function selectInfrastructure(module: Module): Promise<Infrastructu
208
208
  });
209
209
  continue;
210
210
  }
211
- if (await machineHasCapacity(machine, requirements)) {
211
+ if (machineHasCapacity(machine, requirements)) {
212
212
  availableMachines.push(machine);
213
213
  } else {
214
- const allocated = await getModuleResourcesOnMachine(machine.id);
215
214
  const shortfalls: string[] = [];
216
- const remainingCpu = machine.hardware.cpu_cores - allocated.cpu;
217
- const remainingMemory = machine.hardware.memory_mb - allocated.memory;
218
- const remainingDisk = machine.hardware.disk_gb - allocated.disk;
215
+ const remainingCpu = machine.hardware.cpu_cores;
216
+ const remainingMemory = machine.hardware.memory_mb;
217
+ const remainingDisk = machine.hardware.disk_gb;
219
218
 
220
219
  if (remainingCpu < requirements.cpu) {
221
220
  shortfalls.push(`CPU: ${remainingCpu} available, ${requirements.cpu} required`);
@@ -96,7 +96,6 @@ describe('resolveInfrastructureVariables - Machine Infrastructure', () => {
96
96
  sshKeyEncrypted: 'encrypted-key',
97
97
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
98
98
  zone: 'external',
99
- assignedModuleIds: [],
100
99
  });
101
100
 
102
101
  // Create module infrastructure selection
@@ -169,7 +168,6 @@ describe('resolveInfrastructureVariables - Machine Infrastructure', () => {
169
168
  sshKeyEncrypted: 'encrypted',
170
169
  hardware: { cpu_cores: 1, memory_mb: 1024, disk_gb: 10 },
171
170
  zone: 'internal',
172
- assignedModuleIds: [],
173
171
  });
174
172
 
175
173
  await db.insert(moduleInfrastructure).values({
@@ -468,7 +466,6 @@ describe('resolveInfrastructureVariables - User Override', () => {
468
466
  sshKeyEncrypted: 'encrypted',
469
467
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
470
468
  zone: 'external',
471
- assignedModuleIds: [],
472
469
  });
473
470
 
474
471
  await db.insert(moduleInfrastructure).values({
@@ -526,7 +523,6 @@ describe('resolveInfrastructureVariables - Required vs Optional', () => {
526
523
  sshKeyEncrypted: 'encrypted',
527
524
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
528
525
  zone: 'internal',
529
- assignedModuleIds: [],
530
526
  });
531
527
 
532
528
  await db.insert(moduleInfrastructure).values({
@@ -583,7 +579,6 @@ describe('resolveInfrastructureVariables - Required vs Optional', () => {
583
579
  sshKeyEncrypted: 'encrypted',
584
580
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
585
581
  zone: 'internal',
586
- assignedModuleIds: [],
587
582
  });
588
583
 
589
584
  await db.insert(moduleInfrastructure).values({
@@ -694,7 +689,6 @@ describe('resolveInfrastructureVariables - Edge Cases', () => {
694
689
  sshKeyEncrypted: 'encrypted',
695
690
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
696
691
  zone: 'internal',
697
- assignedModuleIds: [],
698
692
  });
699
693
 
700
694
  await db.insert(moduleInfrastructure).values({
@@ -90,9 +90,6 @@ export async function resolveInfrastructureVariables(
90
90
  hardware: machineRow.hardware || { cpu_cores: 0, memory_mb: 0, disk_gb: 0 },
91
91
  role: (machineRow.role as Machine['role']) || 'host',
92
92
  interfaces: (machineRow.interfaces || []) as Machine['interfaces'],
93
- assignedModuleIds: Array.isArray(machineRow.assignedModuleIds)
94
- ? machineRow.assignedModuleIds
95
- : [],
96
93
  earmarkedModule: machineRow.earmarkedModule ?? undefined,
97
94
  createdAt: new Date(machineRow.createdAt),
98
95
  updatedAt: new Date(machineRow.updatedAt),
@@ -7,18 +7,17 @@ import { mkdtempSync, rmSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { eq } from 'drizzle-orm';
10
- import { closeDb, createDbClient } from '../db/client';
10
+ import { closeDb, createDbClient, getDb } from '../db/client';
11
11
  import { runMigrations } from '../db/migrate';
12
- import { machines } from '../db/schema';
12
+ import { machines, moduleInfrastructure, modules } from '../db/schema';
13
13
  import {
14
14
  addMachine,
15
- assignModuleToMachine,
16
15
  getMachine,
17
16
  getMachineByHostname,
18
17
  getMachineSshKey,
18
+ getModulesOnMachine,
19
19
  listMachines,
20
20
  removeMachine,
21
- unassignModuleFromMachine,
22
21
  } from './machine-pool';
23
22
 
24
23
  describe('machine-pool', () => {
@@ -72,7 +71,6 @@ describe('machine-pool', () => {
72
71
  },
73
72
  role: 'host',
74
73
  interfaces: [],
75
- assignedModuleIds: [],
76
74
  });
77
75
 
78
76
  expect(machine.id).toBeDefined();
@@ -80,7 +78,6 @@ describe('machine-pool', () => {
80
78
  expect(machine.zone).toBe('internal');
81
79
  expect(machine.ipAddress).toBe('192.168.1.100');
82
80
  expect(machine.hardware.cpu_cores).toBe(4);
83
- expect(machine.assignedModuleIds).toEqual([]);
84
81
  expect(machine.createdAt).toBeInstanceOf(Date);
85
82
  });
86
83
 
@@ -94,7 +91,6 @@ describe('machine-pool', () => {
94
91
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
95
92
  role: 'host',
96
93
  interfaces: [],
97
- assignedModuleIds: [],
98
94
  });
99
95
 
100
96
  // Verify key is encrypted in database
@@ -115,7 +111,6 @@ describe('machine-pool', () => {
115
111
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
116
112
  role: 'host',
117
113
  interfaces: [],
118
- assignedModuleIds: [],
119
114
  });
120
115
 
121
116
  const externalMachine = await addMachine({
@@ -127,7 +122,6 @@ describe('machine-pool', () => {
127
122
  hardware: { cpu_cores: 1, memory_mb: 1024, disk_gb: 25 },
128
123
  role: 'host',
129
124
  interfaces: [],
130
- assignedModuleIds: [],
131
125
  });
132
126
 
133
127
  expect(dmzMachine.zone).toBe('dmz');
@@ -146,7 +140,6 @@ describe('machine-pool', () => {
146
140
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
147
141
  role: 'host',
148
142
  interfaces: [],
149
- assignedModuleIds: [],
150
143
  });
151
144
 
152
145
  const retrieved = await getMachine(created.id);
@@ -173,7 +166,6 @@ describe('machine-pool', () => {
173
166
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
174
167
  role: 'host',
175
168
  interfaces: [],
176
- assignedModuleIds: [],
177
169
  });
178
170
 
179
171
  const retrieved = await getMachineByHostname('unique-hostname');
@@ -204,7 +196,6 @@ describe('machine-pool', () => {
204
196
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
205
197
  role: 'host',
206
198
  interfaces: [],
207
- assignedModuleIds: [],
208
199
  });
209
200
 
210
201
  await addMachine({
@@ -216,7 +207,6 @@ describe('machine-pool', () => {
216
207
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
217
208
  role: 'host',
218
209
  interfaces: [],
219
- assignedModuleIds: [],
220
210
  });
221
211
 
222
212
  const result = await listMachines();
@@ -233,7 +223,6 @@ describe('machine-pool', () => {
233
223
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
234
224
  role: 'host',
235
225
  interfaces: [],
236
- assignedModuleIds: [],
237
226
  });
238
227
 
239
228
  await addMachine({
@@ -245,7 +234,6 @@ describe('machine-pool', () => {
245
234
  hardware: { cpu_cores: 1, memory_mb: 1024, disk_gb: 25 },
246
235
  role: 'host',
247
236
  interfaces: [],
248
- assignedModuleIds: [],
249
237
  });
250
238
 
251
239
  const dmzMachines = await listMachines({ zone: 'dmz' });
@@ -269,7 +257,6 @@ describe('machine-pool', () => {
269
257
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
270
258
  role: 'host',
271
259
  interfaces: [],
272
- assignedModuleIds: [],
273
260
  });
274
261
 
275
262
  const decryptedKey = await getMachineSshKey(machine.id);
@@ -281,10 +268,19 @@ describe('machine-pool', () => {
281
268
  });
282
269
  });
283
270
 
284
- describe('assignModuleToMachine', () => {
285
- it('assigns module to machine', async () => {
271
+ /**
272
+ * celilo#773. `assignModuleToMachine` / `unassignModuleFromMachine` are gone
273
+ * along with the column they wrote — both were exported, neither had a single
274
+ * caller, and the column they maintained had an append-only writer elsewhere
275
+ * with no removal path at all.
276
+ *
277
+ * Occupancy is derived now, so these assert the two divergences that actually
278
+ * bit the fleet rather than that a setter sets.
279
+ */
280
+ describe('getModulesOnMachine', () => {
281
+ async function machineWithModuleDeployed(hostname: string, moduleId: string) {
286
282
  const machine = await addMachine({
287
- hostname: 'test-machine',
283
+ hostname,
288
284
  zone: 'internal',
289
285
  ipAddress: '192.168.1.100',
290
286
  sshUser: 'ubuntu',
@@ -292,86 +288,59 @@ describe('machine-pool', () => {
292
288
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
293
289
  role: 'host',
294
290
  interfaces: [],
295
- assignedModuleIds: [],
296
291
  });
292
+ const db = getDb();
293
+ db.insert(modules)
294
+ .values({
295
+ id: moduleId,
296
+ name: moduleId,
297
+ version: '1.0.0',
298
+ manifestData: {},
299
+ sourcePath: `/tmp/${moduleId}`,
300
+ })
301
+ .run();
302
+ db.insert(moduleInfrastructure)
303
+ .values({
304
+ id: `infra-${moduleId}`,
305
+ moduleId,
306
+ infrastructureType: 'machine',
307
+ machineId: machine.id,
308
+ })
309
+ .run();
310
+ return machine;
311
+ }
297
312
 
298
- await assignModuleToMachine(machine.id, 'homebridge');
299
-
300
- const updated = await getMachine(machine.id);
301
- expect(updated?.assignedModuleIds).toEqual(['homebridge']);
313
+ it('reports a machine that is hosting a module as occupied', async () => {
314
+ // The live case: briq hosted a VERIFIED iptables and reported
315
+ // "None (available)", so a second module could be placed on top of it.
316
+ const machine = await machineWithModuleDeployed('briq', 'iptables');
317
+ expect(getModulesOnMachine(machine.id)).toEqual(['iptables']);
302
318
  });
303
319
 
304
- it('supports multiple modules on same machine', async () => {
305
- const machine = await addMachine({
306
- hostname: 'multi-module-machine',
307
- zone: 'internal',
308
- ipAddress: '192.168.1.100',
309
- sshUser: 'ubuntu',
310
- sshKey: 'test-key',
311
- hardware: { cpu_cores: 8, memory_mb: 16384, disk_gb: 256 },
312
- role: 'host',
313
- interfaces: [],
314
- assignedModuleIds: [],
315
- });
320
+ it('frees the machine when the module is removed, with no manual step', async () => {
321
+ // The other direction: the id used to survive the module forever, so
322
+ // placement rejected an empty box citing a module that no longer existed
323
+ // and `machine remove` refused to remove it. Nothing could clear it.
324
+ const machine = await machineWithModuleDeployed('rpi4-lr', 'homebridge');
325
+ expect(getModulesOnMachine(machine.id)).toEqual(['homebridge']);
316
326
 
317
- await assignModuleToMachine(machine.id, 'module-1');
318
- await assignModuleToMachine(machine.id, 'module-2');
319
- await assignModuleToMachine(machine.id, 'module-3');
327
+ getDb().delete(modules).where(eq(modules.id, 'homebridge')).run();
320
328
 
321
- const updated = await getMachine(machine.id);
322
- expect(updated?.assignedModuleIds).toEqual(['module-1', 'module-2', 'module-3']);
329
+ expect(getModulesOnMachine(machine.id)).toEqual([]);
323
330
  });
324
331
 
325
- it('throws error for non-existent machine', async () => {
326
- await expect(assignModuleToMachine('non-existent-id', 'module-id')).rejects.toThrow(
327
- /Machine not found/,
328
- );
329
- });
330
- });
331
-
332
- describe('unassignModuleFromMachine', () => {
333
- it('removes module from machine', async () => {
332
+ it('reports an empty machine as empty', async () => {
334
333
  const machine = await addMachine({
335
- hostname: 'test-machine',
334
+ hostname: 'spare',
336
335
  zone: 'internal',
337
- ipAddress: '192.168.1.100',
336
+ ipAddress: '192.168.1.101',
338
337
  sshUser: 'ubuntu',
339
338
  sshKey: 'test-key',
340
- hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
341
- role: 'host',
342
- interfaces: [],
343
- assignedModuleIds: ['homebridge'],
344
- });
345
-
346
- await unassignModuleFromMachine(machine.id, 'homebridge');
347
-
348
- const updated = await getMachine(machine.id);
349
- expect(updated?.assignedModuleIds).toEqual([]);
350
- });
351
-
352
- it('removes specific module from multiple assignments', async () => {
353
- const machine = await addMachine({
354
- hostname: 'test-machine',
355
- zone: 'internal',
356
- ipAddress: '192.168.1.100',
357
- sshUser: 'ubuntu',
358
- sshKey: 'test-key',
359
- hardware: { cpu_cores: 8, memory_mb: 16384, disk_gb: 256 },
339
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
360
340
  role: 'host',
361
341
  interfaces: [],
362
- assignedModuleIds: ['module-1', 'module-2', 'module-3'],
363
342
  });
364
-
365
- await unassignModuleFromMachine(machine.id, 'module-2');
366
-
367
- const updated = await getMachine(machine.id);
368
- expect(updated?.assignedModuleIds).toEqual(['module-1', 'module-3']);
369
- });
370
-
371
- it('throws error for non-existent machine', async () => {
372
- await expect(unassignModuleFromMachine('non-existent-id', 'module-id')).rejects.toThrow(
373
- /Machine not found/,
374
- );
343
+ expect(getModulesOnMachine(machine.id)).toEqual([]);
375
344
  });
376
345
  });
377
346
 
@@ -386,7 +355,6 @@ describe('machine-pool', () => {
386
355
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
387
356
  role: 'host',
388
357
  interfaces: [],
389
- assignedModuleIds: [],
390
358
  });
391
359
 
392
360
  await removeMachine(machine.id);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, eq, inArray } from 'drizzle-orm';
3
- import { getDb } from '../db/client';
3
+ import { type DbClient, getDb } from '../db/client';
4
4
  import {
5
5
  type AllocatableZone,
6
6
  type NetworkZone,
@@ -8,15 +8,11 @@ import {
8
8
  ipAllocations,
9
9
  machines,
10
10
  moduleInfrastructure,
11
+ moduleSystems,
11
12
  } from '../db/schema';
12
13
  import { decryptSecret, encryptSecret } from '../secrets/encryption';
13
14
  import { getOrCreateMasterKey } from '../secrets/master-key';
14
- import type {
15
- Machine,
16
- MachineRole,
17
- NetworkInterface,
18
- ResourceAllocation,
19
- } from '../types/infrastructure';
15
+ import type { Machine, MachineRole, NetworkInterface } from '../types/infrastructure';
20
16
  import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas';
21
17
 
22
18
  /**
@@ -57,7 +53,6 @@ export async function addMachine(
57
53
  hardware: machine.hardware, // Drizzle auto-stringifies with mode: 'json'
58
54
  role: machine.role ?? 'host',
59
55
  interfaces: machine.interfaces ?? [],
60
- assignedModuleIds: machine.assignedModuleIds, // Drizzle auto-stringifies with mode: 'json'
61
56
  earmarkedModule: machine.earmarkedModule || null,
62
57
  createdAt: now,
63
58
  updatedAt: now,
@@ -75,7 +70,6 @@ export async function addMachine(
75
70
  hardware: machine.hardware,
76
71
  role: (machine.role ?? 'host') as MachineRole,
77
72
  interfaces: (machine.interfaces ?? []) as NetworkInterface[],
78
- assignedModuleIds: machine.assignedModuleIds,
79
73
  earmarkedModule: machine.earmarkedModule || null,
80
74
  apiOnly: false, // defaults to false; toggled later via separate flow
81
75
  createdAt: now,
@@ -88,7 +82,6 @@ export async function addMachine(
88
82
  */
89
83
  function rowToMachine(row: typeof machines.$inferSelect): Machine {
90
84
  const hardware = row.hardware || { cpu_cores: 0, memory_mb: 0, disk_gb: 0, arch: 'unknown' };
91
- const assignedModuleIds = Array.isArray(row.assignedModuleIds) ? row.assignedModuleIds : [];
92
85
  const interfaces = Array.isArray(row.interfaces) ? (row.interfaces as NetworkInterface[]) : [];
93
86
 
94
87
  return {
@@ -101,7 +94,6 @@ function rowToMachine(row: typeof machines.$inferSelect): Machine {
101
94
  hardware,
102
95
  role: (row.role as MachineRole) || 'host',
103
96
  interfaces,
104
- assignedModuleIds,
105
97
  earmarkedModule: row.earmarkedModule ?? undefined,
106
98
  apiOnly: row.apiOnly,
107
99
  createdAt: new Date(row.createdAt),
@@ -180,12 +172,15 @@ export async function findMachineForModule(
180
172
  if (!zone) return null;
181
173
  const allMachines = await listMachines({ zone });
182
174
 
175
+ const db = getDb();
183
176
  for (const machine of allMachines) {
184
177
  // Skip machines earmarked for other modules
185
178
  if (machine.earmarkedModule && machine.earmarkedModule !== moduleId) continue;
186
- // Skip machines already assigned to other modules
187
- if (machine.assignedModuleIds.length > 0 && !machine.assignedModuleIds.includes(moduleId))
188
- continue;
179
+ // Skip machines occupied by other modules. Derived, not read from a stored
180
+ // snapshot this preview offered an occupied machine's address with no
181
+ // error at all while the snapshot said the box was free (celilo#773).
182
+ const occupants = getModulesOnMachine(machine.id, db);
183
+ if (occupants.length > 0 && !occupants.includes(moduleId)) continue;
189
184
  // Match role if required
190
185
  if (requiredRole && (machine.role || 'host') !== requiredRole) continue;
191
186
  return machine;
@@ -385,76 +380,65 @@ export async function removeMachine(id: string): Promise<void> {
385
380
  }
386
381
 
387
382
  /**
388
- * Assign a module to a machine
389
- */
390
- export async function assignModuleToMachine(machineId: string, moduleId: string): Promise<void> {
391
- const machine = await getMachine(machineId);
392
- if (!machine) {
393
- throw new Error(`Machine not found: ${machineId}`);
394
- }
395
-
396
- const updatedModuleIds = [...machine.assignedModuleIds, moduleId];
397
- const db = getDb();
398
-
399
- await db
400
- .update(machines)
401
- .set({
402
- assignedModuleIds: updatedModuleIds, // Drizzle auto-stringifies with mode: 'json'
403
- updatedAt: new Date(),
404
- })
405
- .where(eq(machines.id, machineId));
406
- }
407
-
408
- /**
409
- * Unassign a module from a machine
410
- */
411
- export async function unassignModuleFromMachine(
412
- machineId: string,
413
- moduleId: string,
414
- ): Promise<void> {
415
- const machine = await getMachine(machineId);
416
- if (!machine) {
417
- throw new Error(`Machine not found: ${machineId}`);
418
- }
419
-
420
- const updatedModuleIds = machine.assignedModuleIds.filter((id) => id !== moduleId);
421
- const db = getDb();
422
-
423
- await db
424
- .update(machines)
425
- .set({
426
- assignedModuleIds: updatedModuleIds, // Drizzle auto-stringifies with mode: 'json'
427
- updatedAt: new Date(),
428
- })
429
- .where(eq(machines.id, machineId));
430
- }
431
-
432
- /**
433
- * Get resource allocation for all modules on a machine
434
- * Calculates total CPU, memory, and disk used by assigned modules
383
+ * Which modules occupy this machine, ANSWERED AT THE POINT OF USE (celilo#773).
384
+ *
385
+ * There used to be a `machines.assigned_module_ids` array holding this. It had
386
+ * one writer, which only ever appended, no reader that reconciled it, and no
387
+ * removal path at all — and it was load-bearing, because placement refuses a
388
+ * machine whose list is non-empty and does not name the module being placed.
389
+ * It diverged in both directions on the live fleet:
390
+ *
391
+ * - OVER-recorded, permanently: removing a module left its id behind, so
392
+ * placement rejected an empty machine citing a module that no longer
393
+ * existed, and `machine remove` refused to remove it. No command could
394
+ * clear the entry; the only remedy was editing the database.
395
+ * - UNDER-recorded: `briq` hosted a VERIFIED `iptables` and reported
396
+ * "None (available)", so a second module could be placed onto an occupied
397
+ * box the exact collision the filter exists to prevent — and the
398
+ * `machine remove` guard was equally blind. Only a separately-set earmark
399
+ * was keeping placement off it.
400
+ *
401
+ * Both silent. Deriving costs one indexed query and cannot drift, and removal
402
+ * frees the machine for free: both source tables cascade on the module row.
403
+ *
404
+ * ⚠️ The UNION of both tables is deliberate, and is not the "two sources that
405
+ * disagree" problem the old column was. Neither is a copy — both are FK columns
406
+ * owned by the deploy path — and for a SAFETY guard the liberal read is the
407
+ * correct one: reporting an occupied box as free is how two modules land on one
408
+ * machine, while reporting a free box as occupied merely sends the operator to
409
+ * look. `module_infrastructure` records the CLAIM at selection time and
410
+ * `module_systems` the realized deployment, so a module mid-deploy is visible
411
+ * in the first before it appears in the second.
435
412
  */
436
- export async function getModuleResourcesOnMachine(machineId: string): Promise<ResourceAllocation> {
437
- const machine = await getMachine(machineId);
438
- if (!machine) {
439
- throw new Error(`Machine not found: ${machineId}`);
440
- }
441
-
442
- // If no modules assigned, return zero allocation
443
- if (machine.assignedModuleIds.length === 0) {
444
- return { cpu: 0, memory: 0, disk: 0 };
445
- }
446
-
447
- const db = getDb();
448
-
449
- // Get all module infrastructure records for this machine
450
- const _infraRecords = await db
451
- .select()
413
+ export function getModulesOnMachine(machineId: string, db: DbClient = getDb()): string[] {
414
+ const claimed = db
415
+ .select({ moduleId: moduleInfrastructure.moduleId })
452
416
  .from(moduleInfrastructure)
453
- .where(eq(moduleInfrastructure.machineId, machineId));
454
-
455
- // TODO: Load module manifests to get resource requirements
456
- // For now, return placeholder allocation
457
- // This will be implemented when we integrate with module-generator
458
-
459
- return { cpu: 0, memory: 0, disk: 0 };
417
+ .where(eq(moduleInfrastructure.machineId, machineId))
418
+ .all();
419
+ const deployed = db
420
+ .select({ moduleId: moduleSystems.moduleId })
421
+ .from(moduleSystems)
422
+ .where(eq(moduleSystems.machineId, machineId))
423
+ .all();
424
+
425
+ return [...new Set([...claimed, ...deployed].map((r) => r.moduleId))].sort();
460
426
  }
427
+
428
+ // `getModuleResourcesOnMachine` is deleted (celilo#773, Rule 1.2 / 7.6).
429
+ //
430
+ // It queried `module_infrastructure`, discarded the result into an unused
431
+ // variable, and returned `{cpu: 0, memory: 0, disk: 0}` behind a TODO. Its
432
+ // three callers subtracted that zero from the machine's hardware before
433
+ // comparing against the module's requirements — so the subtraction was
434
+ // ceremony and the comparison was really "is this machine big enough at all".
435
+ //
436
+ // That is a genuinely useful check and it survives, stated plainly, in
437
+ // `machineHasCapacity`. What is gone is the appearance of multi-tenant capacity
438
+ // accounting that never accounted for anything: a gate nobody has seen fail is
439
+ // not a gate, and one that cannot fail reads as protection that is not there.
440
+ //
441
+ // Real accounting needs a decision this issue does not make — whether a
442
+ // module's draw is its manifest MINIMUM (`requires.system`) or its deployed
443
+ // size, which for pool machines celilo does not own. That belongs to whoever
444
+ // builds capacity-aware placement for the machine pool.