@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.
@@ -10,8 +10,9 @@
10
10
  import type { Database } from 'bun:sqlite';
11
11
  import { defineEvents, openBus } from '@celilo/event-bus';
12
12
  import { getEventBusPath } from '../../config/paths';
13
- import { getDb } from '../../db/client';
13
+ import { createDbClient, findMigrationsFolder, getDb } from '../../db/client';
14
14
  import { runMigrationsOn } from '../../db/migrate';
15
+ import { getMigrationStatus } from '../../db/migration-status';
15
16
  import { findSchemaDrift } from '../../db/schema-introspection';
16
17
  import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
17
18
  import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
@@ -62,7 +63,50 @@ function countApplied(sqlite: Database): number {
62
63
  }
63
64
  }
64
65
 
65
- export async function handleSystemMigrate(): Promise<CommandResult> {
66
+ /**
67
+ * `celilo system migrate --status` — read-only interrogation (celilo#604).
68
+ *
69
+ * A rollout runbook that says "assert applied 19 → 20 and `backups.pid`
70
+ * present" needs a product surface to assert against; the table count that used
71
+ * to be the only answer cannot see a column migration at all.
72
+ */
73
+ export function migrationStatusResult(sqlite: Database): CommandResult {
74
+ const status = getMigrationStatus(sqlite, findMigrationsFolder());
75
+ const missing = [...status.missingTables, ...status.missingColumns];
76
+ const lines = [
77
+ `Applied migrations: ${status.appliedCount}`,
78
+ `Latest applied: ${status.latestApplied ?? '(none)'}`,
79
+ status.pending.length > 0
80
+ ? `Pending: ${status.pending.join(', ')}`
81
+ : 'Pending: none',
82
+ `Schema present: ${status.tableCount} tables, ${status.columnCount} columns`,
83
+ ...(missing.length > 0 ? [`Missing: ${missing.join(', ')}`] : []),
84
+ ];
85
+ if (status.pending.length > 0 || missing.length > 0) {
86
+ return {
87
+ success: false,
88
+ error: `${lines.join('\n')}\n\nRun \`celilo system migrate\` to apply pending migrations on this box.`,
89
+ };
90
+ }
91
+ return { success: true, message: lines.join('\n'), data: status };
92
+ }
93
+
94
+ export async function handleSystemMigrate(
95
+ _args: string[] = [],
96
+ flags: Record<string, string | boolean> = {},
97
+ ): Promise<CommandResult> {
98
+ // --status must NOT migrate. getDb() auto-migrates on open, so a status that
99
+ // went through it would repair the very state it claims to be reporting and
100
+ // could never say "pending" — the placebo shape this command exists to end.
101
+ if (flags.status) {
102
+ const ro = createDbClient({ readonly: true });
103
+ try {
104
+ return migrationStatusResult(ro.$client);
105
+ } finally {
106
+ ro.$client.close();
107
+ }
108
+ }
109
+
66
110
  // getDb() auto-migrates on open; do it inside try so an existing DB that
67
111
  // predates the drizzle-authoritative change fails with an actionable message
68
112
  // instead of a raw migrator error.
@@ -97,9 +141,13 @@ export async function handleSystemMigrate(): Promise<CommandResult> {
97
141
 
98
142
  ensureCoreSubscribers();
99
143
 
144
+ // Name the latest migration, not just a table count: "35 tables" reads the
145
+ // same whether a column migration applied or silently did nothing (celilo#604).
146
+ const status = getMigrationStatus(sqlite, findMigrationsFolder());
100
147
  const lines = [
101
148
  applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
102
- `Schema current: ${drift.tableCount} tables.`,
149
+ `Applied migrations: ${status.appliedCount} (latest: ${status.latestApplied ?? 'none'})`,
150
+ `Schema current: ${drift.tableCount} tables, ${drift.columnCount} columns.`,
103
151
  ];
104
- return { success: true, message: lines.join('\n') };
152
+ return { success: true, message: lines.join('\n'), data: status };
105
153
  }
@@ -125,6 +125,7 @@ export async function getCompletions(words: string[], current: number): Promise<
125
125
  'respond',
126
126
  'install-daemon',
127
127
  'uninstall-daemon',
128
+ 'restart-daemon',
128
129
  'show-daemon',
129
130
  ];
130
131
  return filterSuggestions(subcommands, args[1] || '');
package/src/cli/index.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  handleEventsRepair,
32
32
  handleEventsReply,
33
33
  handleEventsRespond,
34
+ handleEventsRestartDaemon,
34
35
  handleEventsResyncSubscriptions,
35
36
  handleEventsRun,
36
37
  handleEventsRunHook,
@@ -303,6 +304,7 @@ Subcommands:
303
304
  respond Run the terminal responder; answer deploy prompts from another shell
304
305
  install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
305
306
  uninstall-daemon [--system] Remove the installed supervisor unit
307
+ restart-daemon [--system] Restart the dispatcher and verify the new process is on current code
306
308
  show-daemon [--system] Print the currently installed unit file
307
309
 
308
310
  Description:
@@ -1439,6 +1441,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1439
1441
  return handleEventsInstallDaemon(parsed.args, parsed.flags);
1440
1442
  case 'uninstall-daemon':
1441
1443
  return handleEventsUninstallDaemon(parsed.args, parsed.flags);
1444
+ case 'restart-daemon':
1445
+ return handleEventsRestartDaemon(parsed.args, parsed.flags);
1442
1446
  case 'show-daemon':
1443
1447
  return handleEventsShowDaemon(parsed.args, parsed.flags);
1444
1448
  default:
@@ -2133,7 +2137,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2133
2137
  }
