@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.
Files changed (34) hide show
  1. package/CELILO_SUBSYSTEMS.md +4 -2
  2. package/package.json +4 -4
  3. package/src/api/remote-client.test.ts +86 -2
  4. package/src/api/serve.ts +242 -38
  5. package/src/api/sessions.test.ts +196 -0
  6. package/src/api/sessions.ts +278 -0
  7. package/src/cli/commands/apt-upgrade.test.ts +20 -1
  8. package/src/cli/commands/apt-upgrade.ts +12 -2
  9. package/src/cli/commands/backup-sweep.ts +25 -9
  10. package/src/cli/commands/events.ts +150 -4
  11. package/src/cli/commands/module-update.test.ts +72 -1
  12. package/src/cli/commands/module-update.ts +68 -22
  13. package/src/cli/commands/system-migrate.test.ts +56 -0
  14. package/src/cli/commands/system-migrate.ts +52 -4
  15. package/src/cli/completion.ts +2 -0
  16. package/src/cli/index.ts +27 -3
  17. package/src/db/migration-status.test.ts +114 -0
  18. package/src/db/migration-status.ts +78 -0
  19. package/src/db/schema-introspection.ts +8 -1
  20. package/src/services/backup-metadata.ts +17 -0
  21. package/src/services/backup-staging.test.ts +98 -0
  22. package/src/services/backup-staging.ts +73 -1
  23. package/src/services/backup-sweep.test.ts +15 -0
  24. package/src/services/backup-sweep.ts +17 -1
  25. package/src/services/bus-interview-park.test.ts +179 -0
  26. package/src/services/bus-interview.ts +17 -6
  27. package/src/services/events-daemon.test.ts +244 -0
  28. package/src/services/events-daemon.ts +295 -8
  29. package/src/services/fleet-checks.test.ts +75 -4
  30. package/src/services/fleet-checks.ts +82 -12
  31. package/src/services/interview-errors.ts +37 -0
  32. package/src/services/remote-responder.test.ts +83 -0
  33. package/src/services/remote-responder.ts +31 -10
  34. package/src/services/responder-probe.ts +3 -1
@@ -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,37 @@
1
+ /**
2
+ * The two error types that mean "this interview question was not decided".
3
+ * Their own module so `responder-probe` (nobody is listening at all) and the
4
+ * session reaper (a parked question expired) can both throw without importing
5
+ * each other.
6
+ *
7
+ * Callers catch these to distinguish a question that was never decided from one
8
+ * that was answered — the distinction `module update` conflated when it reported
9
+ * a breaking update as "operator declined" that no operator had ever seen. The
10
+ * two are not interchangeable: *unanswered* means no responder could even be
11
+ * found, *abandoned* means a responder existed, the question stood, and the
12
+ * deadline passed with nobody deciding.
13
+ */
14
+ export class InterviewUnansweredError extends Error {
15
+ constructor(
16
+ /** The interview event type that went unanswered, e.g. `interview.required.<scope>.<key>`. */
17
+ readonly queryType: string,
18
+ message: string,
19
+ ) {
20
+ super(message);
21
+ this.name = 'InterviewUnansweredError';
22
+ }
23
+ }
24
+
25
+ /**
26
+ * The question was posted, stood unanswered past its session's TTL, and the
27
+ * reaper answered it `abandoned` to release what the parked command held.
28
+ */
29
+ export class InterviewAbandonedError extends Error {
30
+ constructor(
31
+ readonly queryType: string,
32
+ message: string,
33
+ ) {
34
+ super(message);
35
+ this.name = 'InterviewAbandonedError';
36
+ }
37
+ }
@@ -76,3 +76,86 @@ test('answers responder.probe with kind daemon', async () => {
76
76
  responder.close();
77
77
  }
78
78
  }, 15_000);
