@celilo/cli 0.26.0 → 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.
Files changed (41) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/package.json +3 -3
  4. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  5. package/src/ansible/dependencies.test.ts +233 -289
  6. package/src/ansible/dependencies.ts +151 -83
  7. package/src/cli/commands/alerts-sweep.ts +14 -3
  8. package/src/cli/commands/machine-add.ts +0 -1
  9. package/src/cli/commands/machine-list.ts +10 -4
  10. package/src/cli/commands/machine-remove.ts +13 -7
  11. package/src/cli/commands/machine-status.ts +9 -11
  12. package/src/db/schema.ts +6 -4
  13. package/src/hooks/capability-loader.ts +6 -0
  14. package/src/hooks/define-hook.test.ts +4 -0
  15. package/src/hooks/types.ts +2 -1
  16. package/src/infrastructure/property-extractor.test.ts +0 -2
  17. package/src/manifest/contracts/v1.ts +19 -0
  18. package/src/manifest/schema.ts +1 -0
  19. package/src/services/alerting/inbound.test.ts +66 -0
  20. package/src/services/alerting/inbound.ts +35 -2
  21. package/src/services/alerting/sweep-runner.test.ts +5 -1
  22. package/src/services/alerting/sweep-runner.ts +14 -8
  23. package/src/services/aspect-runner.test.ts +0 -1
  24. package/src/services/audit/machines-reachable.test.ts +67 -8
  25. package/src/services/audit/machines-reachable.ts +18 -4
  26. package/src/services/deployed-systems.ts +31 -0
  27. package/src/services/fleet-checks.test.ts +232 -0
  28. package/src/services/fleet-checks.ts +275 -3
  29. package/src/services/infrastructure-selector.test.ts +0 -7
  30. package/src/services/infrastructure-selector.ts +24 -25
  31. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  32. package/src/services/infrastructure-variable-resolver.ts +0 -3
  33. package/src/services/machine-pool.test.ts +53 -85
  34. package/src/services/machine-pool.ts +68 -84
  35. package/src/services/machine-probe.test.ts +3 -4
  36. package/src/services/machine-probe.ts +2 -3
  37. package/src/services/module-deploy.ts +17 -39
  38. package/src/services/module-operations.test.ts +72 -1
  39. package/src/services/module-operations.ts +49 -10
  40. package/src/services/ssh-key-manager.test.ts +0 -10
  41. package/src/types/infrastructure.ts +11 -1
@@ -71,6 +71,70 @@ describe('module-operations', () => {
71
71
  });
72
72
  });
73
73
 
74
+ /**
75
+ * celilo#737. Recording an outcome is BOOKKEEPING; the caller's error is the
76
+ * information. On celilo-mgr a `SQLITE_BUSY` inside `failOperation` propagated
77
+ * out of the catch block that called it and REPLACED the deploy's own error,
78
+ * so the operator was shown a database-locking problem and never learned what
79
+ * the deploy did wrong — the original was destroyed and is unrecoverable.
80
+ *
81
+ * `breakOperationsTable` stands in for any write failure. The mechanism does
82
+ * not matter; what matters is that no failure of the write can reach the
83
+ * caller.
84
+ */
85
+ describe('recording an outcome cannot destroy what it records (#737)', () => {
86
+ function breakOperationsTable(): void {
87
+ const { getDb } = require('../db/client');
88
+ getDb().$client.run('DROP TABLE module_operations');
89
+ }
90
+
91
+ it('failOperation does not replace the error it was called to record', () => {
92
+ const id = startOperation('caddy', 'deploy');
93
+ breakOperationsTable();
94
+
95
+ const original = new Error('ansible task failed on step 7');
96
+ let surfaced: unknown;
97
+
98
+ // Exactly the shape every call site uses (module-deploy.ts,
99
+ // module-remove.ts): record the failure, then rethrow the original.
100
+ try {
101
+ try {
102
+ throw original;
103
+ } catch (err) {
104
+ failOperation(id, err);
105
+ throw err;
106
+ }
107
+ } catch (err) {
108
+ surfaced = err;
109
+ }
110
+
111
+ expect(surfaced).toBe(original);
112
+ });
113
+
114
+ it('failOperation does not throw when the write fails', () => {
115
+ const id = startOperation('caddy', 'deploy');
116
+ breakOperationsTable();
117
+ expect(() => failOperation(id, new Error('original'))).not.toThrow();
118
+ });
119
+
120
+ it('completeOperation does not throw when the write fails', () => {
121
+ // The mirror bug: a deploy that SUCCEEDED reporting a database error, and
122
+ // skipping the `emitDeployCompleted` that follows the call.
123
+ const id = startOperation('caddy', 'deploy');
124
+ breakOperationsTable();
125
+ expect(() => completeOperation(id)).not.toThrow();
126
+ });
127
+
128
+ it('startOperation still throws — its row IS the in-flight lock', () => {
129
+ // Deliberately NOT swallowed. `checkInFlight` reads this row to refuse a
130
+ // backup during a deploy, so a silently-missing row would let the two run
131
+ // together. Failing before any work happens is honest; failing after it
132
+ // is what #737 is about.
133
+ breakOperationsTable();
134
+ expect(() => startOperation('caddy', 'deploy')).toThrow();
135
+ });
136
+ });
137
+
74
138
  describe('checkInFlight', () => {
75
139
  it('returns empty when no operations are in flight', () => {
76
140
  expect(checkInFlight()).toHaveLength(0);
@@ -96,7 +160,14 @@ describe('module-operations', () => {
96
160
 
97
161
  it('ignores rows whose pid is no longer alive', () => {
98
162
  // Spawn a short-lived process, capture its pid, wait for it to exit.
99
- const child = spawnSync('node', ['-e', 'process.exit(0)']);
163
+ //
164
+ // `process.execPath` (the bun binary running this suite), NOT a bare
165
+ // `node`: this repo is bun-based and nothing guarantees a node on PATH.
166
+ // Where there was none, spawnSync returned `pid: undefined` and the
167
+ // assertion below failed with "Expected and actual values must be numbers
168
+ // or bigints" — which reads as a broken pid check rather than a missing
169
+ // interpreter.
170
+ const child = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
100
171
  const deadPid = child.pid;
101
172
  expect(deadPid).toBeGreaterThan(0);
102
173
  expect(isPidRunnable(deadPid)).toBe(false);
@@ -33,6 +33,7 @@
33
33
  import { spawnSync } from 'node:child_process';
34
34
  import { randomUUID } from 'node:crypto';
35
35
  import { eq } from 'drizzle-orm';
36
+ import { log } from '../cli/prompts';
36
37
  import { getDb } from '../db/client';
37
38
  import { type ModuleOperation, type ModuleOperationKind, moduleOperations } from '../db/schema';
38
39
 
@@ -55,21 +56,59 @@ export function startOperation(moduleId: string, operation: ModuleOperationKind)
55
56
  return id;
56
57
  }
57
58
 
59
+ /**
60
+ * Write an operation's outcome without ever being able to break the flow that
61
+ * is reporting it (celilo#737).
62
+ *
63
+ * Recording an outcome is BOOKKEEPING; the caller's error is the information.
64
+ * On celilo-mgr a `SQLITE_BUSY` inside `failOperation` propagated out of the
65
+ * catch block that called it and REPLACED the deploy's own error, so the
66
+ * operator was shown a database-locking problem and never learned what the
67
+ * deploy actually did wrong. That error was destroyed and is unrecoverable —
68
+ * the whole cost of the bug. It also skipped the `emitDeployFailed` that
69
+ * follows the call, so the event bus never learned the deploy had failed at
70
+ * all, and the module was left `INSTALLED` while in fact verified.
71
+ *
72
+ * `getDb()` is inside the try on purpose: opening the database is one of the
73
+ * things that can throw here.
74
+ *
75
+ * ⚠️ `startOperation` deliberately does NOT get this treatment. Its row IS the
76
+ * in-flight lock `checkInFlight` reads to refuse a backup during a deploy, so a
77
+ * silently-missing row would let the two run together against the same module.
78
+ * Failing before any work happens is honest; failing after it is what #737 is
79
+ * about.
80
+ */
81
+ function recordOutcome(outcome: 'completed' | 'failed', operationId: string, write: () => void) {
82
+ try {
83
+ write();
84
+ } catch (persistError) {
85
+ // Rule 6.2: never a bare catch. Secondary to whatever the caller is already
86
+ // reporting, so it is a warning rather than the headline — the caller's own
87
+ // error is what the operator needs to read.
88
+ const reason = persistError instanceof Error ? persistError.message : String(persistError);
89
+ log.warn(`Could not record operation ${operationId} as ${outcome}: ${reason}`);
90
+ }
91
+ }
92
+
58
93
  export function completeOperation(operationId: string): void {
59
- const db = getDb();
60
- db.update(moduleOperations)
61
- .set({ status: 'completed', completedAt: new Date() })
62
- .where(eq(moduleOperations.id, operationId))
63
- .run();
94
+ recordOutcome('completed', operationId, () => {
95
+ getDb()
96
+ .update(moduleOperations)
97
+ .set({ status: 'completed', completedAt: new Date() })
98
+ .where(eq(moduleOperations.id, operationId))
99
+ .run();
100
+ });
64
101
  }
65
102
 
66
103
  export function failOperation(operationId: string, error: unknown): void {
67
- const db = getDb();
68
104
  const message = error instanceof Error ? error.message : String(error);
69
- db.update(moduleOperations)
70
- .set({ status: 'failed', completedAt: new Date(), errorMessage: message })
71
- .where(eq(moduleOperations.id, operationId))
72
- .run();
105
+ recordOutcome('failed', operationId, () => {
106
+ getDb()
107
+ .update(moduleOperations)
108
+ .set({ status: 'failed', completedAt: new Date(), errorMessage: message })
109
+ .where(eq(moduleOperations.id, operationId))
110
+ .run();
111
+ });
73
112
  }
74
113
 
75
114
  /**
@@ -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
  /**