@celilo/cli 0.2.1 → 0.3.1
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/package.json +11 -4
- package/src/cli/command-registry.ts +114 -0
- package/src/cli/commands/events.test.ts +156 -0
- package/src/cli/commands/events.ts +356 -0
- package/src/cli/commands/module-remove.ts +14 -0
- package/src/cli/index.ts +103 -0
- package/src/config/paths.ts +20 -0
- package/src/manifest/schema.ts +34 -0
- package/src/manifest/validate.test.ts +75 -0
- package/src/module/import.ts +45 -5
- package/src/services/celilo-events.test.ts +98 -0
- package/src/services/celilo-events.ts +104 -0
- package/src/services/events-daemon.test.ts +184 -0
- package/src/services/events-daemon.ts +244 -0
- package/src/services/module-deploy.ts +51 -0
- package/src/services/module-subscriptions.test.ts +197 -0
- package/src/services/module-subscriptions.ts +120 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
getDaemonUnitPath,
|
|
7
|
+
installDaemon,
|
|
8
|
+
readInstalledUnit,
|
|
9
|
+
renderLaunchdPlist,
|
|
10
|
+
renderSystemdUnit,
|
|
11
|
+
uninstallDaemon,
|
|
12
|
+
} from './events-daemon';
|
|
13
|
+
|
|
14
|
+
describe('renderSystemdUnit', () => {
|
|
15
|
+
it('produces a unit with the expected ExecStart and Environment lines', () => {
|
|
16
|
+
const out = renderSystemdUnit({
|
|
17
|
+
celiloPath: '/usr/local/bin/celilo',
|
|
18
|
+
busDbPath: '/var/lib/celilo/events.db',
|
|
19
|
+
pollMs: 1000,
|
|
20
|
+
concurrency: 4,
|
|
21
|
+
home: '/home/op',
|
|
22
|
+
});
|
|
23
|
+
expect(out).toContain(
|
|
24
|
+
'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
|
|
25
|
+
);
|
|
26
|
+
expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
|
|
27
|
+
expect(out).toContain('Restart=on-failure');
|
|
28
|
+
expect(out).toContain('WantedBy=default.target');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('honors --poll-ms and --concurrency overrides', () => {
|
|
32
|
+
const out = renderSystemdUnit({
|
|
33
|
+
celiloPath: '/c',
|
|
34
|
+
busDbPath: '/db',
|
|
35
|
+
pollMs: 250,
|
|
36
|
+
concurrency: 8,
|
|
37
|
+
home: '/h',
|
|
38
|
+
});
|
|
39
|
+
expect(out).toContain('--poll-ms 250 --concurrency 8');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('renderLaunchdPlist', () => {
|
|
44
|
+
it('puts log files under the user Library/Logs', () => {
|
|
45
|
+
const out = renderLaunchdPlist({
|
|
46
|
+
celiloPath: '/c',
|
|
47
|
+
busDbPath: '/db',
|
|
48
|
+
pollMs: 1000,
|
|
49
|
+
concurrency: 4,
|
|
50
|
+
home: '/Users/op',
|
|
51
|
+
});
|
|
52
|
+
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
|
|
53
|
+
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
|
|
54
|
+
expect(out).toContain('<key>Label</key>');
|
|
55
|
+
expect(out).toContain('<string>com.celilo.events</string>');
|
|
56
|
+
expect(out).toContain('<key>RunAtLoad</key>');
|
|
57
|
+
expect(out).toContain('<key>KeepAlive</key>');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('getDaemonUnitPath', () => {
|
|
62
|
+
it('returns the systemd user path on linux', () => {
|
|
63
|
+
expect(getDaemonUnitPath('linux', '/home/op')).toBe(
|
|
64
|
+
'/home/op/.config/systemd/user/celilo-events.service',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
it('returns the LaunchAgents path on darwin', () => {
|
|
68
|
+
expect(getDaemonUnitPath('darwin', '/Users/op')).toBe(
|
|
69
|
+
'/Users/op/Library/LaunchAgents/com.celilo.events.plist',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
75
|
+
let dir: string;
|
|
76
|
+
let celiloPath: string;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
dir = mkdtempSync(join(tmpdir(), 'daemon-test-'));
|
|
80
|
+
celiloPath = join(dir, 'fake-celilo');
|
|
81
|
+
// Touch a fake celilo executable so resolveCeliloPath via override is happy.
|
|
82
|
+
writeFileSync(celiloPath, '#!/bin/sh\nexit 0\n');
|
|
83
|
+
chmodSync(celiloPath, 0o755);
|
|
84
|
+
});
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
try {
|
|
87
|
+
rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
} catch {
|
|
89
|
+
/* ignore */
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('writes a systemd unit and uninstall removes it', () => {
|
|
94
|
+
const home = join(dir, 'home');
|
|
95
|
+
const installed = installDaemon({
|
|
96
|
+
platform: 'linux',
|
|
97
|
+
home,
|
|
98
|
+
celiloPath,
|
|
99
|
+
busDbPath: '/var/lib/celilo/events.db',
|
|
100
|
+
});
|
|
101
|
+
expect(existsSync(installed.unitPath)).toBe(true);
|
|
102
|
+
expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service'));
|
|
103
|
+
expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload');
|
|
104
|
+
|
|
105
|
+
const removed = uninstallDaemon({ platform: 'linux', home });
|
|
106
|
+
expect(removed.removed).toBe(true);
|
|
107
|
+
expect(existsSync(installed.unitPath)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('writes a launchd plist and uninstall removes it', () => {
|
|
111
|
+
const home = join(dir, 'home');
|
|
112
|
+
const installed = installDaemon({
|
|
113
|
+
platform: 'darwin',
|
|
114
|
+
home,
|
|
115
|
+
celiloPath,
|
|
116
|
+
busDbPath: '/Users/op/celilo/events.db',
|
|
117
|
+
});
|
|
118
|
+
expect(existsSync(installed.unitPath)).toBe(true);
|
|
119
|
+
expect(installed.unitPath).toBe(join(home, 'Library/LaunchAgents/com.celilo.events.plist'));
|
|
120
|
+
expect(readFileSync(installed.unitPath, 'utf-8')).toContain('com.celilo.events');
|
|
121
|
+
expect(installed.nextSteps[0]).toContain('launchctl load');
|
|
122
|
+
|
|
123
|
+
const removed = uninstallDaemon({ platform: 'darwin', home });
|
|
124
|
+
expect(removed.removed).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('install is idempotent — second call overwrites with new content', () => {
|
|
128
|
+
const home = join(dir, 'home');
|
|
129
|
+
installDaemon({
|
|
130
|
+
platform: 'linux',
|
|
131
|
+
home,
|
|
132
|
+
celiloPath,
|
|
133
|
+
busDbPath: '/db1',
|
|
134
|
+
pollMs: 1000,
|
|
135
|
+
});
|
|
136
|
+
const second = installDaemon({
|
|
137
|
+
platform: 'linux',
|
|
138
|
+
home,
|
|
139
|
+
celiloPath,
|
|
140
|
+
busDbPath: '/db2',
|
|
141
|
+
pollMs: 500,
|
|
142
|
+
});
|
|
143
|
+
const written = readFileSync(second.unitPath, 'utf-8');
|
|
144
|
+
expect(written).toContain('EVENT_BUS_DB=/db2');
|
|
145
|
+
expect(written).toContain('--poll-ms 500');
|
|
146
|
+
expect(written).not.toContain('EVENT_BUS_DB=/db1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('uninstall on a missing unit reports not-removed', () => {
|
|
150
|
+
const home = join(dir, 'home');
|
|
151
|
+
const result = uninstallDaemon({ platform: 'linux', home });
|
|
152
|
+
expect(result.removed).toBe(false);
|
|
153
|
+
expect(result.nextSteps[0]).toContain('nothing to clean up');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('readInstalledUnit returns content when present, exists:false when not', () => {
|
|
157
|
+
const home = join(dir, 'home');
|
|
158
|
+
const before = readInstalledUnit({ platform: 'linux', home });
|
|
159
|
+
expect(before.exists).toBe(false);
|
|
160
|
+
|
|
161
|
+
installDaemon({
|
|
162
|
+
platform: 'linux',
|
|
163
|
+
home,
|
|
164
|
+
celiloPath,
|
|
165
|
+
busDbPath: '/db',
|
|
166
|
+
});
|
|
167
|
+
const after = readInstalledUnit({ platform: 'linux', home });
|
|
168
|
+
expect(after.exists).toBe(true);
|
|
169
|
+
if (after.exists) {
|
|
170
|
+
expect(after.content).toContain('events run');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects --celilo-path overrides that do not exist', () => {
|
|
175
|
+
expect(() =>
|
|
176
|
+
installDaemon({
|
|
177
|
+
platform: 'linux',
|
|
178
|
+
home: join(dir, 'home'),
|
|
179
|
+
celiloPath: '/no/such/celilo',
|
|
180
|
+
busDbPath: '/db',
|
|
181
|
+
}),
|
|
182
|
+
).toThrow(/does not exist/);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate and install / uninstall a per-user supervisor unit for the
|
|
3
|
+
* SQLite event-bus dispatcher. Linux gets a systemd user unit; macOS
|
|
4
|
+
* gets a launchd plist. The command WRITES the unit file but never
|
|
5
|
+
* touches the supervisor state — the operator runs `systemctl --user
|
|
6
|
+
* enable --now celilo-events` (or `launchctl load`) themselves so the
|
|
7
|
+
* effect is visible.
|
|
8
|
+
*
|
|
9
|
+
* Why split write-vs-enable: a celilo command silently flipping
|
|
10
|
+
* systemd state would be hard to reason about during incidents. Pure
|
|
11
|
+
* file-on-disk + printed next-steps keeps the operational surface
|
|
12
|
+
* predictable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { homedir, platform as nodePlatform } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { getEventBusPath } from '../config/paths';
|
|
19
|
+
|
|
20
|
+
export type SupervisorPlatform = 'linux' | 'darwin';
|
|
21
|
+
|
|
22
|
+
export interface InstallDaemonOptions {
|
|
23
|
+
celiloPath?: string;
|
|
24
|
+
pollMs?: number;
|
|
25
|
+
concurrency?: number;
|
|
26
|
+
/** Override platform detection. Mainly for tests. */
|
|
27
|
+
platform?: SupervisorPlatform;
|
|
28
|
+
/** Override the bus DB path. Defaults to celilo's getEventBusPath(). */
|
|
29
|
+
busDbPath?: string;
|
|
30
|
+
/** Override the home directory used to compute install paths. */
|
|
31
|
+
home?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InstallDaemonResult {
|
|
35
|
+
platform: SupervisorPlatform;
|
|
36
|
+
unitPath: string;
|
|
37
|
+
unitContent: string;
|
|
38
|
+
celiloPath: string;
|
|
39
|
+
busDbPath: string;
|
|
40
|
+
nextSteps: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UninstallDaemonResult {
|
|
44
|
+
platform: SupervisorPlatform;
|
|
45
|
+
unitPath: string;
|
|
46
|
+
removed: boolean;
|
|
47
|
+
nextSteps: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SYSTEMD_UNIT_NAME = 'celilo-events.service';
|
|
51
|
+
const LAUNCHD_LABEL = 'com.celilo.events';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map a node `process.platform` to one of the supported supervisor
|
|
55
|
+
* platforms. We don't ship Windows or BSD support for v1.
|
|
56
|
+
*/
|
|
57
|
+
export function detectPlatform(): SupervisorPlatform {
|
|
58
|
+
const p = nodePlatform();
|
|
59
|
+
if (p === 'linux') return 'linux';
|
|
60
|
+
if (p === 'darwin') return 'darwin';
|
|
61
|
+
throw new Error(
|
|
62
|
+
`celilo events daemon: unsupported platform "${p}". Only linux (systemd user) and darwin (launchd) are supported in v1.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve where to write the unit file for a given platform + user.
|
|
68
|
+
* User-mode locations only; system-wide is opt-in for a future phase.
|
|
69
|
+
*/
|
|
70
|
+
export function getDaemonUnitPath(platform: SupervisorPlatform, home: string): string {
|
|
71
|
+
if (platform === 'linux') {
|
|
72
|
+
return join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
|
|
73
|
+
}
|
|
74
|
+
return join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Find the celilo executable. Priority:
|
|
79
|
+
* 1. Explicit override
|
|
80
|
+
* 2. Bun.which('celilo')
|
|
81
|
+
* 3. error
|
|
82
|
+
*
|
|
83
|
+
* The unit file needs an absolute path because systemd does not resolve
|
|
84
|
+
* PATH for ExecStart and launchd is similarly literal.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveCeliloPath(override?: string): string {
|
|
87
|
+
if (override) {
|
|
88
|
+
if (!existsSync(override)) {
|
|
89
|
+
throw new Error(`celilo events daemon: --celilo-path "${override}" does not exist`);
|
|
90
|
+
}
|
|
91
|
+
return override;
|
|
92
|
+
}
|
|
93
|
+
// Bun.which returns null when not found.
|
|
94
|
+
const found = (Bun as unknown as { which: (cmd: string) => string | null }).which('celilo');
|
|
95
|
+
if (!found) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'celilo events daemon: could not find `celilo` on PATH. Install it globally (`bun add -g @celilo/cli`) or pass --celilo-path <path>.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface UnitInputs {
|
|
104
|
+
celiloPath: string;
|
|
105
|
+
busDbPath: string;
|
|
106
|
+
pollMs: number;
|
|
107
|
+
concurrency: number;
|
|
108
|
+
home: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderSystemdUnit(input: UnitInputs): string {
|
|
112
|
+
return `[Unit]
|
|
113
|
+
Description=Celilo SQLite Event Bus Dispatcher
|
|
114
|
+
Documentation=https://github.com/psbanka/infra/blob/main/design/SQLITE_EVENT_BUS.md
|
|
115
|
+
After=network.target
|
|
116
|
+
|
|
117
|
+
[Service]
|
|
118
|
+
Type=simple
|
|
119
|
+
ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --concurrency ${input.concurrency}
|
|
120
|
+
Restart=on-failure
|
|
121
|
+
RestartSec=10s
|
|
122
|
+
Environment=EVENT_BUS_DB=${input.busDbPath}
|
|
123
|
+
# stdout/stderr are captured by journalctl --user -u celilo-events.service.
|
|
124
|
+
StandardOutput=journal
|
|
125
|
+
StandardError=journal
|
|
126
|
+
|
|
127
|
+
[Install]
|
|
128
|
+
WantedBy=default.target
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderLaunchdPlist(input: UnitInputs): string {
|
|
133
|
+
const logDir = join(input.home, 'Library', 'Logs');
|
|
134
|
+
// Indentation matters very little to launchd but we want it readable
|
|
135
|
+
// when an operator opens the file manually to debug.
|
|
136
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
137
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
138
|
+
<plist version="1.0">
|
|
139
|
+
<dict>
|
|
140
|
+
<key>Label</key>
|
|
141
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
142
|
+
<key>ProgramArguments</key>
|
|
143
|
+
<array>
|
|
144
|
+
<string>${input.celiloPath}</string>
|
|
145
|
+
<string>events</string>
|
|
146
|
+
<string>run</string>
|
|
147
|
+
<string>--poll-ms</string>
|
|
148
|
+
<string>${input.pollMs}</string>
|
|
149
|
+
<string>--concurrency</string>
|
|
150
|
+
<string>${input.concurrency}</string>
|
|
151
|
+
</array>
|
|
152
|
+
<key>EnvironmentVariables</key>
|
|
153
|
+
<dict>
|
|
154
|
+
<key>EVENT_BUS_DB</key>
|
|
155
|
+
<string>${input.busDbPath}</string>
|
|
156
|
+
</dict>
|
|
157
|
+
<key>RunAtLoad</key>
|
|
158
|
+
<true/>
|
|
159
|
+
<key>KeepAlive</key>
|
|
160
|
+
<true/>
|
|
161
|
+
<key>StandardOutPath</key>
|
|
162
|
+
<string>${join(logDir, 'celilo-events.out.log')}</string>
|
|
163
|
+
<key>StandardErrorPath</key>
|
|
164
|
+
<string>${join(logDir, 'celilo-events.err.log')}</string>
|
|
165
|
+
</dict>
|
|
166
|
+
</plist>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Write the supervisor unit file. Idempotent: rewrites if present.
|
|
172
|
+
* Returns the path written + the operator-facing next steps.
|
|
173
|
+
*/
|
|
174
|
+
export function installDaemon(opts: InstallDaemonOptions = {}): InstallDaemonResult {
|
|
175
|
+
const platform = opts.platform ?? detectPlatform();
|
|
176
|
+
const home = opts.home ?? homedir();
|
|
177
|
+
const celiloPath = resolveCeliloPath(opts.celiloPath);
|
|
178
|
+
const busDbPath = opts.busDbPath ?? getEventBusPath();
|
|
179
|
+
const pollMs = opts.pollMs ?? 1000;
|
|
180
|
+
const concurrency = opts.concurrency ?? 4;
|
|
181
|
+
|
|
182
|
+
const unitPath = getDaemonUnitPath(platform, home);
|
|
183
|
+
const unitInputs: UnitInputs = { celiloPath, busDbPath, pollMs, concurrency, home };
|
|
184
|
+
const unitContent =
|
|
185
|
+
platform === 'linux' ? renderSystemdUnit(unitInputs) : renderLaunchdPlist(unitInputs);
|
|
186
|
+
|
|
187
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
188
|
+
writeFileSync(unitPath, unitContent, { mode: 0o644 });
|
|
189
|
+
|
|
190
|
+
const nextSteps =
|
|
191
|
+
platform === 'linux'
|
|
192
|
+
? [
|
|
193
|
+
'Reload systemd: systemctl --user daemon-reload',
|
|
194
|
+
`Enable + start: systemctl --user enable --now ${SYSTEMD_UNIT_NAME}`,
|
|
195
|
+
`Tail logs: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`,
|
|
196
|
+
`Status: systemctl --user status ${SYSTEMD_UNIT_NAME}`,
|
|
197
|
+
]
|
|
198
|
+
: [
|
|
199
|
+
`Load + start: launchctl load -w ${unitPath}`,
|
|
200
|
+
'Tail stdout: tail -f ~/Library/Logs/celilo-events.out.log',
|
|
201
|
+
'Tail stderr: tail -f ~/Library/Logs/celilo-events.err.log',
|
|
202
|
+
`Status: launchctl list | grep ${LAUNCHD_LABEL}`,
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
return { platform, unitPath, unitContent, celiloPath, busDbPath, nextSteps };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function uninstallDaemon(
|
|
209
|
+
opts: { platform?: SupervisorPlatform; home?: string } = {},
|
|
210
|
+
): UninstallDaemonResult {
|
|
211
|
+
const platform = opts.platform ?? detectPlatform();
|
|
212
|
+
const home = opts.home ?? homedir();
|
|
213
|
+
const unitPath = getDaemonUnitPath(platform, home);
|
|
214
|
+
|
|
215
|
+
let removed = false;
|
|
216
|
+
if (existsSync(unitPath)) {
|
|
217
|
+
unlinkSync(unitPath);
|
|
218
|
+
removed = true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const nextSteps =
|
|
222
|
+
platform === 'linux'
|
|
223
|
+
? removed
|
|
224
|
+
? [
|
|
225
|
+
`Disable + stop: systemctl --user disable --now ${SYSTEMD_UNIT_NAME}`,
|
|
226
|
+
'Reload systemd: systemctl --user daemon-reload',
|
|
227
|
+
]
|
|
228
|
+
: ['No unit file present; nothing to clean up.']
|
|
229
|
+
: removed
|
|
230
|
+
? [`Unload: launchctl unload ${unitPath}`]
|
|
231
|
+
: ['No plist present; nothing to clean up.'];
|
|
232
|
+
|
|
233
|
+
return { platform, unitPath, removed, nextSteps };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function readInstalledUnit(
|
|
237
|
+
opts: { platform?: SupervisorPlatform; home?: string } = {},
|
|
238
|
+
): { exists: true; path: string; content: string } | { exists: false; path: string } {
|
|
239
|
+
const platform = opts.platform ?? detectPlatform();
|
|
240
|
+
const home = opts.home ?? homedir();
|
|
241
|
+
const unitPath = getDaemonUnitPath(platform, home);
|
|
242
|
+
if (!existsSync(unitPath)) return { exists: false, path: unitPath };
|
|
243
|
+
return { exists: true, path: unitPath, content: readFileSync(unitPath, 'utf-8') };
|
|
244
|
+
}
|
|
@@ -244,10 +244,61 @@ async function invokeHookWithEnsureRetry(
|
|
|
244
244
|
* @param options - Deployment options
|
|
245
245
|
* @returns Deployment result with phase tracking
|
|
246
246
|
*/
|
|
247
|
+
/**
|
|
248
|
+
* Public entry point. Wraps the core deploy work with event-bus
|
|
249
|
+
* lifecycle emits (`deploy.started.<id>`, `deploy.completed.<id>`,
|
|
250
|
+
* `deploy.failed.<id>`) so subscribers — production smoke tests,
|
|
251
|
+
* alerting, etc. — can react. Bus emit failures are best-effort and
|
|
252
|
+
* never affect the deploy outcome.
|
|
253
|
+
*/
|
|
247
254
|
export async function deployModule(
|
|
248
255
|
moduleId: string,
|
|
249
256
|
db: DbClient,
|
|
250
257
|
options: DeployOptions = {},
|
|
258
|
+
): Promise<DeployResult> {
|
|
259
|
+
const startedAt = Date.now();
|
|
260
|
+
const { emitDeployCompleted, emitDeployFailed, emitDeployStarted, emitHealthCheckFailed } =
|
|
261
|
+
await import('./celilo-events');
|
|
262
|
+
|
|
263
|
+
emitDeployStarted({ module: moduleId, startedAt });
|
|
264
|
+
|
|
265
|
+
let result: DeployResult;
|
|
266
|
+
try {
|
|
267
|
+
result = await deployModuleImpl(moduleId, db, options);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
270
|
+
emitDeployFailed({
|
|
271
|
+
module: moduleId,
|
|
272
|
+
startedAt,
|
|
273
|
+
durationMs: Date.now() - startedAt,
|
|
274
|
+
error,
|
|
275
|
+
});
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const durationMs = Date.now() - startedAt;
|
|
280
|
+
if (result.success) {
|
|
281
|
+
emitDeployCompleted({ module: moduleId, startedAt, durationMs });
|
|
282
|
+
} else {
|
|
283
|
+
emitDeployFailed({
|
|
284
|
+
module: moduleId,
|
|
285
|
+
startedAt,
|
|
286
|
+
durationMs,
|
|
287
|
+
error: result.error ?? 'unknown error',
|
|
288
|
+
});
|
|
289
|
+
// Health-check failures are a sub-class worth surfacing on their own
|
|
290
|
+
// channel so subscribers can target them without parsing error strings.
|
|
291
|
+
if (result.error?.toLowerCase().includes('health check')) {
|
|
292
|
+
emitHealthCheckFailed({ module: moduleId, reason: result.error });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function deployModuleImpl(
|
|
299
|
+
moduleId: string,
|
|
300
|
+
db: DbClient,
|
|
301
|
+
options: DeployOptions = {},
|
|
251
302
|
): Promise<DeployResult> {
|
|
252
303
|
const phases: DeployResult['phases'] = {};
|
|
253
304
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
6
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
7
|
+
import {
|
|
8
|
+
registerModuleSubscriptions,
|
|
9
|
+
resolveSubscription,
|
|
10
|
+
unregisterModuleSubscriptions,
|
|
11
|
+
} from './module-subscriptions';
|
|
12
|
+
|
|
13
|
+
const baseManifest = (overrides: Partial<ModuleManifest> = {}): ModuleManifest => ({
|
|
14
|
+
celilo_contract: '1.0',
|
|
15
|
+
id: 'lunacycle',
|
|
16
|
+
name: 'Lunacycle',
|
|
17
|
+
version: '1.0.0',
|
|
18
|
+
requires: { capabilities: [] },
|
|
19
|
+
provides: { capabilities: [] },
|
|
20
|
+
variables: { owns: [], imports: [] },
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('resolveSubscription', () => {
|
|
25
|
+
it('substitutes $self in pattern and ${MODULE_PATH} in handler', () => {
|
|
26
|
+
const resolved = resolveSubscription(
|
|
27
|
+
{
|
|
28
|
+
name: 'smoke-after-deploy',
|
|
29
|
+
pattern: 'deploy.completed.$self',
|
|
30
|
+
handler: 'bun ${MODULE_PATH}/celilo/scripts/smoke.ts',
|
|
31
|
+
},
|
|
32
|
+
'lunacycle',
|
|
33
|
+
'/var/lib/celilo/modules/lunacycle',
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(resolved.name).toBe('lunacycle.smoke-after-deploy');
|
|
37
|
+
expect(resolved.pattern).toBe('deploy.completed.lunacycle');
|
|
38
|
+
expect(resolved.handler).toBe('bun /var/lib/celilo/modules/lunacycle/celilo/scripts/smoke.ts');
|
|
39
|
+
expect(resolved.registeredBy).toBe('lunacycle');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('only substitutes $self when followed by . or end-of-string', () => {
|
|
43
|
+
// `$selfish` would not be a real pattern but we want to ensure the
|
|
44
|
+
// substitution doesn't accidentally rewrite identifier-like names.
|
|
45
|
+
const resolved = resolveSubscription(
|
|
46
|
+
{
|
|
47
|
+
name: 'a',
|
|
48
|
+
pattern: '$self.x.$selfish',
|
|
49
|
+
handler: 'echo',
|
|
50
|
+
},
|
|
51
|
+
'foo',
|
|
52
|
+
'/p',
|
|
53
|
+
);
|
|
54
|
+
expect(resolved.pattern).toBe('foo.x.$selfish');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('passes through max_attempts and timeout_ms when set', () => {
|
|
58
|
+
const resolved = resolveSubscription(
|
|
59
|
+
{
|
|
60
|
+
name: 'a',
|
|
61
|
+
pattern: 'x',
|
|
62
|
+
handler: 'echo',
|
|
63
|
+
max_attempts: 5,
|
|
64
|
+
timeout_ms: 90000,
|
|
65
|
+
},
|
|
66
|
+
'foo',
|
|
67
|
+
'/p',
|
|
68
|
+
);
|
|
69
|
+
expect(resolved.maxAttempts).toBe(5);
|
|
70
|
+
expect(resolved.timeoutMs).toBe(90000);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('register / unregister roundtrip', () => {
|
|
75
|
+
let dir: string;
|
|
76
|
+
let dbPath: string;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
dir = mkdtempSync(join(tmpdir(), 'modsubs-test-'));
|
|
80
|
+
dbPath = join(dir, 'events.db');
|
|
81
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
82
|
+
});
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
85
|
+
try {
|
|
86
|
+
rmSync(dir, { recursive: true, force: true });
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('manifest with no subscriptions is a no-op', () => {
|
|
93
|
+
const result = registerModuleSubscriptions(baseManifest(), '/p');
|
|
94
|
+
expect(result.registered).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('registers each subscription as a row, names scoped to module id', () => {
|
|
98
|
+
const result = registerModuleSubscriptions(
|
|
99
|
+
baseManifest({
|
|
100
|
+
subscriptions: [
|
|
101
|
+
{
|
|
102
|
+
name: 'smoke',
|
|
103
|
+
pattern: 'deploy.completed.$self',
|
|
104
|
+
handler: 'bun ${MODULE_PATH}/x.ts',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'on-cert-rotated',
|
|
108
|
+
pattern: 'cert.rotated',
|
|
109
|
+
handler: 'echo',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
'/var/lib/celilo/modules/lunacycle',
|
|
114
|
+
);
|
|
115
|
+
expect(result.registered).toBe(2);
|
|
116
|
+
|
|
117
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
118
|
+
try {
|
|
119
|
+
const rows = bus.db
|
|
120
|
+
.query<{ name: string; pattern: string; handler: string }, []>(
|
|
121
|
+
'SELECT name, pattern, handler FROM subscribers ORDER BY name',
|
|
122
|
+
)
|
|
123
|
+
.all();
|
|
124
|
+
expect(rows).toEqual([
|
|
125
|
+
{
|
|
126
|
+
name: 'lunacycle.on-cert-rotated',
|
|
127
|
+
pattern: 'cert.rotated',
|
|
128
|
+
handler: 'echo',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'lunacycle.smoke',
|
|
132
|
+
pattern: 'deploy.completed.lunacycle',
|
|
133
|
+
handler: 'bun /var/lib/celilo/modules/lunacycle/x.ts',
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
} finally {
|
|
137
|
+
bus.close();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('re-registering the same manifest is idempotent (updates rows in place)', () => {
|
|
142
|
+
const m = baseManifest({
|
|
143
|
+
subscriptions: [{ name: 's', pattern: 'a', handler: 'echo first' }],
|
|
144
|
+
});
|
|
145
|
+
registerModuleSubscriptions(m, '/p');
|
|
146
|
+
// Edit the in-memory manifest and re-register; same name, new handler.
|
|
147
|
+
if (!m.subscriptions) throw new Error('subscriptions missing');
|
|
148
|
+
m.subscriptions[0].handler = 'echo second';
|
|
149
|
+
registerModuleSubscriptions(m, '/p');
|
|
150
|
+
|
|
151
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
152
|
+
try {
|
|
153
|
+
const rows = bus.db
|
|
154
|
+
.query<{ count: number }, []>('SELECT COUNT(*) AS count FROM subscribers')
|
|
155
|
+
.get();
|
|
156
|
+
expect(rows?.count).toBe(1);
|
|
157
|
+
const row = bus.db.query<{ handler: string }, []>('SELECT handler FROM subscribers').get();
|
|
158
|
+
expect(row?.handler).toBe('echo second');
|
|
159
|
+
} finally {
|
|
160
|
+
bus.close();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('unregister removes all subscriptions for the module, scoped by name prefix', () => {
|
|
165
|
+
registerModuleSubscriptions(
|
|
166
|
+
baseManifest({
|
|
167
|
+
id: 'lunacycle',
|
|
168
|
+
subscriptions: [
|
|
169
|
+
{ name: 'a', pattern: 'x.$self', handler: 'echo' },
|
|
170
|
+
{ name: 'b', pattern: 'y.$self', handler: 'echo' },
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
'/p',
|
|
174
|
+
);
|
|
175
|
+
// Another module's subs should NOT be touched.
|
|
176
|
+
registerModuleSubscriptions(
|
|
177
|
+
baseManifest({
|
|
178
|
+
id: 'authentik',
|
|
179
|
+
subscriptions: [{ name: 'a', pattern: 'z.$self', handler: 'echo' }],
|
|
180
|
+
}),
|
|
181
|
+
'/p',
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const result = unregisterModuleSubscriptions('lunacycle');
|
|
185
|
+
expect(result.unregistered).toBe(2);
|
|
186
|
+
|
|
187
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
188
|
+
try {
|
|
189
|
+
const rows = bus.db
|
|
190
|
+
.query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
|
|
191
|
+
.all();
|
|
192
|
+
expect(rows).toEqual([{ name: 'authentik.a' }]);
|
|
193
|
+
} finally {
|
|
194
|
+
bus.close();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|