2134
2138
 
2135
2139
  if (parsed.subcommand === 'migrate') {
2136
- return handleSystemMigrate();
2140
+ return handleSystemMigrate(parsed.args, parsed.flags);
2137
2141
  }
2138
2142
 
2139
2143
  return {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * celilo#604: a table COUNT reported "up to date" for a DB missing a migrated
3
+ * COLUMN, so the rollout's own stop-condition was uncheckable by the tools the
4
+ * runbook named. These assert the column case specifically.
5
+ */
6
+
7
+ import { Database } from 'bun:sqlite';
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { getMigrationStatus, readMigrationJournal } from './migration-status';
13
+
14
+ /** A journal + a `__drizzle_migrations` table, wired the way drizzle wires them. */
15
+ function seed(dir: string, entries: Array<{ when: number; tag: string }>, appliedWhens: number[]) {
16
+ mkdirSync(join(dir, 'meta'), { recursive: true });
17
+ writeFileSync(
18
+ join(dir, 'meta', '_journal.json'),
19
+ JSON.stringify({ version: '7', dialect: 'sqlite', entries }),
20
+ );
21
+ const db = new Database(':memory:');
22
+ db.run(
23
+ 'CREATE TABLE `__drizzle_migrations` (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)',
24
+ );
25
+ for (const when of appliedWhens) {
26
+ db.run('INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES (?, ?)', [
27
+ `h${when}`,
28
+ when,
29
+ ]);
30
+ }
31
+ return db;
32
+ }
33
+
34
+ describe('getMigrationStatus', () => {
35
+ let dir: string;
36
+ beforeEach(() => {
37
+ dir = mkdtempSync(join(tmpdir(), 'celilo-migstatus-'));
38
+ });
39
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
40
+
41
+ it('names the latest applied migration and every pending one', () => {
42
+ const db = seed(
43
+ dir,
44
+ [
45
+ { when: 100, tag: '0018_drop_alert_policy_snapshot' },
46
+ { when: 200, tag: '0019_backup_pid' },
47
+ { when: 300, tag: '0020_future' },
48
+ ],
49
+ [100, 200],
50
+ );
51
+
52
+ const status = getMigrationStatus(db, dir);
53
+
54
+ expect(status.appliedCount).toBe(2);
55
+ expect(status.latestApplied).toBe('0019_backup_pid');
56
+ expect(status.pending).toEqual(['0020_future']);
57
+ });
58
+
59
+ it('reports nothing pending once every journal entry is applied', () => {
60
+ const db = seed(dir, [{ when: 100, tag: '0000_init' }], [100]);
61
+ const status = getMigrationStatus(db, dir);
62
+ expect(status.pending).toEqual([]);
63
+ expect(status.latestApplied).toBe('0000_init');
64
+ });
65
+
66
+ it('reports a DB that has never been migrated as all-pending', () => {
67
+ mkdirSync(join(dir, 'meta'), { recursive: true });
68
+ writeFileSync(
69
+ join(dir, 'meta', '_journal.json'),
70
+ JSON.stringify({ entries: [{ when: 1, tag: '0000_init' }] }),
71
+ );
72
+ const status = getMigrationStatus(new Database(':memory:'), dir);
73
+ expect(status.appliedCount).toBe(0);
74
+ expect(status.latestApplied).toBeNull();
75
+ expect(status.pending).toEqual(['0000_init']);
76
+ });
77
+
78
+ it('counts columns, not just tables — the signal a column migration needs', () => {
79
+ const db = seed(dir, [], []);
80
+ const status = getMigrationStatus(db, dir);
81
+ // Every schema table is missing here, so the counts are the code's totals.
82
+ expect(status.tableCount).toBeGreaterThan(0);
83
+ expect(status.columnCount).toBeGreaterThan(status.tableCount);
84
+ });
85
+
86
+ it('reads journal entries oldest-first regardless of file order', () => {
87
+ mkdirSync(join(dir, 'meta'), { recursive: true });
88
+ writeFileSync(
89
+ join(dir, 'meta', '_journal.json'),
90
+ JSON.stringify({
91
+ entries: [
92
+ { when: 300, tag: 'c' },
93
+ { when: 100, tag: 'a' },
94
+ ],
95
+ }),
96
+ );
97
+ expect(readMigrationJournal(dir).map((e) => e.tag)).toEqual(['a', 'c']);
98
+ });
99
+
100
+ it('returns an empty journal when the folder has none', () => {
101
+ expect(readMigrationJournal(join(dir, 'nope'))).toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe('getMigrationStatus against the real migrations folder', () => {
106
+ it('sees backups.pid as missing when the column migration did not land', () => {
107
+ // The exact celilo#604 shape: every table present, one migrated column not.
108
+ const db = new Database(':memory:');
109
+ db.run('CREATE TABLE backups (id TEXT PRIMARY KEY)');
110
+ const status = getMigrationStatus(db, join(import.meta.dir, '../../drizzle'));
111
+ expect(status.missingColumns).toContain('backups.pid');
112
+ expect(status.pending).toContain('0019_backup_pid');
113
+ });
114
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Read-only migration interrogation (celilo#604).
3
+ *
4
+ * `system migrate` used to report "Schema current: 35 tables" — a table COUNT,
5
+ * which cannot distinguish "the column migration applied" from "nothing
6
+ * happened". 0019_backup_pid adds a column; a rollout runbook asserting
7
+ * "applied 19 → 20, backups.pid present" had no product surface to check it
8
+ * against and had to reach for `sqlite3` over SSH.
9
+ *
10
+ * This names migrations. Drizzle's `__drizzle_migrations.created_at` is the
11
+ * journal entry's `when`, so the join back to a human tag is exact.
12
+ */
13
+
14
+ import type { Database } from 'bun:sqlite';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { findSchemaDrift } from './schema-introspection';
18
+
19
+ export interface MigrationStatus {
20
+ /** Rows in `__drizzle_migrations` — what the runbook calls "applied count". */
21
+ appliedCount: number;
22
+ /** Tag of the newest applied migration, e.g. `0019_backup_pid`. */
23
+ latestApplied: string | null;
24
+ /** Tags present on disk that this DB has not applied, oldest first. */
25
+ pending: string[];
26
+ /** Tables the running code declares that the DB lacks. */
27
+ missingTables: string[];
28
+ /** `table.column` the running code declares that the DB lacks. */
29
+ missingColumns: string[];
30
+ tableCount: number;
31
+ columnCount: number;
32
+ }
33
+
34
+ interface JournalEntry {
35
+ when: number;
36
+ tag: string;
37
+ }
38
+
39
+ /** Journal entries (oldest first), or [] when the folder has no journal. */
40
+ export function readMigrationJournal(migrationsFolder: string): JournalEntry[] {
41
+ const path = join(migrationsFolder, 'meta', '_journal.json');
42
+ if (!existsSync(path)) return [];
43
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { entries?: JournalEntry[] };
44
+ return [...(parsed.entries ?? [])].sort((a, b) => a.when - b.when);
45
+ }
46
+
47
+ /** `created_at` timestamps recorded in `__drizzle_migrations` (empty if untracked). */
48
+ function appliedTimestamps(sqlite: Database): number[] {
49
+ try {
50
+ return sqlite
51
+ .query<{ created_at: number }, []>(
52
+ 'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at',
53
+ )
54
+ .all()
55
+ .map((r) => r.created_at);
56
+ } catch {
57
+ // No migrations table — a DB that has never been through the migrator.
58
+ return [];
59
+ }
60
+ }
61
+
62
+ export function getMigrationStatus(sqlite: Database, migrationsFolder: string): MigrationStatus {
63
+ const journal = readMigrationJournal(migrationsFolder);
64
+ const applied = new Set(appliedTimestamps(sqlite));
65
+ const appliedTags = journal.filter((e) => applied.has(e.when)).map((e) => e.tag);
66
+ const pending = journal.filter((e) => !applied.has(e.when)).map((e) => e.tag);
67
+ const drift = findSchemaDrift(sqlite);
68
+
69
+ return {
70
+ appliedCount: applied.size,
71
+ latestApplied: appliedTags.at(-1) ?? null,
72
+ pending,
73
+ missingTables: drift.missingTables,
74
+ missingColumns: drift.missingColumns,
75
+ tableCount: drift.tableCount,
76
+ columnCount: drift.columnCount,
77
+ };
78
+ }
@@ -20,6 +20,12 @@ export interface SchemaDrift {
20
20
  missingColumns: string[];
21
21
  /** Total number of tables the code's schema declares. */
22
22
  tableCount: number;
23
+ /**
24
+ * Total number of columns the code's schema declares. Reported alongside
25
+ * tableCount so the doctor can say what it actually checked — a table count
26
+ * alone reads as "columns unverified" even when they were (celilo#604).
27
+ */
28
+ columnCount: number;
23
29
  }
24
30
 
25
31
  /** Every table name + column names the drizzle schema declares. */
@@ -84,5 +90,6 @@ export function findSchemaDrift(sqlite: Database): SchemaDrift {
84
90
  if (!cols.has(c)) missingColumns.push(`${t.name}.${c}`);
85
91
  }
86
92
  }
87
- return { missingTables, missingColumns, tableCount: tables.length };
93
+ const columnCount = tables.reduce((n, t) => n + t.columns.length, 0);
94
+ return { missingTables, missingColumns, tableCount: tables.length, columnCount };
88
95
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
16
16
  import { getEventBusPath } from '../config/paths';
17
+ import { InterviewUnansweredError } from './interview-errors';
17
18
  import { ensureResponderForInterview } from './responder-probe';
18
19
 
19
20
  const NO_SCHEMAS = defineEvents({});
@@ -239,6 +240,13 @@ export interface InterviewRequiredPayload {
239
240
  */
240
241
  export interface InterviewReply {
241
242
  value: unknown;
243
+ /**
244
+ * Set instead of `value` when the responder could not reach a decider (e.g.
245
+ * the remote client has no TTY and no pre-staged answer). `askInterview`
246
+ * turns it into an `InterviewUnansweredError` so the question fails loudly
247
+ * rather than silently resolving to `defaultValue`.
248
+ */
249
+ error?: string;
242
250
  }
243
251
 
244
252
  /**
@@ -314,11 +322,9 @@ export async function askInterview(
314
322
  payload: InterviewRequiredPayload,
315
323
  ownerBus?: Bus,
316
324
  ): Promise<unknown> {
317
- const reply = await busInterviewGuarded<InterviewReply>(
318
- EVENT_TYPES.interviewRequired(payload.scope, payload.key),
319
- payload,
320
- ownerBus,
321
- );
325
+ const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key);
326
+ const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus);
327
+ if (reply.error) throw new InterviewUnansweredError(type, reply.error);
322
328
  return reply.value;
323
329
  }
324
330
 
@@ -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
+ });