@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
package/src/cli/index.ts
CHANGED
|
@@ -10,6 +10,21 @@ import { COMMANDS, type CommandDef } from './command-registry';
|
|
|
10
10
|
import { handleCapabilityInfo } from './commands/capability-info';
|
|
11
11
|
import { handleCapabilityList } from './commands/capability-list';
|
|
12
12
|
import { handleCompletion } from './commands/completion';
|
|
13
|
+
import {
|
|
14
|
+
handleEventsAck,
|
|
15
|
+
handleEventsDrain,
|
|
16
|
+
handleEventsEmit,
|
|
17
|
+
handleEventsFail,
|
|
18
|
+
handleEventsInstallDaemon,
|
|
19
|
+
handleEventsListPending,
|
|
20
|
+
handleEventsListSubscribers,
|
|
21
|
+
handleEventsRepair,
|
|
22
|
+
handleEventsRun,
|
|
23
|
+
handleEventsShowDaemon,
|
|
24
|
+
handleEventsStatus,
|
|
25
|
+
handleEventsTail,
|
|
26
|
+
handleEventsUninstallDaemon,
|
|
27
|
+
} from './commands/events';
|
|
13
28
|
import { handleHookRun } from './commands/hook-run';
|
|
14
29
|
import {
|
|
15
30
|
handleIpamIpListReservations,
|
|
@@ -212,6 +227,45 @@ Related Commands:
|
|
|
212
227
|
/**
|
|
213
228
|
* Display capability command help
|
|
214
229
|
*/
|
|
230
|
+
function displayEventsHelp(): CommandResult {
|
|
231
|
+
const helpText = `
|
|
232
|
+
Celilo - SQLite Event Bus
|
|
233
|
+
|
|
234
|
+
Usage:
|
|
235
|
+
celilo events <subcommand> [args...]
|
|
236
|
+
|
|
237
|
+
Subcommands:
|
|
238
|
+
status Print bus health as JSON
|
|
239
|
+
tail [--type T] [--limit N] Recent events as JSON
|
|
240
|
+
list-subscribers List persistent bus subscribers
|
|
241
|
+
list-pending [--subscriber] List pending deliveries
|
|
242
|
+
drain [--concurrency N] Process pending deliveries once and return
|
|
243
|
+
run [--poll-ms N] Run the long-running dispatcher (foreground)
|
|
244
|
+
emit <type> [<payload>] Emit an event (operator/test path)
|
|
245
|
+
ack <event_id> Mark a running delivery succeeded
|
|
246
|
+
fail <event_id> --error MSG Mark a running delivery failed
|
|
247
|
+
repair Crash-recovery sweep without starting the dispatcher
|
|
248
|
+
resume Alias for repair (acknowledges halt-on-recovery)
|
|
249
|
+
install-daemon Write a systemd/launchd user unit for the dispatcher
|
|
250
|
+
uninstall-daemon Remove the installed supervisor unit
|
|
251
|
+
show-daemon Print the currently installed unit file
|
|
252
|
+
|
|
253
|
+
Description:
|
|
254
|
+
The event bus is a SQLite-backed pub/sub layer for celilo modules.
|
|
255
|
+
Modules declare \`subscriptions:\` in their manifests; \`celilo module deploy\`
|
|
256
|
+
emits lifecycle events that subscribers react to.
|
|
257
|
+
|
|
258
|
+
See infra/design/SQLITE_EVENT_BUS.md for the full design.
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
celilo events run # foreground dispatcher
|
|
262
|
+
celilo events status # is anything stuck?
|
|
263
|
+
celilo events tail --type deploy.completed.lunacycle # filter by type
|
|
264
|
+
celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
|
|
265
|
+
`;
|
|
266
|
+
return { success: true, message: helpText.trim() };
|
|
267
|
+
}
|
|
268
|
+
|
|
215
269
|
function displayCapabilityHelp(): CommandResult {
|
|
216
270
|
const helpText = `
|
|
217
271
|
Celilo - Capability Management
|
|
@@ -918,6 +972,55 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
918
972
|
};
|
|
919
973
|
}
|
|
920
974
|
|
|
975
|
+
if (parsed.command === 'events') {
|
|
976
|
+
if (parsed.flags.help || parsed.flags.h) {
|
|
977
|
+
return displayEventsHelp();
|
|
978
|
+
}
|
|
979
|
+
if (!parsed.subcommand) {
|
|
980
|
+
return {
|
|
981
|
+
success: false,
|
|
982
|
+
error: 'Events subcommand required\n\nRun "celilo events --help" for usage',
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
const evFlagError = checkFlags('events', parsed.subcommand, parsed.flags, parsed.args);
|
|
986
|
+
if (evFlagError) return evFlagError;
|
|
987
|
+
|
|
988
|
+
switch (parsed.subcommand) {
|
|
989
|
+
case 'status':
|
|
990
|
+
return handleEventsStatus();
|
|
991
|
+
case 'tail':
|
|
992
|
+
return handleEventsTail(parsed.args, parsed.flags);
|
|
993
|
+
case 'list-subscribers':
|
|
994
|
+
return handleEventsListSubscribers();
|
|
995
|
+
case 'list-pending':
|
|
996
|
+
return handleEventsListPending(parsed.args, parsed.flags);
|
|
997
|
+
case 'drain':
|
|
998
|
+
return handleEventsDrain(parsed.args, parsed.flags);
|
|
999
|
+
case 'run':
|
|
1000
|
+
return handleEventsRun(parsed.args, parsed.flags);
|
|
1001
|
+
case 'emit':
|
|
1002
|
+
return handleEventsEmit(parsed.args, parsed.flags);
|
|
1003
|
+
case 'ack':
|
|
1004
|
+
return handleEventsAck(parsed.args, parsed.flags);
|
|
1005
|
+
case 'fail':
|
|
1006
|
+
return handleEventsFail(parsed.args, parsed.flags);
|
|
1007
|
+
case 'repair':
|
|
1008
|
+
case 'resume':
|
|
1009
|
+
return handleEventsRepair();
|
|
1010
|
+
case 'install-daemon':
|
|
1011
|
+
return handleEventsInstallDaemon(parsed.args, parsed.flags);
|
|
1012
|
+
case 'uninstall-daemon':
|
|
1013
|
+
return handleEventsUninstallDaemon();
|
|
1014
|
+
case 'show-daemon':
|
|
1015
|
+
return handleEventsShowDaemon();
|
|
1016
|
+
default:
|
|
1017
|
+
return {
|
|
1018
|
+
success: false,
|
|
1019
|
+
error: `Unknown events subcommand: ${parsed.subcommand}\n\nRun "celilo events --help" for usage`,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
921
1024
|
if (parsed.command === 'module') {
|
|
922
1025
|
// Handle module --help
|
|
923
1026
|
if (parsed.flags.help || parsed.flags.h) {
|
package/src/config/paths.ts
CHANGED
|
@@ -92,6 +92,26 @@ export function getDbPath(): string {
|
|
|
92
92
|
return join(getDataDir(), 'celilo.db');
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Get the SQLite event-bus database file path.
|
|
97
|
+
*
|
|
98
|
+
* Priority:
|
|
99
|
+
* 1. EVENT_BUS_DB environment variable (the bus library's native name —
|
|
100
|
+
* used by the event-bus CLI, defineHandler, and any standalone tool)
|
|
101
|
+
* 2. <data-dir>/events.db (platform-specific)
|
|
102
|
+
*
|
|
103
|
+
* Kept separate from the main celilo.db so the bus library remains
|
|
104
|
+
* standalone — the bus owns its own schema and migrations.
|
|
105
|
+
*
|
|
106
|
+
* @returns Absolute path to event-bus database
|
|
107
|
+
*/
|
|
108
|
+
export function getEventBusPath(): string {
|
|
109
|
+
if (process.env.EVENT_BUS_DB) {
|
|
110
|
+
return process.env.EVENT_BUS_DB;
|
|
111
|
+
}
|
|
112
|
+
return join(getDataDir(), 'events.db');
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
/**
|
|
96
116
|
* Shorten a path by replacing the celilo data directory with $CELILO_DATA
|
|
97
117
|
* For display purposes in CLI output.
|
package/src/manifest/schema.ts
CHANGED
|
@@ -260,6 +260,32 @@ export const AnsibleCollectionSchema = z.object({
|
|
|
260
260
|
reason: z.string().optional(),
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Event-bus subscription declaration in a module manifest.
|
|
265
|
+
*
|
|
266
|
+
* Each entry becomes a row in the bus's `subscribers` table when the
|
|
267
|
+
* module is imported, and is removed when the module is removed.
|
|
268
|
+
*
|
|
269
|
+
* Substitutions resolved at subscribe time:
|
|
270
|
+
* - `$self` in `pattern` → the module's id
|
|
271
|
+
* - `${MODULE_PATH}` in `handler` → the module's installed path
|
|
272
|
+
*/
|
|
273
|
+
export const ModuleSubscriptionSchema = z.object({
|
|
274
|
+
name: z
|
|
275
|
+
.string()
|
|
276
|
+
.min(1)
|
|
277
|
+
.regex(
|
|
278
|
+
/^[a-z][a-z0-9_-]*$/,
|
|
279
|
+
'Subscription name must be kebab/snake_case (lowercase, alphanumeric, dash/underscore)',
|
|
280
|
+
),
|
|
281
|
+
pattern: z.string().min(1),
|
|
282
|
+
handler: z.string().min(1),
|
|
283
|
+
max_attempts: z.number().int().positive().optional(),
|
|
284
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
export type ModuleSubscription = z.infer<typeof ModuleSubscriptionSchema>;
|
|
288
|
+
|
|
263
289
|
/**
|
|
264
290
|
* Module manifest schema (v2 shape).
|
|
265
291
|
*
|
|
@@ -424,6 +450,14 @@ export const ModuleManifestSchema = z
|
|
|
424
450
|
tests_dir: z.string().min(1),
|
|
425
451
|
})
|
|
426
452
|
.optional(),
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Event-bus subscriptions this module wants registered. See
|
|
456
|
+
* `infra/design/SQLITE_EVENT_BUS.md`. Truly optional so existing
|
|
457
|
+
* test fixtures and manifests don't need a churn-pass; readers
|
|
458
|
+
* use `manifest.subscriptions ?? []`.
|
|
459
|
+
*/
|
|
460
|
+
subscriptions: z.array(ModuleSubscriptionSchema).optional(),
|
|
427
461
|
})
|
|
428
462
|
.strict();
|
|
429
463
|
|
|
@@ -1246,3 +1246,78 @@ secrets:
|
|
|
1246
1246
|
expect(result.success).toBe(false);
|
|
1247
1247
|
});
|
|
1248
1248
|
});
|
|
1249
|
+
|
|
1250
|
+
describe('subscriptions field', () => {
|
|
1251
|
+
test('manifests without subscriptions still validate', () => {
|
|
1252
|
+
const yaml = `
|
|
1253
|
+
${CONTRACT_LINE}
|
|
1254
|
+
id: test
|
|
1255
|
+
name: Test
|
|
1256
|
+
version: 1.0.0
|
|
1257
|
+
`;
|
|
1258
|
+
const result = validateManifest(yaml);
|
|
1259
|
+
expect(result.success).toBe(true);
|
|
1260
|
+
if (result.success) {
|
|
1261
|
+
expect(result.data.subscriptions).toBeUndefined();
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test('parses a valid subscriptions block', () => {
|
|
1266
|
+
const yaml = `
|
|
1267
|
+
${CONTRACT_LINE}
|
|
1268
|
+
id: test
|
|
1269
|
+
name: Test
|
|
1270
|
+
version: 1.0.0
|
|
1271
|
+
|
|
1272
|
+
subscriptions:
|
|
1273
|
+
- name: smoke-after-deploy
|
|
1274
|
+
pattern: deploy.completed.$self
|
|
1275
|
+
handler: bun \${MODULE_PATH}/celilo/scripts/smoke.ts
|
|
1276
|
+
timeout_ms: 120000
|
|
1277
|
+
- name: cert-rotated
|
|
1278
|
+
pattern: cert.rotated
|
|
1279
|
+
handler: echo
|
|
1280
|
+
max_attempts: 5
|
|
1281
|
+
`;
|
|
1282
|
+
const result = validateManifest(yaml);
|
|
1283
|
+
expect(result.success).toBe(true);
|
|
1284
|
+
if (result.success) {
|
|
1285
|
+
expect(result.data.subscriptions).toHaveLength(2);
|
|
1286
|
+
expect(result.data.subscriptions?.[0].name).toBe('smoke-after-deploy');
|
|
1287
|
+
expect(result.data.subscriptions?.[0].timeout_ms).toBe(120000);
|
|
1288
|
+
expect(result.data.subscriptions?.[1].max_attempts).toBe(5);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
test('rejects subscription names with invalid characters', () => {
|
|
1293
|
+
const yaml = `
|
|
1294
|
+
${CONTRACT_LINE}
|
|
1295
|
+
id: test
|
|
1296
|
+
name: Test
|
|
1297
|
+
version: 1.0.0
|
|
1298
|
+
|
|
1299
|
+
subscriptions:
|
|
1300
|
+
- name: SmokeAfterDeploy
|
|
1301
|
+
pattern: deploy.$self
|
|
1302
|
+
handler: echo
|
|
1303
|
+
`;
|
|
1304
|
+
const result = validateManifest(yaml);
|
|
1305
|
+
expect(result.success).toBe(false);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test('requires pattern and handler to be non-empty', () => {
|
|
1309
|
+
const yaml = `
|
|
1310
|
+
${CONTRACT_LINE}
|
|
1311
|
+
id: test
|
|
1312
|
+
name: Test
|
|
1313
|
+
version: 1.0.0
|
|
1314
|
+
|
|
1315
|
+
subscriptions:
|
|
1316
|
+
- name: smoke
|
|
1317
|
+
pattern: ""
|
|
1318
|
+
handler: echo
|
|
1319
|
+
`;
|
|
1320
|
+
const result = validateManifest(yaml);
|
|
1321
|
+
expect(result.success).toBe(false);
|
|
1322
|
+
});
|
|
1323
|
+
});
|
package/src/module/import.ts
CHANGED
|
@@ -23,6 +23,25 @@ import {
|
|
|
23
23
|
import { parseJsonWithValidation } from '../validation/schemas';
|
|
24
24
|
import { cleanupTempDir, extractPackage, verifyPackageIntegrity } from './packaging/extract';
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Phase-timing helper for `celilo module import`. Set CELILO_IMPORT_DEBUG=1
|
|
28
|
+
* to get a phase-by-phase breakdown to stderr — useful when an import is
|
|
29
|
+
* inexplicably slow (e.g. a Docker bind-mount fsync amplification, a stale
|
|
30
|
+
* lockfile triggering a fresh bun install, etc.).
|
|
31
|
+
*
|
|
32
|
+
* Zero overhead when disabled: the wrapper just calls the inner fn.
|
|
33
|
+
*/
|
|
34
|
+
async function timedPhase<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
|
|
35
|
+
if (!process.env.CELILO_IMPORT_DEBUG) return await fn();
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
try {
|
|
38
|
+
return await fn();
|
|
39
|
+
} finally {
|
|
40
|
+
const ms = Date.now() - start;
|
|
41
|
+
process.stderr.write(`[import-timing] ${label.padEnd(36)} ${ms}ms\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/**
|
|
27
46
|
* Module import options
|
|
28
47
|
*/
|
|
@@ -547,7 +566,7 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
547
566
|
|
|
548
567
|
// Execution: Copy files
|
|
549
568
|
try {
|
|
550
|
-
await copyModuleFiles(actualSourcePath, targetPath);
|
|
569
|
+
await timedPhase('copyModuleFiles', () => copyModuleFiles(actualSourcePath, targetPath));
|
|
551
570
|
} catch (error) {
|
|
552
571
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
553
572
|
return {
|
|
@@ -563,7 +582,9 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
563
582
|
// If no package.json exists but hook scripts do, auto-generate one with
|
|
564
583
|
// just the framework dep — smooths migration for existing modules.
|
|
565
584
|
try {
|
|
566
|
-
await installScriptDependencies
|
|
585
|
+
await timedPhase('installScriptDependencies', () =>
|
|
586
|
+
installScriptDependencies(targetPath, manifest),
|
|
587
|
+
);
|
|
567
588
|
} catch (error) {
|
|
568
589
|
// Non-fatal: the module is importable without deps, but hooks
|
|
569
590
|
// will fail at runtime. Warn and continue.
|
|
@@ -574,7 +595,7 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
574
595
|
|
|
575
596
|
// Execution: Insert to database
|
|
576
597
|
try {
|
|
577
|
-
await insertModuleToDb(manifest, targetPath, db);
|
|
598
|
+
await timedPhase('insertModuleToDb', () => insertModuleToDb(manifest, targetPath, db));
|
|
578
599
|
} catch (error) {
|
|
579
600
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
580
601
|
return {
|
|
@@ -586,8 +607,10 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
586
607
|
|
|
587
608
|
// Execution: Register capabilities if module provides them
|
|
588
609
|
if (manifest.provides?.capabilities && manifest.provides.capabilities.length > 0) {
|
|
589
|
-
const
|
|
590
|
-
|
|
610
|
+
const capResult = await timedPhase('registerModuleCapabilities', async () => {
|
|
611
|
+
const { registerModuleCapabilities } = await import('../capabilities/registration');
|
|
612
|
+
return registerModuleCapabilities(manifest.id, manifest, db.$client, flags);
|
|
613
|
+
});
|
|
591
614
|
|
|
592
615
|
if (!capResult.success) {
|
|
593
616
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
@@ -599,6 +622,23 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
599
622
|
}
|
|
600
623
|
}
|
|
601
624
|
|
|
625
|
+
// Execution: Register event-bus subscriptions declared in the manifest.
|
|
626
|
+
// Best-effort: a bus problem shouldn't wedge an import — the operator
|
|
627
|
+
// can re-run the import after fixing the bus, and bus.subscribe is
|
|
628
|
+
// idempotent.
|
|
629
|
+
try {
|
|
630
|
+
await timedPhase('registerModuleSubscriptions', async () => {
|
|
631
|
+
const { registerModuleSubscriptions } = await import('../services/module-subscriptions');
|
|
632
|
+
registerModuleSubscriptions(manifest, targetPath);
|
|
633
|
+
});
|
|
634
|
+
} catch (error) {
|
|
635
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
636
|
+
log.warn(`Failed to register event-bus subscriptions: ${msg}`);
|
|
637
|
+
log.warn(
|
|
638
|
+
'Module imported, but reactive flows on the event bus will not fire until this is fixed.',
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
602
642
|
// Execution: Store integrity data
|
|
603
643
|
// For .netapp packages: store checksums + signature
|
|
604
644
|
// For directory imports: calculate and store checksums (no signature)
|
|
@@ -0,0 +1,98 @@
|
|
|
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 {
|
|
7
|
+
emitDeployCompleted,
|
|
8
|
+
emitDeployFailed,
|
|
9
|
+
emitDeployStarted,
|
|
10
|
+
emitHealthCheckFailed,
|
|
11
|
+
} from './celilo-events';
|
|
12
|
+
|
|
13
|
+
describe('celilo lifecycle events', () => {
|
|
14
|
+
let dir: string;
|
|
15
|
+
let dbPath: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-events-test-'));
|
|
19
|
+
dbPath = join(dir, 'events.db');
|
|
20
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
24
|
+
try {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
/* ignore */
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function recentEvents() {
|
|
32
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
33
|
+
try {
|
|
34
|
+
return bus.recentEvents({ limit: 100 });
|
|
35
|
+
} finally {
|
|
36
|
+
bus.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('emits deploy.started.<module> with expected payload', () => {
|
|
41
|
+
emitDeployStarted({ module: 'lunacycle', startedAt: 1234 });
|
|
42
|
+
const events = recentEvents();
|
|
43
|
+
expect(events).toHaveLength(1);
|
|
44
|
+
expect(events[0].type).toBe('deploy.started.lunacycle');
|
|
45
|
+
expect(events[0].payload).toEqual({ module: 'lunacycle', startedAt: 1234 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('emits deploy.completed.<module> with duration', () => {
|
|
49
|
+
emitDeployCompleted({ module: 'authentik', startedAt: 1000, durationMs: 5500 });
|
|
50
|
+
const events = recentEvents();
|
|
51
|
+
expect(events[0].type).toBe('deploy.completed.authentik');
|
|
52
|
+
expect((events[0].payload as { durationMs: number }).durationMs).toBe(5500);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('emits deploy.failed.<module> with error', () => {
|
|
56
|
+
emitDeployFailed({
|
|
57
|
+
module: 'authentik',
|
|
58
|
+
startedAt: 1000,
|
|
59
|
+
durationMs: 200,
|
|
60
|
+
error: 'oops',
|
|
61
|
+
});
|
|
62
|
+
const events = recentEvents();
|
|
63
|
+
expect(events[0].type).toBe('deploy.failed.authentik');
|
|
64
|
+
expect((events[0].payload as { error: string }).error).toBe('oops');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('emits health-check.failed.<module>', () => {
|
|
68
|
+
emitHealthCheckFailed({ module: 'lunacycle', reason: 'http 503' });
|
|
69
|
+
const events = recentEvents();
|
|
70
|
+
expect(events[0].type).toBe('health-check.failed.lunacycle');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('triggers persistent subscribers and creates deliveries', () => {
|
|
74
|
+
// Pre-register a subscriber that targets all completed deploys.
|
|
75
|
+
const setupBus = openBus({ dbPath, events: defineEvents({}) });
|
|
76
|
+
setupBus.subscribe({
|
|
77
|
+
name: 'test-watcher',
|
|
78
|
+
pattern: 'deploy.completed.*',
|
|
79
|
+
handler: 'unused',
|
|
80
|
+
});
|
|
81
|
+
setupBus.close();
|
|
82
|
+
|
|
83
|
+
emitDeployCompleted({ module: 'lunacycle', startedAt: 1, durationMs: 2 });
|
|
84
|
+
|
|
85
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
86
|
+
try {
|
|
87
|
+
const pending = bus.pendingDeliveries();
|
|
88
|
+
expect(pending).toHaveLength(1);
|
|
89
|
+
} finally {
|
|
90
|
+
bus.close();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not throw when the bus path is unwritable', () => {
|
|
95
|
+
process.env.EVENT_BUS_DB = '/proc/no/such/place/events.db';
|
|
96
|
+
expect(() => emitDeployStarted({ module: 'x', startedAt: 0 })).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Celilo's own lifecycle events. Emitted on every `module deploy`
|
|
3
|
+
* (started / completed / failed) so subscribers can react — production
|
|
4
|
+
* smoke tests, alerting, dashboards, etc.
|
|
5
|
+
*
|
|
6
|
+
* The event types are dot-segmented with the module id as the last
|
|
7
|
+
* segment: `deploy.started.lunacycle`, `deploy.completed.lunacycle`.
|
|
8
|
+
* This lets subscribers target one module (`deploy.completed.lunacycle`)
|
|
9
|
+
* or fan out (`deploy.completed.*`).
|
|
10
|
+
*
|
|
11
|
+
* Bus errors are best-effort: a sick bus shouldn't break a deploy. Each
|
|
12
|
+
* helper opens-emits-closes; per-event overhead is a few ms.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { getEventBusPath } from '../config/paths';
|
|
18
|
+
|
|
19
|
+
const NO_SCHEMAS = defineEvents({});
|
|
20
|
+
|
|
21
|
+
export interface DeployStartedPayload {
|
|
22
|
+
module: string;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DeployCompletedPayload {
|
|
27
|
+
module: string;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DeployFailedPayload {
|
|
33
|
+
module: string;
|
|
34
|
+
startedAt: number;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
error: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HealthCheckFailedPayload {
|
|
40
|
+
module: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DeployStartedSchema = z.object({
|
|
45
|
+
module: z.string().min(1),
|
|
46
|
+
startedAt: z.number().int().nonnegative(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const DeployCompletedSchema = z.object({
|
|
50
|
+
module: z.string().min(1),
|
|
51
|
+
startedAt: z.number().int().nonnegative(),
|
|
52
|
+
durationMs: z.number().int().nonnegative(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const DeployFailedSchema = z.object({
|
|
56
|
+
module: z.string().min(1),
|
|
57
|
+
startedAt: z.number().int().nonnegative(),
|
|
58
|
+
durationMs: z.number().int().nonnegative(),
|
|
59
|
+
error: z.string(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const HealthCheckFailedSchema = z.object({
|
|
63
|
+
module: z.string().min(1),
|
|
64
|
+
reason: z.string(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function emitDeployStarted(payload: DeployStartedPayload): void {
|
|
68
|
+
DeployStartedSchema.parse(payload);
|
|
69
|
+
emitBest(`deploy.started.${payload.module}`, payload);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function emitDeployCompleted(payload: DeployCompletedPayload): void {
|
|
73
|
+
DeployCompletedSchema.parse(payload);
|
|
74
|
+
emitBest(`deploy.completed.${payload.module}`, payload);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function emitDeployFailed(payload: DeployFailedPayload): void {
|
|
78
|
+
DeployFailedSchema.parse(payload);
|
|
79
|
+
emitBest(`deploy.failed.${payload.module}`, payload);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function emitHealthCheckFailed(payload: HealthCheckFailedPayload): void {
|
|
83
|
+
HealthCheckFailedSchema.parse(payload);
|
|
84
|
+
emitBest(`health-check.failed.${payload.module}`, payload);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Open the bus, emit, close. Errors are caught and logged so a
|
|
89
|
+
* misbehaving bus never wedges the caller. The empty-registry mode
|
|
90
|
+
* skips bus-side payload validation; we validate above with our own
|
|
91
|
+
* schemas before calling.
|
|
92
|
+
*/
|
|
93
|
+
function emitBest(type: string, payload: unknown): void {
|
|
94
|
+
let bus: ReturnType<typeof openBus> | undefined;
|
|
95
|
+
try {
|
|
96
|
+
bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
97
|
+
bus.emitRaw(type, payload);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
100
|
+
console.warn(`[celilo] failed to emit ${type}: ${msg}`);
|
|
101
|
+
} finally {
|
|
102
|
+
bus?.close();
|
|
103
|
+
}
|
|
104
|
+
}
|