@celilo/cli 0.18.0 → 0.19.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 +2 -2
- package/src/api/remote-client.test.ts +62 -0
- package/src/api/serve.ts +14 -6
- package/src/cli/commands/apt-upgrade.test.ts +20 -1
- package/src/cli/commands/apt-upgrade.ts +12 -2
- package/src/cli/commands/events.ts +90 -0
- package/src/cli/commands/module-update.test.ts +72 -1
- package/src/cli/commands/module-update.ts +45 -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 +1 -0
- package/src/cli/index.ts +5 -1
- 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/bus-interview.ts +11 -5
- 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 +20 -0
- package/src/services/remote-responder.test.ts +70 -0
- package/src/services/remote-responder.ts +27 -10
- package/src/services/responder-probe.ts +3 -1
|
@@ -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
|
}
|
|
@@ -39,9 +39,9 @@ function seedHeartbeat(
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Write a supervisor unit file so readInstalledUnit(
|
|
43
|
-
function installFakeUnit(home: string): void {
|
|
44
|
-
const path = getDaemonUnitPath('linux', home,
|
|
42
|
+
/** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
|
|
43
|
+
function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
|
|
44
|
+
const path = getDaemonUnitPath('linux', home, scope, systemRoot);
|
|
45
45
|
mkdirSync(dirname(path), { recursive: true });
|
|
46
46
|
writeFileSync(path, '[Unit]\nDescription=fake\n');
|
|
47
47
|
}
|
|
@@ -114,6 +114,51 @@ describe('checkDispatcher', () => {
|
|
|
114
114
|
expect(f.detail.join(' ')).toContain('not under a supervisor');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
// #610 — celilo-mgr had a dead system unit and a live user-scope unit of the
|
|
118
|
+
// SAME name. The old file-exists test called that "supervised".
|
|
119
|
+
it('fails when the running dispatcher is not the pid any installed unit supervises', () => {
|
|
120
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000, pid: 588704 });
|
|
121
|
+
installFakeUnit(home);
|
|
122
|
+
const f = checkDispatcher(bus, {
|
|
123
|
+
now: now,
|
|
124
|
+
home,
|
|
125
|
+
platform: 'linux',
|
|
126
|
+
unitMainPid: () => 3639051, // systemd supervises a different process
|
|
127
|
+
});
|
|
128
|
+
expect(f.status).toBe('fail');
|
|
129
|
+
expect(f.detail.join(' ')).toContain('not the process any installed unit supervises');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('fails when both a user-scope and a system-scope unit are installed', () => {
|
|
133
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000, pid: 4242 });
|
|
134
|
+
installFakeUnit(home);
|
|
135
|
+
installFakeUnit(home, 'system', dir);
|
|
136
|
+
const f = checkDispatcher(bus, {
|
|
137
|
+
now: now,
|
|
138
|
+
home,
|
|
139
|
+
systemRoot: dir,
|
|
140
|
+
platform: 'linux',
|
|
141
|
+
unitMainPid: () => 4242,
|
|
142
|
+
});
|
|
143
|
+
expect(f.status).toBe('fail');
|
|
144
|
+
expect(f.detail.join(' ')).toContain('same unit name, different services');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Null is ignorance, not evidence: a systemd probe that fails must not
|
|
148
|
+
// manufacture an orphan report.
|
|
149
|
+
it('makes no supervision claim when the unit pid cannot be determined', () => {
|
|
150
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000, pid: 4242 });
|
|
151
|
+
installFakeUnit(home);
|
|
152
|
+
const f = checkDispatcher(bus, {
|
|
153
|
+
now: now,
|
|
154
|
+
home,
|
|
155
|
+
platform: 'linux',
|
|
156
|
+
installedCodeMtimeMs: now - 2 * MINUTE,
|
|
157
|
+
unitMainPid: () => null,
|
|
158
|
+
});
|
|
159
|
+
expect(f.status).toBe('ok');
|
|
160
|
+
});
|
|
161
|
+
|
|
117
162
|
it('warns when the dispatcher started before the installed code (stale)', () => {
|
|
118
163
|
seedHeartbeat(bus, { startedAt: now - 10 * MINUTE, lastHeartbeat: now - 1000 });
|
|
119
164
|
installFakeUnit(home);
|
|
@@ -425,7 +470,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
425
470
|
it('is ok when every schema table is present (fresh migrated DB)', () => {
|
|
426
471
|
const f = checkSchemaDrift(db);
|
|
427
472
|
expect(f.status).toBe('ok');
|
|
428
|
-
expect(f.summary).toContain('schema tables
|
|
473
|
+
expect(f.summary).toContain('schema tables');
|
|
429
474
|
});
|
|
430
475
|
|
|
431
476
|
it('fails and names a table the running CLI expects but the DB lacks', () => {
|
|
@@ -435,6 +480,32 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
435
480
|
expect(f.detail.join(' ')).toContain('dns_internal_records');
|
|
436
481
|
expect(f.remediation).toContain('migrations');
|
|
437
482
|
});
|
|
483
|
+
|
|
484
|
+
// celilo#604: this is the state the rollout could not check. Every table
|
|
485
|
+
// is present, one MIGRATED COLUMN is not, and the doctor must not call
|
|
486
|
+
// that "migrations applied".
|
|
487
|
+
it('fails and names a migrated COLUMN the DB lacks, with every table present', () => {
|
|
488
|
+
db.$client.run('ALTER TABLE backups DROP COLUMN pid');
|
|
489
|
+
const f = checkSchemaDrift(db);
|
|
490
|
+
expect(f.status).toBe('fail');
|
|
491
|
+
expect(f.detail.join(' ')).toContain('backups.pid');
|
|
492
|
+
expect(f.summary).not.toContain('present');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('says it checked columns, not only tables', () => {
|
|
496
|
+
const f = checkSchemaDrift(db);
|
|
497
|
+
expect(f.status).toBe('ok');
|
|
498
|
+
expect(f.summary).toContain('columns present');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('fails when a journal migration has not been applied on this box', () => {
|
|
502
|
+
db.$client.run(
|
|
503
|
+
'DELETE FROM `__drizzle_migrations` WHERE created_at = (SELECT MAX(created_at) FROM `__drizzle_migrations`)',
|
|
504
|
+
);
|
|
505
|
+
const f = checkSchemaDrift(db);
|
|
506
|
+
expect(f.status).toBe('fail');
|
|
507
|
+
expect(f.detail.join(' ')).toContain('unapplied migration');
|
|
508
|
+
});
|
|
438
509
|
});
|
|
439
510
|
});
|
|
440
511
|
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
import type { Bus } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
22
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
|
-
import type
|
|
23
|
+
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
24
|
+
import { getMigrationStatus } from '../db/migration-status';
|
|
24
25
|
import { capabilities as capabilitiesTable, modules } from '../db/schema';
|
|
25
26
|
import { findSchemaDrift } from '../db/schema-introspection';
|
|
26
27
|
import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
|
|
@@ -30,7 +31,13 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
30
31
|
const CONTROL_PLANE_MODULE = 'celilo-mgmt';
|
|
31
32
|
import { getModuleSystems } from './deployed-systems';
|
|
32
33
|
import { listDnsInternalRecords } from './dns-internal-records';
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
SUPERVISOR_SCOPES,
|
|
36
|
+
type SupervisorPlatform,
|
|
37
|
+
type SupervisorScope,
|
|
38
|
+
readInstalledUnit,
|
|
39
|
+
unitMainPid,
|
|
40
|
+
} from './events-daemon';
|
|
34
41
|
import { resolveSubscription } from './module-subscriptions';
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -107,26 +114,43 @@ function worst(statuses: FleetFindingStatus[]): FleetFindingStatus {
|
|
|
107
114
|
* its schema is current, so presence is the honest signal.
|
|
108
115
|
*/
|
|
109
116
|
export function checkSchemaDrift(db: DbClient): FleetFinding {
|
|
110
|
-
const { missingTables, missingColumns, tableCount } = findSchemaDrift(db.$client);
|
|
117
|
+
const { missingTables, missingColumns, tableCount, columnCount } = findSchemaDrift(db.$client);
|
|
111
118
|
|
|
112
119
|
const detail: string[] = [];
|
|
113
120
|
if (missingTables.length > 0) detail.push(`missing table(s): ${missingTables.join(', ')}`);
|
|
114
121
|
if (missingColumns.length > 0) detail.push(`missing column(s): ${missingColumns.join(', ')}`);
|
|
122
|
+
|
|
123
|
+
// Also name unapplied migrations. Presence is the honest signal for schema
|
|
124
|
+
// objects, but a migration can carry an index or a data fix that presence
|
|
125
|
+
// can't see — and an operator reading "migrations applied" deserves to know
|
|
126
|
+
// when some aren't (celilo#604). Best-effort: an install layout where the
|
|
127
|
+
// journal can't be found must not fail the check.
|
|
128
|
+
let pending: string[] = [];
|
|
129
|
+
try {
|
|
130
|
+
pending = getMigrationStatus(db.$client, findMigrationsFolder()).pending;
|
|
131
|
+
} catch {
|
|
132
|
+
// No journal reachable — the presence check above still stands.
|
|
133
|
+
}
|
|
134
|
+
if (pending.length > 0) detail.push(`unapplied migration(s): ${pending.join(', ')}`);
|
|
135
|
+
|
|
115
136
|
const status: FleetFindingStatus = detail.length > 0 ? 'fail' : 'ok';
|
|
116
137
|
|
|
117
138
|
return {
|
|
118
139
|
id: 'schema',
|
|
119
140
|
title: 'database schema matches the running CLI (migrations applied)',
|
|
120
141
|
status,
|
|
142
|
+
// Say tables AND columns: "all 35 tables present" reads as though columns
|
|
143
|
+
// went unchecked, which is what sent a rollout to sqlite3 over SSH to
|
|
144
|
+
// confirm a column migration the doctor had in fact already verified.
|
|
121
145
|
summary:
|
|
122
146
|
status === 'ok'
|
|
123
|
-
? `all ${tableCount} schema tables present`
|
|
147
|
+
? `all ${tableCount} schema tables and ${columnCount} columns present, no unapplied migrations`
|
|
124
148
|
: 'database schema is behind the running CLI — migrations not applied',
|
|
125
149
|
detail,
|
|
126
150
|
remediation:
|
|
127
151
|
status === 'ok'
|
|
128
152
|
? null
|
|
129
|
-
: 'run `celilo system migrate` to apply pending migrations on this box — see ISS-0100',
|
|
153
|
+
: 'run `celilo system migrate` to apply pending migrations on this box (`celilo system migrate --status` names them) — see ISS-0100',
|
|
130
154
|
autoFixable: false,
|
|
131
155
|
};
|
|
132
156
|
}
|
|
@@ -152,6 +176,14 @@ export interface DispatcherCheckOptions {
|
|
|
152
176
|
/** Override for readInstalledUnit — tests point this at a temp home. */
|
|
153
177
|
home?: string;
|
|
154
178
|
platform?: SupervisorPlatform;
|
|
179
|
+
/** Prefix for system-scope unit paths. Test seam — see getDaemonUnitPath. */
|
|
180
|
+
systemRoot?: string;
|
|
181
|
+
/**
|
|
182
|
+
* Which pid each scope's unit supervises. Injected so the check is testable
|
|
183
|
+
* without systemd. Returning null means "can't tell" — the check then makes
|
|
184
|
+
* no supervision claim rather than guessing.
|
|
185
|
+
*/
|
|
186
|
+
unitMainPid?: (scope: SupervisorScope) => number | null;
|
|
155
187
|
}
|
|
156
188
|
|
|
157
189
|
/**
|
|
@@ -223,16 +255,54 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
223
255
|
);
|
|
224
256
|
}
|
|
225
257
|
|
|
226
|
-
// (2) supervised — a unit file exists
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
258
|
+
// (2) supervised — not just "a unit file exists on disk", but "the process
|
|
259
|
+
// that is actually running IS the one an installed unit supervises".
|
|
260
|
+
//
|
|
261
|
+
// The file-exists test this replaces reported green on celilo-mgr while the
|
|
262
|
+
// system unit was dead and a user-scope unit of the SAME NAME served
|
|
263
|
+
// production (#610). A check whose entire job is catching an unsupervised
|
|
264
|
+
// dispatcher cannot be satisfied by a file nobody is running.
|
|
265
|
+
const installedScopes = SUPERVISOR_SCOPES.filter(
|
|
266
|
+
(scope) =>
|
|
267
|
+
readInstalledUnit({
|
|
268
|
+
scope,
|
|
269
|
+
home: opts.home,
|
|
270
|
+
platform: opts.platform,
|
|
271
|
+
systemRoot: opts.systemRoot,
|
|
272
|
+
}).exists,
|
|
273
|
+
);
|
|
274
|
+
if (installedScopes.length === 0) {
|
|
233
275
|
statuses.push('warn');
|
|
234
276
|
detail.push('not under a supervisor unit — will not survive a reboot (orphan process)');
|
|
235
277
|
remediations.push('`celilo events install-daemon` then enable the unit so it is supervised');
|
|
278
|
+
} else {
|
|
279
|
+
if (installedScopes.length > 1) {
|
|
280
|
+
statuses.push('fail');
|
|
281
|
+
detail.push(
|
|
282
|
+
'both a user-scope AND a system-scope unit are installed — same unit name, different services; ' +
|
|
283
|
+
'one will lose the race on every boot and retry forever',
|
|
284
|
+
);
|
|
285
|
+
remediations.push(
|
|
286
|
+
'keep exactly one: `celilo events uninstall-daemon` (user) or `celilo events uninstall-daemon --system`, and disable it in systemd',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
// Only accuse when systemd actually answered. A null probe is ignorance,
|
|
290
|
+
// not evidence of an orphan.
|
|
291
|
+
const probe =
|
|
292
|
+
opts.unitMainPid ?? ((scope: SupervisorScope) => unitMainPid(scope, opts.platform));
|
|
293
|
+
const supervisedPids = installedScopes
|
|
294
|
+
.map((scope) => ({ scope, pid: probe(scope) }))
|
|
295
|
+
.filter((entry): entry is { scope: SupervisorScope; pid: number } => entry.pid !== null);
|
|
296
|
+
if (supervisedPids.length > 0 && !supervisedPids.some((entry) => entry.pid === hb.pid)) {
|
|
297
|
+
statuses.push('fail');
|
|
298
|
+
detail.push(
|
|
299
|
+
`the running dispatcher (pid ${hb.pid}) is not the process any installed unit supervises ` +
|
|
300
|
+
`(${supervisedPids.map((e) => `${e.scope}=${e.pid}`).join(', ')}) — restarting the unit will not restart it`,
|
|
301
|
+
);
|
|
302
|
+
remediations.push(
|
|
303
|
+
'stop the unsupervised process and let the unit own the dispatcher, or reinstall the unit for the scope that is actually running it',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
236
306
|
}
|
|
237
307
|
|
|
238
308
|
// (3) current — started before the installed code was last written ⇒
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error type that means "this interview question could not be
|
|
3
|
+
* answered". Its own module so `responder-probe` (no responder listening) and
|
|
4
|
+
* `bus-interview` (a responder replied that it couldn't decide) can both throw
|
|
5
|
+
* it without importing each other.
|
|
6
|
+
*
|
|
7
|
+
* Callers catch this to distinguish an *unanswered* question from an answered
|
|
8
|
+
* one — the distinction `module update` conflated when it reported a breaking
|
|
9
|
+
* update as "operator declined" that no operator had ever seen.
|
|
10
|
+
*/
|
|
11
|
+
export class InterviewUnansweredError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
/** The interview event type that went unanswered, e.g. `interview.required.<scope>.<key>`. */
|
|
14
|
+
readonly queryType: string,
|
|
15
|
+
message: string,
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'InterviewUnansweredError';
|
|
19
|
+
}
|
|
20
|
+
}
|