79
+
80
+ /**
81
+ * A responder that cannot reach a decider must emit NOTHING (celilo#609).
82
+ *
83
+ * PR #607 had it reply `{error}` here, which reads as "fail loudly" but carries
84
+ * `replyFor: <query id>` — it *consumes the query*. The question then no longer
85
+ * exists for anyone else to answer, and the command dies with it. Leaving it
86
+ * unanswered parks the command instead, so a later responder can still decide.
87
+ *
88
+ * Asserted on the value that comes back, not on liveness: the reply emitted
89
+ * afterwards correlates to the original query, which is only true if the
90
+ * responder left it standing.
91
+ */
92
+ test('an ask that cannot be answered emits no reply — the query stays answerable', async () => {
93
+ const responder = startRemoteResponder({
94
+ busDbPath,
95
+ ask: async () => {
96
+ throw new Error("stdin isn't a terminal and no answer was pre-staged");
97
+ },
98
+ });
99
+
100
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
101
+ const type = 'interview.required.module-upgrade:iptables.apply_breaking';
102
+ try {
103
+ const query = bus.emitRaw(type, {
104
+ scope: 'module-upgrade:iptables',
105
+ key: 'apply_breaking',
106
+ kind: 'confirm',
107
+ message: 'Apply breaking update for iptables?',
108
+ required: true,
109
+ defaultValue: 'false',
110
+ });
111
+
112
+ // Well past the responder's watch latency: still nobody has answered.
113
+ await new Promise((r) => setTimeout(r, 600));
114
+ expect(bus.recentEvents({ type: `${type}.reply` })).toHaveLength(0);
115
+
116
+ // And the query is still live: a reply emitted now is a genuine answer.
117
+ bus.emitRaw(
118
+ `${type}.reply`,
119
+ { value: false },
120
+ { replyFor: query.id, emittedBy: 'later-responder' },
121
+ );
122
+ const replies = bus.recentEvents({ type: `${type}.reply` });
123
+ expect(replies).toHaveLength(1);
124
+ expect((replies[0].payload as { value: unknown }).value).toBe(false);
125
+ expect(replies[0].replyFor).toBe(query.id);
126
+ } finally {
127
+ bus.close();
128
+ responder.close();
129
+ }
130
+ }, 15_000);
131
+
132
+ test('forwards the question scope/key so a client can pre-stage an answer', async () => {
133
+ const asked: WireInterview[] = [];
134
+ const responder = startRemoteResponder({
135
+ busDbPath,
136
+ ask: async (iv) => {
137
+ asked.push(iv);
138
+ return true;
139
+ },
140
+ });
141
+
142
+ const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
143
+ try {
144
+ await bus.query(
145
+ 'interview.required.module-upgrade:iptables.apply_breaking' as never,
146
+ {
147
+ scope: 'module-upgrade:iptables',
148
+ key: 'apply_breaking',
149
+ kind: 'confirm',
150
+ message: 'Apply breaking update for iptables?',
151
+ required: true,
152
+ } as never,
153
+ { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
154
+ );
155
+ expect(asked[0].scope).toBe('module-upgrade:iptables');
156
+ expect(asked[0].key).toBe('apply_breaking');
157
+ } finally {
158
+ bus.close();
159
+ responder.close();
160
+ }
161
+ }, 15_000);
@@ -20,6 +20,9 @@ const NO_SCHEMAS = defineEvents({});
20
20
  /** The normalized question handed to the wire (mirrors InterviewMessage). */
21
21
  export interface WireInterview {
22
22
  id: string;
23
+ /** The question's stable identity, so a client can pre-stage `<scope>.<key>`. */
24
+ scope: string;
25
+ key: string;
23
26
  kind: InterviewRequiredPayload['kind'];
24
27
  message: string;
25
28
  description?: string;
@@ -54,16 +57,34 @@ export function startRemoteResponder(opts: RemoteResponderOptions): RemoteRespon
54
57
  return;
55
58
  }
56
59
 
57
- const value = await opts.ask({
58
- id: String(event.id),
59
- kind: payload.kind,
60
- message: payload.message,
61
- description: payload.description,
62
- defaultValue: payload.defaultValue,
63
- placeholder: payload.placeholder,
64
- options: payload.options,
65
- required: payload.required,
66
- });
60
+ let value: unknown;
61
+ try {
62
+ value = await opts.ask({
63
+ id: String(event.id),
64
+ scope: payload.scope,
65
+ key: payload.key,
66
+ kind: payload.kind,
67
+ message: payload.message,
68
+ description: payload.description,
69
+ defaultValue: payload.defaultValue,
70
+ placeholder: payload.placeholder,
71
+ options: payload.options,
72
+ required: payload.required,
73
+ });
74
+ } catch (err) {
75
+ // The client had no way to answer — so we emit NOTHING. A reply of any
76
+ // shape consumes the query (it carries `replyFor: event.id`), destroying
77
+ // a question nobody has answered yet; the asking command then dies with
78
+ // it and no other responder can ever act. Leaving it unanswered parks the
79
+ // command instead, which is what `busInterview`'s `timeoutMs: 0` is for.
80
+ // The client learns it is parked from the `blocked` wire message.
81
+ process.stderr.write(
82
+ `[remote-responder] parked ${event.type} (#${event.id}): ${
83
+ err instanceof Error ? err.message : String(err)
84
+ }\n`,
85
+ );
86
+ return;
87
+ }
67
88
 
68
89
  bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
69
90
  });
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { defineEvents, openBus } from '@celilo/event-bus';
16
16
  import { getEventBusPath } from '../config/paths';
17
+ import { InterviewUnansweredError } from './interview-errors';
17
18
 
18
19
  const NO_SCHEMAS = defineEvents({});
19
20
 
@@ -63,7 +64,8 @@ export async function ensureResponderForInterview(queryType: string): Promise<vo
63
64
  if (process.stdin.isTTY) return;
64
65
  const available = await probeForResponder(getEventBusPath());
65
66
  if (available) return;
66
- throw new Error(
67
+ throw new InterviewUnansweredError(
68
+ queryType,
67
69
  `No responder is listening and stdin isn't a TTY, so this interview prompt can't be answered (${queryType}).
68
70
 
69
71
  Either: