@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.
@@ -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; system paths are fixed.
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('/etc/systemd/system', SYSTEMD_UNIT_NAME)
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('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`)
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: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string } = {},
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: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string } = {},
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('user') sees it. */
43
- function installFakeUnit(home: string): void {
44
- const path = getDaemonUnitPath('linux', home, 'user');
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 present');
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 { DbClient } from '../db/client';
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 { type SupervisorPlatform, readInstalledUnit } from './events-daemon';
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 (user or system scope). A
227
- // running dispatcher with NO unit is the orphan case: works now, gone
228
- // after reboot.
229
- const supervised =
230
- readInstalledUnit({ scope: 'user', home: opts.home, platform: opts.platform }).exists ||
231
- readInstalledUnit({ scope: 'system', home: opts.home, platform: opts.platform }).exists;
232
- if (!supervised) {
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
+ }