@celilo/cli 0.18.0 → 0.20.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_SUBSYSTEMS.md +4 -2
- package/package.json +4 -4
- package/src/api/remote-client.test.ts +86 -2
- package/src/api/serve.ts +242 -38
- package/src/api/sessions.test.ts +196 -0
- package/src/api/sessions.ts +278 -0
- package/src/cli/commands/apt-upgrade.test.ts +20 -1
- package/src/cli/commands/apt-upgrade.ts +12 -2
- package/src/cli/commands/backup-sweep.ts +25 -9
- package/src/cli/commands/events.ts +150 -4
- package/src/cli/commands/module-update.test.ts +72 -1
- package/src/cli/commands/module-update.ts +68 -22
- package/src/cli/commands/system-migrate.test.ts +56 -0
- package/src/cli/commands/system-migrate.ts +52 -4
- package/src/cli/completion.ts +2 -0
- package/src/cli/index.ts +27 -3
- package/src/db/migration-status.test.ts +114 -0
- package/src/db/migration-status.ts +78 -0
- package/src/db/schema-introspection.ts +8 -1
- package/src/services/backup-metadata.ts +17 -0
- package/src/services/backup-staging.test.ts +98 -0
- package/src/services/backup-staging.ts +73 -1
- package/src/services/backup-sweep.test.ts +15 -0
- package/src/services/backup-sweep.ts +17 -1
- package/src/services/bus-interview-park.test.ts +179 -0
- package/src/services/bus-interview.ts +17 -6
- package/src/services/events-daemon.test.ts +244 -0
- package/src/services/events-daemon.ts +295 -8
- package/src/services/fleet-checks.test.ts +75 -4
- package/src/services/fleet-checks.ts +82 -12
- package/src/services/interview-errors.ts +37 -0
- package/src/services/remote-responder.test.ts +83 -0
- package/src/services/remote-responder.ts +31 -10
- package/src/services/responder-probe.ts +3 -1
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* No timeouts: the deploy waits indefinitely for a responder. If
|
|
9
9
|
* nothing answers, the operator sees the unanswered query via
|
|
10
|
-
* `celilo events list-
|
|
10
|
+
* `celilo events list-unanswered` and answers it with `celilo events
|
|
11
|
+
* reply <id> <value>`. (NOT `events list-pending` — that reads the
|
|
12
|
+
* subscriber `deliveries` table and cannot see an unanswered query.)
|
|
11
13
|
*
|
|
12
14
|
* See `infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md`.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
16
18
|
import { getEventBusPath } from '../config/paths';
|
|
19
|
+
import { InterviewAbandonedError } from './interview-errors';
|
|
17
20
|
import { ensureResponderForInterview } from './responder-probe';
|
|
18
21
|
|
|
19
22
|
const NO_SCHEMAS = defineEvents({});
|
|
@@ -239,6 +242,16 @@ export interface InterviewRequiredPayload {
|
|
|
239
242
|
*/
|
|
240
243
|
export interface InterviewReply {
|
|
241
244
|
value: unknown;
|
|
245
|
+
/**
|
|
246
|
+
* Set instead of `value` when the question was reaped rather than decided —
|
|
247
|
+
* a parked session passed its TTL with nobody answering. `askInterview` turns
|
|
248
|
+
* it into an `InterviewAbandonedError`, which is distinct from both a decline
|
|
249
|
+
* (someone said no) and an unanswered question (still standing).
|
|
250
|
+
*
|
|
251
|
+
* A responder that merely *cannot* decide emits nothing at all: the query
|
|
252
|
+
* stays unanswered and the asking command stays parked (celilo#609).
|
|
253
|
+
*/
|
|
254
|
+
abandoned?: { reason: string };
|
|
242
255
|
}
|
|
243
256
|
|
|
244
257
|
/**
|
|
@@ -314,11 +327,9 @@ export async function askInterview(
|
|
|
314
327
|
payload: InterviewRequiredPayload,
|
|
315
328
|
ownerBus?: Bus,
|
|
316
329
|
): Promise<unknown> {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
ownerBus,
|
|
321
|
-
);
|
|
330
|
+
const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key);
|
|
331
|
+
const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus);
|
|
332
|
+
if (reply.abandoned) throw new InterviewAbandonedError(type, reply.abandoned.reason);
|
|
322
333
|
return reply.value;
|
|
323
334
|
}
|
|
324
335
|
|
|
@@ -5,11 +5,17 @@ import { join } from 'node:path';
|
|
|
5
5
|
import {
|
|
6
6
|
getDaemonUnitPath,
|
|
7
7
|
installDaemon,
|
|
8
|
+
orphanDispatcherPids,
|
|
9
|
+
planDaemonInstall,
|
|
8
10
|
readInstalledUnit,
|
|
9
11
|
renderLaunchdPlist,
|
|
10
12
|
renderSystemdUnit,
|
|
13
|
+
resolveRestartScope,
|
|
11
14
|
resolveRunAsUser,
|
|
15
|
+
restartDaemon,
|
|
16
|
+
supervisorCommands,
|
|
12
17
|
uninstallDaemon,
|
|
18
|
+
unitInstalledInAnyScope,
|
|
13
19
|
} from './events-daemon';
|
|
14
20
|
|
|
15
21
|
describe('renderSystemdUnit', () => {
|
|
@@ -166,6 +172,61 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
|
166
172
|
expect(existsSync(installed.unitPath)).toBe(false);
|
|
167
173
|
});
|
|
168
174
|
|
|
175
|
+
// #610 — install-daemon defaults to USER scope, so running it on a box whose
|
|
176
|
+
// system unit Ansible already installed silently produced a second daemon of
|
|
177
|
+
// the same name. That is how celilo-mgr ended up with two dispatchers.
|
|
178
|
+
it('refuses to install when the other scope already has a unit', () => {
|
|
179
|
+
const home = join(dir, 'home');
|
|
180
|
+
const systemRoot = join(dir, 'root');
|
|
181
|
+
installDaemon({
|
|
182
|
+
platform: 'linux',
|
|
183
|
+
scope: 'system',
|
|
184
|
+
home,
|
|
185
|
+
systemRoot,
|
|
186
|
+
celiloPath,
|
|
187
|
+
busDbPath: '/var/lib/celilo/events.db',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(() =>
|
|
191
|
+
installDaemon({
|
|
192
|
+
platform: 'linux',
|
|
193
|
+
scope: 'user',
|
|
194
|
+
home,
|
|
195
|
+
systemRoot,
|
|
196
|
+
celiloPath,
|
|
197
|
+
busDbPath: '/var/lib/celilo/events.db',
|
|
198
|
+
}),
|
|
199
|
+
).toThrow(/system-scope unit is already installed/);
|
|
200
|
+
// Nothing written: refusing must not leave the second unit behind.
|
|
201
|
+
expect(existsSync(getDaemonUnitPath('linux', home, 'user'))).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --print creates nothing, and the celilo-mgmt Ansible role captures it —
|
|
205
|
+
// throwing there would wedge the deploy of the tool used to fix the conflict.
|
|
206
|
+
it('reports the conflict from planDaemonInstall without throwing', () => {
|
|
207
|
+
const home = join(dir, 'home');
|
|
208
|
+
const systemRoot = join(dir, 'root');
|
|
209
|
+
installDaemon({
|
|
210
|
+
platform: 'linux',
|
|
211
|
+
scope: 'system',
|
|
212
|
+
home,
|
|
213
|
+
systemRoot,
|
|
214
|
+
celiloPath,
|
|
215
|
+
busDbPath: '/var/lib/celilo/events.db',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const plan = planDaemonInstall({
|
|
219
|
+
platform: 'linux',
|
|
220
|
+
scope: 'user',
|
|
221
|
+
home,
|
|
222
|
+
systemRoot,
|
|
223
|
+
celiloPath,
|
|
224
|
+
busDbPath: '/var/lib/celilo/events.db',
|
|
225
|
+
});
|
|
226
|
+
expect(plan.conflict?.scope).toBe('system');
|
|
227
|
+
expect(plan.unitContent).toContain('ExecStart=');
|
|
228
|
+
});
|
|
229
|
+
|
|
169
230
|
it('writes a launchd plist and uninstall removes it', () => {
|
|
170
231
|
const home = join(dir, 'home');
|
|
171
232
|
const installed = installDaemon({
|
|
@@ -241,3 +302,186 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
|
241
302
|
).toThrow(/does not exist/);
|
|
242
303
|
});
|
|
243
304
|
});
|
|
305
|
+
|
|
306
|
+
// --- restart (celilo#604) ---------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A fake bus + supervisor. `restartUnit()` only produces a new dispatcher if no
|
|
310
|
+
* live one is left — that IS the one-dispatcher-per-bus guard (#584), and it's
|
|
311
|
+
* what makes the orphan case fatal rather than merely untidy.
|
|
312
|
+
*/
|
|
313
|
+
function fakeFleet(opts: {
|
|
314
|
+
initial: Array<{ pid: number; version: string | null; supervised?: boolean }>;
|
|
315
|
+
newVersion: string;
|
|
316
|
+
}) {
|
|
317
|
+
let live = opts.initial.map((d) => ({ ...d }));
|
|
318
|
+
const supervised = opts.initial.find((d) => d.supervised);
|
|
319
|
+
const killed: number[] = [];
|
|
320
|
+
let nextPid = 9000;
|
|
321
|
+
let restarts = 0;
|
|
322
|
+
return {
|
|
323
|
+
killed,
|
|
324
|
+
restarts: () => restarts,
|
|
325
|
+
deps: {
|
|
326
|
+
liveDispatchers: () => live.map((d) => ({ pid: d.pid, version: d.version })),
|
|
327
|
+
supervisorPid: () => supervised?.pid ?? null,
|
|
328
|
+
kill: (pid: number) => {
|
|
329
|
+
killed.push(pid);
|
|
330
|
+
live = live.filter((d) => d.pid !== pid);
|
|
331
|
+
},
|
|
332
|
+
restartUnit: () => {
|
|
333
|
+
restarts++;
|
|
334
|
+
live = live.filter((d) => d.pid !== supervised?.pid);
|
|
335
|
+
if (live.length > 0) return; // guard refuses: another dispatcher is live
|
|
336
|
+
live = [{ pid: nextPid++, version: opts.newVersion }];
|
|
337
|
+
},
|
|
338
|
+
sleep: async () => {},
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
describe('orphanDispatcherPids', () => {
|
|
344
|
+
it('names every live dispatcher the supervisor does not own', () => {
|
|
345
|
+
expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], 200)).toEqual([100]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('treats all of them as orphans when the supervisor owns none', () => {
|
|
349
|
+
expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], null)).toEqual([100, 200]);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('restartDaemon', () => {
|
|
354
|
+
let dir: string;
|
|
355
|
+
let home: string;
|
|
356
|
+
|
|
357
|
+
beforeEach(() => {
|
|
358
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-restart-'));
|
|
359
|
+
home = join(dir, 'home');
|
|
360
|
+
installDaemon({ platform: 'linux', home, celiloPath: '/bin/sh', busDbPath: '/db' });
|
|
361
|
+
});
|
|
362
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
363
|
+
|
|
364
|
+
it('stops an orphan the supervisor does not own, then brings up new code', async () => {
|
|
365
|
+
// celilo-mgr exactly: PPID 1, stale v0.1.8, systemd owns nothing.
|
|
366
|
+
const fleet = fakeFleet({
|
|
367
|
+
initial: [{ pid: 3639051, version: '0.1.8' }],
|
|
368
|
+
newVersion: '0.2.0',
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const result = await restartDaemon(
|
|
372
|
+
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 },
|
|
373
|
+
fleet.deps,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(fleet.killed).toContain(3639051);
|
|
377
|
+
expect(result.orphansKilled).toEqual([3639051]);
|
|
378
|
+
expect(result.dispatcher.version).toBe('0.2.0');
|
|
379
|
+
expect(result.dispatcher.pid).not.toBe(3639051);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('does not kill the dispatcher the supervisor already owns', async () => {
|
|
383
|
+
const fleet = fakeFleet({
|
|
384
|
+
initial: [{ pid: 4242, version: '0.1.8', supervised: true }],
|
|
385
|
+
newVersion: '0.2.0',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const result = await restartDaemon(
|
|
389
|
+
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 },
|
|
390
|
+
fleet.deps,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
expect(fleet.killed).toEqual([]);
|
|
394
|
+
expect(result.orphansKilled).toEqual([]);
|
|
395
|
+
expect(result.dispatcher.version).toBe('0.2.0');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('fails when the restarted dispatcher still reports the OLD version', async () => {
|
|
399
|
+
// systemctl returned 0, a dispatcher is live — and it is the stale code.
|
|
400
|
+
// The whole point: never report success off the supervisor's exit code.
|
|
401
|
+
const fleet = fakeFleet({
|
|
402
|
+
initial: [{ pid: 3639051, version: '0.1.8' }],
|
|
403
|
+
newVersion: '0.1.8',
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await expect(
|
|
407
|
+
restartDaemon(
|
|
408
|
+
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 },
|
|
409
|
+
fleet.deps,
|
|
410
|
+
),
|
|
411
|
+
).rejects.toThrow(/no dispatcher on v0\.2\.0 came up/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('fails rather than reporting success when nothing comes back at all', async () => {
|
|
415
|
+
const fleet = fakeFleet({ initial: [], newVersion: '0.2.0' });
|
|
416
|
+
fleet.deps.restartUnit = () => {}; // unit crash-loops; bus stays empty
|
|
417
|
+
|
|
418
|
+
await expect(
|
|
419
|
+
restartDaemon(
|
|
420
|
+
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 },
|
|
421
|
+
fleet.deps,
|
|
422
|
+
),
|
|
423
|
+
).rejects.toThrow(/no dispatcher is live on the bus/);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('refuses when no supervisor unit is installed', () => {
|
|
427
|
+
expect(() => resolveRestartScope({ platform: 'linux', home: join(dir, 'empty') })).toThrow(
|
|
428
|
+
/no supervisor unit installed/,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// apt-upgrade runs restart-daemon on every box, including ones that never
|
|
433
|
+
// installed the daemon. The CLI branches on this so an upgrade with nothing
|
|
434
|
+
// stale to fix is not failed by a missing unit.
|
|
435
|
+
it('unitInstalledInAnyScope sees an installed unit, and its absence', () => {
|
|
436
|
+
expect(unitInstalledInAnyScope('linux', home)).toBe(true);
|
|
437
|
+
expect(unitInstalledInAnyScope('linux', join(dir, 'empty'))).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe('supervisorCommands', () => {
|
|
442
|
+
it('uses systemctl --user for user scope', () => {
|
|
443
|
+
expect(supervisorCommands('linux', 'user').restart).toEqual([
|
|
444
|
+
'systemctl',
|
|
445
|
+
'--user',
|
|
446
|
+
'restart',
|
|
447
|
+
'celilo-events.service',
|
|
448
|
+
]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// The system unit is root-owned and celilo is unprivileged. Drop the sudo
|
|
452
|
+
// and every apt-upgrade on celilo-mgr reports "dispatcher still on old
|
|
453
|
+
// code" — honest, and never able to do its job. celilo-bootstrap ships the
|
|
454
|
+
// scoped grant for exactly these two argvs, so they must match it verbatim.
|
|
455
|
+
it('goes through sudo for system scope, matching the shipped sudoers grant', () => {
|
|
456
|
+
expect(supervisorCommands('linux', 'system').restart).toEqual([
|
|
457
|
+
'sudo',
|
|
458
|
+
'systemctl',
|
|
459
|
+
'restart',
|
|
460
|
+
'celilo-events.service',
|
|
461
|
+
]);
|
|
462
|
+
expect(supervisorCommands('linux', 'system').mainPid).toEqual([
|
|
463
|
+
'sudo',
|
|
464
|
+
'systemctl',
|
|
465
|
+
'show',
|
|
466
|
+
'celilo-events.service',
|
|
467
|
+
'-p',
|
|
468
|
+
'MainPID',
|
|
469
|
+
'--value',
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('the shipped sudoers grant covers exactly the argvs used', () => {
|
|
474
|
+
const grant = readFileSync(
|
|
475
|
+
join(
|
|
476
|
+
import.meta.dir,
|
|
477
|
+
'../../../../packaging/celilo-bootstrap/conffiles/sudoers.d-celilo-events-restart',
|
|
478
|
+
),
|
|
479
|
+
'utf-8',
|
|
480
|
+
);
|
|
481
|
+
const cmds = supervisorCommands('linux', 'system');
|
|
482
|
+
for (const argv of [cmds.restart, cmds.mainPid as string[]]) {
|
|
483
|
+
// `sudo` itself is the invoker, not part of the granted command.
|
|
484
|
+
expect(grant).toContain(`/usr/bin/${argv.slice(1).join(' ')}`);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
});
|
|
@@ -50,6 +50,8 @@ export interface InstallDaemonOptions {
|
|
|
50
50
|
busDbPath?: string;
|
|
51
51
|
/** Override the home directory used to compute install paths. */
|
|
52
52
|
home?: string;
|
|
53
|
+
/** Prefix for system-scope paths. Test seam — see getDaemonUnitPath. */
|
|
54
|
+
systemRoot?: string;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface InstallDaemonResult {
|
|
@@ -61,9 +63,53 @@ export interface InstallDaemonResult {
|
|
|
61
63
|
busDbPath: string;
|
|
62
64
|
/** Explicit run-as user for system scope; undefined for user scope. */
|
|
63
65
|
runAsUser?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Set when the OTHER scope already has a unit installed. Both scopes use the
|
|
68
|
+
* same unit name, so installing over that produces two same-named daemons on
|
|
69
|
+
* one bus — see conflictingScopeError.
|
|
70
|
+
*/
|
|
71
|
+
conflict?: { scope: SupervisorScope; unitPath: string };
|
|
64
72
|
nextSteps: string[];
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
export const SUPERVISOR_SCOPES: readonly SupervisorScope[] = ['user', 'system'];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Both scopes name the unit `celilo-events.service`, so `systemctl status
|
|
79
|
+
* celilo-events.service` and `systemctl --user status celilo-events.service`
|
|
80
|
+
* are different services that look identical in every operator-facing string.
|
|
81
|
+
*
|
|
82
|
+
* That is how celilo-mgr came to run two dispatchers for 40 days (#580, #610):
|
|
83
|
+
* Ansible installed the system unit, someone later ran `celilo events
|
|
84
|
+
* install-daemon` — which defaults to USER scope — and got a second daemon with
|
|
85
|
+
* no warning. `systemctl status celilo-events.service` then reported the dead
|
|
86
|
+
* system unit while the user-scope one served production.
|
|
87
|
+
*/
|
|
88
|
+
function conflictingScopeError(conflict: { scope: SupervisorScope; unitPath: string }): Error {
|
|
89
|
+
const flag = conflict.scope === 'system' ? ' --system' : '';
|
|
90
|
+
return new Error(
|
|
91
|
+
[
|
|
92
|
+
`celilo events daemon: a ${conflict.scope}-scope unit is already installed at ${conflict.unitPath}.`,
|
|
93
|
+
'Both scopes use the same unit name, so installing this one would create a SECOND dispatcher',
|
|
94
|
+
'on the same bus that `systemctl status` cannot distinguish.',
|
|
95
|
+
`Remove the other first: \`celilo events uninstall-daemon${flag}\` (and disable it in systemd),`,
|
|
96
|
+
'or keep the existing one.',
|
|
97
|
+
].join(' '),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The other scope's unit, if it exists. */
|
|
102
|
+
function findConflictingScope(
|
|
103
|
+
platform: SupervisorPlatform,
|
|
104
|
+
home: string,
|
|
105
|
+
scope: SupervisorScope,
|
|
106
|
+
systemRoot?: string,
|
|
107
|
+
): { scope: SupervisorScope; unitPath: string } | undefined {
|
|
108
|
+
const other = scope === 'user' ? 'system' : 'user';
|
|
109
|
+
const found = readInstalledUnit({ platform, scope: other, home, systemRoot });
|
|
110
|
+
return found.exists ? { scope: other, unitPath: found.path } : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
67
113
|
export interface UninstallDaemonResult {
|
|
68
114
|
platform: SupervisorPlatform;
|
|
69
115
|
scope: SupervisorScope;
|
|
@@ -90,20 +136,26 @@ export function detectPlatform(): SupervisorPlatform {
|
|
|
90
136
|
|
|
91
137
|
/**
|
|
92
138
|
* Resolve where to write the unit file for a given platform + scope.
|
|
93
|
-
* `home` only matters for user scope
|
|
139
|
+
* `home` only matters for user scope.
|
|
140
|
+
*
|
|
141
|
+
* `systemRoot` prefixes the system-scope path. It exists so a test can have
|
|
142
|
+
* BOTH scopes installed at once without writing to the real /etc — the
|
|
143
|
+
* two-units-one-name state that produced #610 is otherwise untestable, since
|
|
144
|
+
* `home` alone can only ever relocate the user unit.
|
|
94
145
|
*/
|
|
95
146
|
export function getDaemonUnitPath(
|
|
96
147
|
platform: SupervisorPlatform,
|
|
97
148
|
home: string,
|
|
98
149
|
scope: SupervisorScope = 'user',
|
|
150
|
+
systemRoot = '/',
|
|
99
151
|
): string {
|
|
100
152
|
if (platform === 'linux') {
|
|
101
153
|
return scope === 'system'
|
|
102
|
-
? join('
|
|
154
|
+
? join(systemRoot, 'etc/systemd/system', SYSTEMD_UNIT_NAME)
|
|
103
155
|
: join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
|
|
104
156
|
}
|
|
105
157
|
return scope === 'system'
|
|
106
|
-
? join('
|
|
158
|
+
? join(systemRoot, 'Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`)
|
|
107
159
|
: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
108
160
|
}
|
|
109
161
|
|
|
@@ -286,7 +338,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
|
|
|
286
338
|
const concurrency = opts.concurrency ?? 4;
|
|
287
339
|
const runAsUser = scope === 'system' ? resolveRunAsUser(busDbPath, opts.runAsUser) : undefined;
|
|
288
340
|
|
|
289
|
-
const unitPath = getDaemonUnitPath(platform, home, scope);
|
|
341
|
+
const unitPath = getDaemonUnitPath(platform, home, scope, opts.systemRoot);
|
|
290
342
|
const unitInputs: UnitInputs = {
|
|
291
343
|
celiloPath,
|
|
292
344
|
busDbPath,
|
|
@@ -307,6 +359,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
|
|
|
307
359
|
celiloPath,
|
|
308
360
|
busDbPath,
|
|
309
361
|
runAsUser,
|
|
362
|
+
conflict: findConflictingScope(platform, home, scope, opts.systemRoot),
|
|
310
363
|
nextSteps: nextStepsFor(platform, scope, unitPath),
|
|
311
364
|
};
|
|
312
365
|
}
|
|
@@ -320,18 +373,27 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
|
|
|
320
373
|
*/
|
|
321
374
|
export function installDaemon(opts: InstallDaemonOptions = {}): InstallDaemonResult {
|
|
322
375
|
const plan = planDaemonInstall(opts);
|
|
376
|
+
// Refuse on the WRITE path only. `--print` renders without creating anything,
|
|
377
|
+
// and the celilo-mgmt Ansible role captures it — throwing there would wedge
|
|
378
|
+
// the deploy of the very tool an operator needs to clean the conflict up.
|
|
379
|
+
if (plan.conflict) throw conflictingScopeError(plan.conflict);
|
|
323
380
|
mkdirSync(dirname(plan.unitPath), { recursive: true });
|
|
324
381
|
writeFileSync(plan.unitPath, plan.unitContent, { mode: 0o644 });
|
|
325
382
|
return plan;
|
|
326
383
|
}
|
|
327
384
|
|
|
328
385
|
export function uninstallDaemon(
|
|
329
|
-
opts: {
|
|
386
|
+
opts: {
|
|
387
|
+
platform?: SupervisorPlatform;
|
|
388
|
+
scope?: SupervisorScope;
|
|
389
|
+
home?: string;
|
|
390
|
+
systemRoot?: string;
|
|
391
|
+
} = {},
|
|
330
392
|
): UninstallDaemonResult {
|
|
331
393
|
const platform = opts.platform ?? detectPlatform();
|
|
332
394
|
const scope = opts.scope ?? 'user';
|
|
333
395
|
const home = opts.home ?? homedir();
|
|
334
|
-
const unitPath = getDaemonUnitPath(platform, home, scope);
|
|
396
|
+
const unitPath = getDaemonUnitPath(platform, home, scope, opts.systemRoot);
|
|
335
397
|
|
|
336
398
|
let removed = false;
|
|
337
399
|
if (existsSync(unitPath)) {
|
|
@@ -366,13 +428,238 @@ export function uninstallDaemon(
|
|
|
366
428
|
return { platform, scope, unitPath, removed, nextSteps };
|
|
367
429
|
}
|
|
368
430
|
|
|
431
|
+
// --- restart (celilo#604) ---------------------------------------------
|
|
432
|
+
//
|
|
433
|
+
// `install-daemon` deliberately never touches supervisor state, so the only
|
|
434
|
+
// supported restart was SSH + systemctl — which the "operate celilo through
|
|
435
|
+
// celilo" model forbids. Restart is the one supervisor verb that has to be a
|
|
436
|
+
// celilo command, because the upgrade path needs it.
|
|
437
|
+
//
|
|
438
|
+
// The trap: on celilo-mgr the running dispatcher was an ORPHAN (PPID 1, not the
|
|
439
|
+
// unit's child). `systemctl restart` could not kill it, the unit crash-looped
|
|
440
|
+
// against the one-dispatcher-per-bus guard (#584), and systemctl reported
|
|
441
|
+
// `activating` while the OLD code kept serving. So a restart that shells out and
|
|
442
|
+
// returns is worthless: it must reconcile the orphan and then verify the bus.
|
|
443
|
+
|
|
444
|
+
export interface RestartDaemonResult {
|
|
445
|
+
platform: SupervisorPlatform;
|
|
446
|
+
scope: SupervisorScope;
|
|
447
|
+
unitPath: string;
|
|
448
|
+
/** Live dispatchers stopped because the supervisor did not own them. */
|
|
449
|
+
orphansKilled: number[];
|
|
450
|
+
/** Version the restarted dispatcher must report (installed @celilo/event-bus). */
|
|
451
|
+
expectedVersion: string;
|
|
452
|
+
/** The dispatcher now serving the bus. */
|
|
453
|
+
dispatcher: { pid: number; version: string | null };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface RestartDaemonDeps {
|
|
457
|
+
/** Live dispatchers on the bus, by pid + the code version each reports. */
|
|
458
|
+
liveDispatchers: () => Array<{ pid: number; version: string | null }>;
|
|
459
|
+
/** The pid the supervisor owns for the unit, or null if it owns none. */
|
|
460
|
+
supervisorPid: () => number | null;
|
|
461
|
+
/** Stop a process the supervisor does not own. */
|
|
462
|
+
kill: (pid: number, signal: NodeJS.Signals) => void;
|
|
463
|
+
/** Ask the supervisor to (re)start the unit. Throws on failure. */
|
|
464
|
+
restartUnit: () => void;
|
|
465
|
+
sleep: (ms: number) => Promise<void>;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface RestartDaemonOptions {
|
|
469
|
+
platform?: SupervisorPlatform;
|
|
470
|
+
scope?: SupervisorScope;
|
|
471
|
+
home?: string;
|
|
472
|
+
/** Version the restarted dispatcher must report before we call it a success. */
|
|
473
|
+
expectedVersion: string;
|
|
474
|
+
/** How long to wait for the new dispatcher to appear on the bus. */
|
|
475
|
+
timeoutMs?: number;
|
|
476
|
+
/** Poll interval while waiting. */
|
|
477
|
+
pollMs?: number;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Which live dispatchers block the unit from starting: every one whose pid the
|
|
482
|
+
* supervisor does not own. Pure — the orphan trap is decided here so it can be
|
|
483
|
+
* tested without a systemd.
|
|
484
|
+
*/
|
|
485
|
+
export function orphanDispatcherPids(
|
|
486
|
+
live: Array<{ pid: number }>,
|
|
487
|
+
supervisorPid: number | null,
|
|
488
|
+
): number[] {
|
|
489
|
+
return live.filter((d) => d.pid !== supervisorPid).map((d) => d.pid);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Pick the scope to act on: whichever scope has a unit installed. Explicit wins;
|
|
494
|
+
* with units in both scopes, system wins (the management-plane shape).
|
|
495
|
+
*/
|
|
496
|
+
/** Is a supervisor unit present in either scope? */
|
|
497
|
+
export function unitInstalledInAnyScope(platform?: SupervisorPlatform, home?: string): boolean {
|
|
498
|
+
return (['system', 'user'] as const).some(
|
|
499
|
+
(scope) => readInstalledUnit({ scope, home, platform }).exists,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function resolveRestartScope(
|
|
504
|
+
opts: { platform?: SupervisorPlatform; home?: string; scope?: SupervisorScope } = {},
|
|
505
|
+
): SupervisorScope {
|
|
506
|
+
if (opts.scope) return opts.scope;
|
|
507
|
+
const probe = (scope: SupervisorScope) =>
|
|
508
|
+
readInstalledUnit({ scope, home: opts.home, platform: opts.platform }).exists;
|
|
509
|
+
if (probe('system')) return 'system';
|
|
510
|
+
if (probe('user')) return 'user';
|
|
511
|
+
throw new Error(
|
|
512
|
+
'celilo events restart-daemon: no supervisor unit installed (user or system scope). Run `celilo events install-daemon` first.',
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Stop any unmanaged dispatcher, restart the unit, and confirm on the BUS — not
|
|
518
|
+
* from the supervisor's exit code — that a new process is live on the expected
|
|
519
|
+
* version. Anything less reports success into the crash loop described above.
|
|
520
|
+
*/
|
|
521
|
+
export async function restartDaemon(
|
|
522
|
+
opts: RestartDaemonOptions,
|
|
523
|
+
deps: RestartDaemonDeps,
|
|
524
|
+
): Promise<RestartDaemonResult> {
|
|
525
|
+
const platform = opts.platform ?? detectPlatform();
|
|
526
|
+
const scope = resolveRestartScope({ platform, home: opts.home, scope: opts.scope });
|
|
527
|
+
const unitPath = getDaemonUnitPath(platform, opts.home ?? homedir(), scope);
|
|
528
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
529
|
+
const pollMs = opts.pollMs ?? 1000;
|
|
530
|
+
|
|
531
|
+
const before = deps.liveDispatchers();
|
|
532
|
+
const orphans = orphanDispatcherPids(before, deps.supervisorPid());
|
|
533
|
+
for (const pid of orphans) {
|
|
534
|
+
deps.kill(pid, 'SIGTERM');
|
|
535
|
+
}
|
|
536
|
+
// Wait for the guard's view to clear before restarting, else the unit starts
|
|
537
|
+
// into `another dispatcher is already running` and auto-restart backoff eats
|
|
538
|
+
// the window. SIGKILL whatever refuses to go.
|
|
539
|
+
const graceUntil = Date.now() + 10_000;
|
|
540
|
+
while (orphans.length > 0 && Date.now() < graceUntil) {
|
|
541
|
+
if (deps.liveDispatchers().every((d) => !orphans.includes(d.pid))) break;
|
|
542
|
+
await deps.sleep(pollMs);
|
|
543
|
+
}
|
|
544
|
+
for (const pid of deps.liveDispatchers().map((d) => d.pid)) {
|
|
545
|
+
if (orphans.includes(pid)) deps.kill(pid, 'SIGKILL');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
deps.restartUnit();
|
|
549
|
+
|
|
550
|
+
const beforePids = new Set(before.map((d) => d.pid));
|
|
551
|
+
const deadline = Date.now() + timeoutMs;
|
|
552
|
+
let last: Array<{ pid: number; version: string | null }> = [];
|
|
553
|
+
while (Date.now() < deadline) {
|
|
554
|
+
last = deps.liveDispatchers();
|
|
555
|
+
const fresh = last.filter((d) => !beforePids.has(d.pid));
|
|
556
|
+
if (last.length === 1 && fresh.length === 1 && fresh[0]?.version === opts.expectedVersion) {
|
|
557
|
+
return {
|
|
558
|
+
platform,
|
|
559
|
+
scope,
|
|
560
|
+
unitPath,
|
|
561
|
+
orphansKilled: orphans,
|
|
562
|
+
expectedVersion: opts.expectedVersion,
|
|
563
|
+
dispatcher: fresh[0],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
await deps.sleep(pollMs);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const seen =
|
|
570
|
+
last.length === 0
|
|
571
|
+
? 'no dispatcher is live on the bus'
|
|
572
|
+
: `live: ${last.map((d) => `pid ${d.pid} (v${d.version ?? '?'})`).join(', ')}`;
|
|
573
|
+
throw new Error(
|
|
574
|
+
`celilo events restart-daemon: the supervisor was asked to restart ${unitPath}, but no dispatcher on v${opts.expectedVersion} came up within ${Math.round(timeoutMs / 1000)}s — ${seen}. The old code may still be serving. Check \`celilo events status\` and the unit's logs.`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* systemctl/launchctl argv for this platform+scope — `restart` and MainPID.
|
|
580
|
+
*
|
|
581
|
+
* System scope goes through `sudo`, and that is not optional on the box this
|
|
582
|
+
* exists for: the unit is root-owned and the command runs as the unprivileged
|
|
583
|
+
* `celilo` user (api-serve's child, the wrapper's sudo-drop target). Without
|
|
584
|
+
* sudo, every apt-upgrade on celilo-mgr would take the honest-but-useless
|
|
585
|
+
* branch — "packages upgraded, dispatcher still on old code" — forever.
|
|
586
|
+
* `celilo-bootstrap` ships the scoped grant (/etc/sudoers.d/celilo-events-restart)
|
|
587
|
+
* for exactly these two invocations. Same shape as `install-daemon`'s own
|
|
588
|
+
* printed next-steps, which already say `sudo systemctl` for system scope.
|
|
589
|
+
*/
|
|
590
|
+
export function supervisorCommands(
|
|
591
|
+
platform: SupervisorPlatform,
|
|
592
|
+
scope: SupervisorScope,
|
|
593
|
+
): { restart: string[]; mainPid: string[] | null } {
|
|
594
|
+
if (platform === 'linux') {
|
|
595
|
+
const sc = scope === 'system' ? ['sudo', 'systemctl'] : ['systemctl', '--user'];
|
|
596
|
+
return {
|
|
597
|
+
restart: [...sc, 'restart', SYSTEMD_UNIT_NAME],
|
|
598
|
+
mainPid: [...sc, 'show', SYSTEMD_UNIT_NAME, '-p', 'MainPID', '--value'],
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const target =
|
|
602
|
+
scope === 'system'
|
|
603
|
+
? `system/${LAUNCHD_LABEL}`
|
|
604
|
+
: `gui/${process.getuid?.() ?? 501}/${LAUNCHD_LABEL}`;
|
|
605
|
+
return {
|
|
606
|
+
// kickstart -k restarts a loaded job; bootstrap first if it was never loaded.
|
|
607
|
+
restart:
|
|
608
|
+
scope === 'system'
|
|
609
|
+
? ['sudo', 'launchctl', 'kickstart', '-k', target]
|
|
610
|
+
: ['launchctl', 'kickstart', '-k', target],
|
|
611
|
+
// launchctl print's pid line is not machine-stable across releases; treat
|
|
612
|
+
// launchd as "owns nothing we can name" and let the bus decide.
|
|
613
|
+
// ponytail: no MainPID probe on darwin — celilo-mgr is Linux; add if a mac
|
|
614
|
+
// ever runs the management plane.
|
|
615
|
+
mainPid: null,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The pid the supervisor currently owns for this scope's unit, or null when
|
|
621
|
+
* that can't be established (launchd, systemd absent, unit inactive, user bus
|
|
622
|
+
* unreachable). `MainPID=0` means inactive — reported as null, not 0.
|
|
623
|
+
*
|
|
624
|
+
* Null is "don't know", never "not supervised": a caller must not accuse a
|
|
625
|
+
* dispatcher of being an orphan on the strength of a failed probe.
|
|
626
|
+
*
|
|
627
|
+
* The argv comes from supervisorCommands (celilo#604) rather than being rebuilt
|
|
628
|
+
* here — that is where the sudo-for-system-scope decision lives, and two copies
|
|
629
|
+
* of it would drift.
|
|
630
|
+
*/
|
|
631
|
+
export function unitMainPid(
|
|
632
|
+
scope: SupervisorScope,
|
|
633
|
+
platform: SupervisorPlatform = detectPlatform(),
|
|
634
|
+
): number | null {
|
|
635
|
+
const { mainPid } = supervisorCommands(platform, scope);
|
|
636
|
+
if (!mainPid) return null;
|
|
637
|
+
const [cmd, ...args] = mainPid;
|
|
638
|
+
if (!cmd) return null;
|
|
639
|
+
try {
|
|
640
|
+
const out = execFileSync(cmd, args, {
|
|
641
|
+
encoding: 'utf-8',
|
|
642
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
643
|
+
});
|
|
644
|
+
const pid = Number(out.trim());
|
|
645
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
646
|
+
} catch {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
369
651
|
export function readInstalledUnit(
|
|
370
|
-
opts: {
|
|
652
|
+
opts: {
|
|
653
|
+
platform?: SupervisorPlatform;
|
|
654
|
+
scope?: SupervisorScope;
|
|
655
|
+
home?: string;
|
|
656
|
+
systemRoot?: string;
|
|
657
|
+
} = {},
|
|
371
658
|
): { exists: true; path: string; content: string } | { exists: false; path: string } {
|
|
372
659
|
const platform = opts.platform ?? detectPlatform();
|
|
373
660
|
const scope = opts.scope ?? 'user';
|
|
374
661
|
const home = opts.home ?? homedir();
|
|
375
|
-
const unitPath = getDaemonUnitPath(platform, home, scope);
|
|
662
|
+
const unitPath = getDaemonUnitPath(platform, home, scope, opts.systemRoot);
|
|
376
663
|
if (!existsSync(unitPath)) return { exists: false, path: unitPath };
|
|
377
664
|
return { exists: true, path: unitPath, content: readFileSync(unitPath, 'utf-8') };
|
|
378
665
|
}
|