@celilo/cli 0.2.1 → 0.3.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/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 +19 -0
- package/src/manifest/schema.ts +34 -0
- package/src/manifest/validate.test.ts +75 -0
- package/src/module/import.ts +15 -0
- 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,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=CELILO_EVENT_BUS_PATH=${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>CELILO_EVENT_BUS_PATH</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.CELILO_EVENT_BUS_PATH = dbPath;
|
|
82
|
+
});
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
process.env.CELILO_EVENT_BUS_PATH = 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
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire a module's manifest `subscriptions:` block into the SQLite event
|
|
3
|
+
* bus. Called from `module/import.ts` on install and from
|
|
4
|
+
* `cli/commands/module-remove.ts` on remove.
|
|
5
|
+
*
|
|
6
|
+
* Substitutions performed at subscribe time:
|
|
7
|
+
* - `$self` in `pattern` → the module's id
|
|
8
|
+
* - `${MODULE_PATH}` in `handler` → the module's installed targetPath
|
|
9
|
+
*
|
|
10
|
+
* The bus subscriber's name is namespaced as `<module-id>.<sub-name>`
|
|
11
|
+
* so two modules can declare a subscription named `smoke` without
|
|
12
|
+
* colliding.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
16
|
+
import { getEventBusPath } from '../config/paths';
|
|
17
|
+
import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The bus is opened by the celilo CLI without an event registry — the
|
|
21
|
+
* CLI doesn't know the schemas of every module's events. The bus's
|
|
22
|
+
* empty-registry path skips payload validation, leaving that to the
|
|
23
|
+
* linked handlers (which open the bus *with* their own registry).
|
|
24
|
+
*/
|
|
25
|
+
const NO_SCHEMAS = defineEvents({});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the per-module substitutions on a single subscription. Pure
|
|
29
|
+
* function: takes a parsed manifest entry, returns the bus-shaped
|
|
30
|
+
* subscribe options.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveSubscription(
|
|
33
|
+
sub: ModuleSubscription,
|
|
34
|
+
moduleId: string,
|
|
35
|
+
modulePath: string,
|
|
36
|
+
): {
|
|
37
|
+
name: string;
|
|
38
|
+
pattern: string;
|
|
39
|
+
handler: string;
|
|
40
|
+
maxAttempts?: number;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
registeredBy: string;
|
|
43
|
+
} {
|
|
44
|
+
return {
|
|
45
|
+
name: scopedName(moduleId, sub.name),
|
|
46
|
+
pattern: substituteSelf(sub.pattern, moduleId),
|
|
47
|
+
handler: substituteModulePath(sub.handler, modulePath),
|
|
48
|
+
maxAttempts: sub.max_attempts,
|
|
49
|
+
timeoutMs: sub.timeout_ms,
|
|
50
|
+
registeredBy: moduleId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register all of a module's subscriptions on the bus. Idempotent —
|
|
56
|
+
* re-running with the same manifest updates existing rows in place.
|
|
57
|
+
*
|
|
58
|
+
* If the module's manifest declares no subscriptions, this is a
|
|
59
|
+
* cheap no-op (the bus DB isn't even touched).
|
|
60
|
+
*/
|
|
61
|
+
export function registerModuleSubscriptions(
|
|
62
|
+
manifest: ModuleManifest,
|
|
63
|
+
modulePath: string,
|
|
64
|
+
): { registered: number } {
|
|
65
|
+
const subs = manifest.subscriptions ?? [];
|
|
66
|
+
if (subs.length === 0) return { registered: 0 };
|
|
67
|
+
|
|
68
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
69
|
+
try {
|
|
70
|
+
for (const sub of subs) {
|
|
71
|
+
const resolved = resolveSubscription(sub, manifest.id, modulePath);
|
|
72
|
+
bus.subscribe(resolved);
|
|
73
|
+
}
|
|
74
|
+
return { registered: subs.length };
|
|
75
|
+
} finally {
|
|
76
|
+
bus.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tear down every bus subscription that belongs to this module. Looks
|
|
82
|
+
* up rows by name prefix `<module-id>.` rather than rereading the
|
|
83
|
+
* old manifest, so a stale subscription left behind by a manifest
|
|
84
|
+
* change still gets cleaned up.
|
|
85
|
+
*
|
|
86
|
+
* Best-effort: if the bus DB doesn't exist (never opened), returns 0.
|
|
87
|
+
*/
|
|
88
|
+
export function unregisterModuleSubscriptions(moduleId: string): {
|
|
89
|
+
unregistered: number;
|
|
90
|
+
} {
|
|
91
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
92
|
+
try {
|
|
93
|
+
const likePattern = `${moduleId}.%`;
|
|
94
|
+
const rows = bus.db
|
|
95
|
+
.query<{ name: string }, [string]>('SELECT name FROM subscribers WHERE name LIKE ?')
|
|
96
|
+
.all(likePattern);
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
bus.unsubscribe(row.name);
|
|
99
|
+
}
|
|
100
|
+
return { unregistered: rows.length };
|
|
101
|
+
} finally {
|
|
102
|
+
bus.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function scopedName(moduleId: string, subName: string): string {
|
|
107
|
+
return `${moduleId}.${subName}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function substituteSelf(pattern: string, moduleId: string): string {
|
|
111
|
+
// `$self` matches when followed by a dot or end-of-string, so a
|
|
112
|
+
// pattern like `deploy.$self.foo` substitutes correctly without
|
|
113
|
+
// confusing `$selfish` if anyone wrote that. (No real reason they
|
|
114
|
+
// would, but be precise.)
|
|
115
|
+
return pattern.replace(/\$self(?=\.|$)/g, moduleId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function substituteModulePath(handler: string, modulePath: string): string {
|
|
119
|
+
return handler.replace(/\$\{MODULE_PATH\}/g, modulePath);
|
|
120
|
+
}
|