@celilo/cli 0.26.1 → 0.27.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.
@@ -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.
@@ -59,38 +59,18 @@ export interface DeployResult {
59
59
  };
60
60
  }
61
61
 
62
- /**
63
- * Update machine's assigned module IDs after successful deployment
64
- * Execution function - updates database
65
- *
66
- * @param moduleId - Module identifier
67
- * @param machineId - Machine identifier
68
- * @param db - Database connection
69
- */
70
- async function updateMachineAssignment(
71
- moduleId: string,
72
- machineId: string,
73
- db: DbClient,
74
- ): Promise<void> {
75
- // Get current machine record
76
- const machine = await db.select().from(machines).where(eq(machines.id, machineId)).get();
77
-
78
- if (!machine) {
79
- throw new Error(`Machine not found: ${machineId}`);
80
- }
81
-
82
- // Add module ID if not already assigned
83
- const assignedIds = machine.assignedModuleIds || [];
84
- if (!assignedIds.includes(moduleId)) {
85
- const updatedIds = [...assignedIds, moduleId];
86
- await db
87
- .update(machines)
88
- .set({ assignedModuleIds: updatedIds })
89
- .where(eq(machines.id, machineId))
90
- .run();
91
- log.success(`Machine ${machineId} updated with module assignment`);
92
- }
93
- }
62
+ // `updateMachineAssignment` is deleted (celilo#773).
63
+ //
64
+ // It was the sole writer of `machines.assigned_module_ids`, it only ever
65
+ // APPENDED, and nothing ever removed an entry — `module remove` deletes the
66
+ // module row and never touches `machines`. So a machine accumulated the ids of
67
+ // modules that no longer existed and could never be freed except by editing the
68
+ // database.
69
+ //
70
+ // Nothing replaces it. Occupancy is now derived from `module_infrastructure`
71
+ // and `module_systems` at the point of use (`getModulesOnMachine`), both of
72
+ // which the deploy path already writes and both of which cascade on module
73
+ // removal — so the machine frees itself with no bookkeeping step to forget.
94
74
 
95
75
  export interface DeployOptions {
96
76
  debug?: boolean;
@@ -1203,13 +1183,11 @@ async function deployModuleImpl(
1203
1183
  };
1204
1184
  }
1205
1185
 
1206
- if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
1207
- // Update machine's assigned_module_ids
1208
- await updateMachineAssignment(moduleId, plan.infrastructure.machineId, db);
1209
- } else if (
1210
- plan.infrastructure?.type === 'container_service' &&
1211
- plan.infrastructure.serviceId
1212
- ) {
1186
+ if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
1187
+ // Placeholder branch retained below; the machine branch is gone because
1188
+ // occupancy is no longer a stored fact to update (celilo#773).
1189
+ }
1190
+ if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
1213
1191
  // TODO: extract Terraform outputs and persist them on
1214
1192
  // module_infrastructure.containerMetadata. Until that lands,
1215
1193
  // the deploy still succeeds — we just don't track which
@@ -55,7 +55,6 @@ describe('a machine with no stored SSH key fails loudly, not silently', () => {
55
55
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 100 },
56
56
  role: 'host',
57
57
  interfaces: [],
58
- assignedModuleIds: [],
59
58
  });
60
59
 
61
60
  await expect(writeTemporarySshKey(machine.id)).rejects.toThrow(/has no SSH key stored/);
@@ -114,7 +113,6 @@ describe('ssh-key-manager', () => {
114
113
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
115
114
  role: 'host',
116
115
  interfaces: [],
117
- assignedModuleIds: [],
118
116
  });
119
117
 
120
118
  const keyPath = await writeTemporarySshKey(machine.id);
@@ -142,7 +140,6 @@ describe('ssh-key-manager', () => {
142
140
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
143
141
  role: 'host',
144
142
  interfaces: [],
145
- assignedModuleIds: [],
146
143
  });
147
144
 
148
145
  const keyPath = await writeTemporarySshKey(machine.id);
@@ -163,7 +160,6 @@ describe('ssh-key-manager', () => {
163
160
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
164
161
  role: 'host',
165
162
  interfaces: [],
166
- assignedModuleIds: [],
167
163
  });
168
164
 
169
165
  const keyPath = await writeTemporarySshKey(machine.id);
@@ -189,7 +185,6 @@ describe('ssh-key-manager', () => {
189
185
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
190
186
  role: 'host',
191
187
  interfaces: [],
192
- assignedModuleIds: [],
193
188
  });
194
189
 
195
190
  const machine2 = await addMachine({
@@ -201,7 +196,6 @@ describe('ssh-key-manager', () => {
201
196
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
202
197
  role: 'host',
203
198
  interfaces: [],
204
- assignedModuleIds: [],
205
199
  });
206
200
 
207
201
  const keyPath1 = await writeTemporarySshKey(machine1.id);
@@ -232,7 +226,6 @@ describe('ssh-key-manager', () => {
232
226
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
233
227
  role: 'host',
234
228
  interfaces: [],
235
- assignedModuleIds: [],
236
229
  });
237
230
 
238
231
  const managedKey = new ManagedSshKey(machine.id);
@@ -252,7 +245,6 @@ describe('ssh-key-manager', () => {
252
245
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
253
246
  role: 'host',
254
247
  interfaces: [],
255
- assignedModuleIds: [],
256
248
  });
257
249
 
258
250
  const managedKey = new ManagedSshKey(machine.id);
@@ -274,7 +266,6 @@ describe('ssh-key-manager', () => {
274
266
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
275
267
  role: 'host',
276
268
  interfaces: [],
277
- assignedModuleIds: [],
278
269
  });
279
270
 
280
271
  const managedKey = new ManagedSshKey(machine.id);
@@ -301,7 +292,6 @@ describe('ssh-key-manager', () => {
301
292
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
302
293
  role: 'host',
303
294
  interfaces: [],
304
- assignedModuleIds: [],
305
295
  });
306
296
 
307
297
  const managedKey = new ManagedSshKey(machine.id);
@@ -90,7 +90,17 @@ export interface Machine {
90
90
  hardware: MachineHardware;
91
91
  role: MachineRole;
92
92
  interfaces: NetworkInterface[];
93
- assignedModuleIds: string[];
93
+ // No `assignedModuleIds` (celilo#773). Occupancy is DERIVED — call
94
+ // `getModulesOnMachine(machineId)` in machine-pool.ts. It used to be a stored
95
+ // array with an append-only writer and no removal path, which diverged in
96
+ // both directions on the live fleet: a removed module's id stranded forever
97
+ // so placement rejected an empty box citing a module that no longer existed,
98
+ // and a machine genuinely hosting `iptables` reported "None (available)" so a
99
+ // second module could be placed on top of it.
100
+ //
101
+ // It is gone from the TYPE rather than merely unused, so nothing can read it
102
+ // back: leaving the field is what would let a new call site compile clean
103
+ // against a value nobody maintains (Rule 3.9).
94
104
  /** Module ID this machine is earmarked for, or null/undefined */
95
105
  earmarkedModule?: string | null;
96
106
  /**