@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
|
@@ -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
|
-
|
|
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
|
-
`
|
|
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
|
}
|
package/src/cli/completion.ts
CHANGED
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
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
});
|