@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/db/schema.ts +6 -4
- package/src/hooks/capability-loader.ts +6 -0
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +0 -2
- package/src/manifest/contracts/v1.ts +19 -0
- package/src/manifest/schema.ts +1 -0
- package/src/services/alerting/inbound.test.ts +66 -0
- package/src/services/alerting/inbound.ts +35 -2
- package/src/services/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- package/src/services/audit/machines-reachable.test.ts +67 -8
- package/src/services/audit/machines-reachable.ts +18 -4
- package/src/services/deployed-systems.ts +31 -0
- package/src/services/fleet-checks.test.ts +232 -0
- package/src/services/fleet-checks.ts +275 -3
- package/src/services/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/machine-probe.test.ts +3 -4
- package/src/services/machine-probe.ts +2 -3
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-operations.test.ts +72 -1
- package/src/services/module-operations.ts +49 -10
- package/src/services/ssh-key-manager.test.ts +0 -10
- 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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
/**
|