@celilo/cli 0.2.0 → 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/cli/prompts.ts +1 -1
- package/src/config/paths.ts +19 -0
- package/src/hooks/define-hook.test.ts +12 -0
- package/src/hooks/executor.ts +9 -0
- package/src/hooks/logger.ts +38 -0
- package/src/hooks/types.ts +12 -1
- package/src/manifest/schema.ts +34 -0
- package/src/manifest/validate.test.ts +143 -0
- package/src/manifest/validate.ts +9 -3
- 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/config-interview.ts +9 -9
- package/src/services/deploy-ansible.ts +1 -1
- package/src/services/events-daemon.test.ts +184 -0
- package/src/services/events-daemon.ts +244 -0
- package/src/services/health-runner.ts +1 -1
- package/src/services/module-deploy.ts +62 -22
- package/src/services/module-subscriptions.test.ts +197 -0
- package/src/services/module-subscriptions.ts +120 -0
- package/src/templates/generator.ts +3 -6
- package/src/variables/declarative-derivation.test.ts +93 -8
- package/src/variables/declarative-derivation.ts +15 -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/cli/prompts.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function showNote(message: string, title?: string): void {
|
|
|
122
122
|
export const log = {
|
|
123
123
|
success: (message: string) => {
|
|
124
124
|
const d = getActiveDisplay();
|
|
125
|
-
if (d) return d.subEvent(`\x1b[32m
|
|
125
|
+
if (d) return d.subEvent(`\x1b[32m✔\x1b[0m ${message}`);
|
|
126
126
|
p.log.success(message);
|
|
127
127
|
},
|
|
128
128
|
error: (message: string) => {
|
package/src/config/paths.ts
CHANGED
|
@@ -92,6 +92,25 @@ 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. CELILO_EVENT_BUS_PATH environment variable (explicit override)
|
|
100
|
+
* 2. <data-dir>/events.db (platform-specific)
|
|
101
|
+
*
|
|
102
|
+
* Kept separate from the main celilo.db so the bus library remains
|
|
103
|
+
* standalone — the bus owns its own schema and migrations.
|
|
104
|
+
*
|
|
105
|
+
* @returns Absolute path to event-bus database
|
|
106
|
+
*/
|
|
107
|
+
export function getEventBusPath(): string {
|
|
108
|
+
if (process.env.CELILO_EVENT_BUS_PATH) {
|
|
109
|
+
return process.env.CELILO_EVENT_BUS_PATH;
|
|
110
|
+
}
|
|
111
|
+
return join(getDataDir(), 'events.db');
|
|
112
|
+
}
|
|
113
|
+
|
|
95
114
|
/**
|
|
96
115
|
* Shorten a path by replacing the celilo data directory with $CELILO_DATA
|
|
97
116
|
* For display purposes in CLI output.
|
|
@@ -91,6 +91,9 @@ const fakeIdp: IdpCapability = {
|
|
|
91
91
|
async create_user() {
|
|
92
92
|
return { user_id: 1, created: true };
|
|
93
93
|
},
|
|
94
|
+
async create_token() {
|
|
95
|
+
return { token: 'fake-token', created: true };
|
|
96
|
+
},
|
|
94
97
|
};
|
|
95
98
|
|
|
96
99
|
describe('defineHook', () => {
|
|
@@ -253,6 +256,9 @@ describe('defineCapabilityFunction', () => {
|
|
|
253
256
|
async create_user() {
|
|
254
257
|
return { user_id: 1, created: true };
|
|
255
258
|
},
|
|
259
|
+
async create_token() {
|
|
260
|
+
return { token: 'fake-token', created: true };
|
|
261
|
+
},
|
|
256
262
|
}),
|
|
257
263
|
});
|
|
258
264
|
|
|
@@ -313,6 +319,9 @@ describe('defineCapabilityFunction', () => {
|
|
|
313
319
|
async create_user(_request: CreateUserRequest): Promise<CreateUserResult> {
|
|
314
320
|
return { user_id: 1, created: true };
|
|
315
321
|
},
|
|
322
|
+
async create_token(): Promise<{ token: string; created: boolean }> {
|
|
323
|
+
return { token: 'tok', created: true };
|
|
324
|
+
},
|
|
316
325
|
};
|
|
317
326
|
},
|
|
318
327
|
});
|
|
@@ -443,6 +452,9 @@ void defineCapabilityFunction({
|
|
|
443
452
|
async create_user() {
|
|
444
453
|
return { user_id: 1, created: true };
|
|
445
454
|
},
|
|
455
|
+
async create_token() {
|
|
456
|
+
return { token: 'x', created: true };
|
|
457
|
+
},
|
|
446
458
|
}),
|
|
447
459
|
});
|
|
448
460
|
|
package/src/hooks/executor.ts
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
* - Output validation
|
|
8
8
|
* - Structured logging
|
|
9
9
|
*
|
|
10
|
+
* **Execution model:** hook scripts run *in-process* on the celilo CLI host.
|
|
11
|
+
* `runScript` dynamically `import()`s the hook module and awaits its default
|
|
12
|
+
* export. They do NOT execute on the target machine. A hook that needs to
|
|
13
|
+
* touch the target initiates SSH outbound itself (see e.g.
|
|
14
|
+
* modules/lunacycle/celilo/scripts/health-check.ts's `ssh()` helper). This
|
|
15
|
+
* means hook scripts can freely use npm packages, make HTTP calls, read
|
|
16
|
+
* local files, etc. — and conversely, anything they depend on (chromium,
|
|
17
|
+
* system binaries, credentials) must be available on the celilo CLI host.
|
|
18
|
+
*
|
|
10
19
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
11
20
|
*/
|
|
12
21
|
|
package/src/hooks/logger.ts
CHANGED
|
@@ -66,6 +66,44 @@ export function createGaugeLogger(
|
|
|
66
66
|
warn: (message: string) => emit('warn', message),
|
|
67
67
|
error: (message: string) => emit('error', message),
|
|
68
68
|
success: (message: string) => emit('success', message),
|
|
69
|
+
beginStep: (name: string) => {
|
|
70
|
+
const display = getActiveDisplay();
|
|
71
|
+
if (display) {
|
|
72
|
+
// pushStep nests under the FuelGauge step that wraps the hook,
|
|
73
|
+
// so inner log calls (logger.info within the capability impl)
|
|
74
|
+
// are sub-events of this nested step.
|
|
75
|
+
display.pushStep(`→ ${name}`, `✓ ${name}`);
|
|
76
|
+
} else {
|
|
77
|
+
// Without a display, fall back to a plain info-style marker so
|
|
78
|
+
// the line still appears in raw log output.
|
|
79
|
+
gauge.addOutput(`${prefix} → ${name}`);
|
|
80
|
+
if (nonInteractive) {
|
|
81
|
+
process.stdout.write(`${prefix} → ${name}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
endStep: (_name: string) => {
|
|
86
|
+
const display = getActiveDisplay();
|
|
87
|
+
if (display) {
|
|
88
|
+
display.doneStep();
|
|
89
|
+
} else {
|
|
90
|
+
gauge.addOutput(`${prefix} ✓ ${_name}`);
|
|
91
|
+
if (nonInteractive) {
|
|
92
|
+
process.stdout.write(`${prefix} ✓ ${_name}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
failStep: (name: string, error: string) => {
|
|
97
|
+
const display = getActiveDisplay();
|
|
98
|
+
if (display) {
|
|
99
|
+
display.failStep(`${name}: ${error}`);
|
|
100
|
+
} else {
|
|
101
|
+
gauge.addOutput(`${prefix} ✗ ${name}: ${error}`);
|
|
102
|
+
if (nonInteractive) {
|
|
103
|
+
process.stdout.write(`${prefix} ✗ ${name}: ${error}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
69
107
|
};
|
|
70
108
|
}
|
|
71
109
|
|
package/src/hooks/types.ts
CHANGED
|
@@ -24,13 +24,24 @@ export interface HookDefinition {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Logger interface provided to hook scripts
|
|
27
|
+
* Logger interface provided to hook scripts.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors `HookLogger` in `@celilo/capabilities/types` — kept in sync so
|
|
30
|
+
* concrete loggers we build here (createGaugeLogger, createConsoleLogger,
|
|
31
|
+
* createCapturingLogger) satisfy both the in-process consumers and the
|
|
32
|
+
* cross-package contract used by `wrapWithLogging`.
|
|
28
33
|
*/
|
|
29
34
|
export interface HookLogger {
|
|
30
35
|
info(message: string): void;
|
|
31
36
|
warn(message: string): void;
|
|
32
37
|
error(message: string): void;
|
|
33
38
|
success(message: string): void;
|
|
39
|
+
/** Begin a logical span. Subsequent log calls nest under it visually. */
|
|
40
|
+
beginStep?(name: string): void;
|
|
41
|
+
/** End the current span (success). */
|
|
42
|
+
endStep?(name: string): void;
|
|
43
|
+
/** End the current span (failure) with an error message. */
|
|
44
|
+
failStep?(name: string, error: string): void;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
/**
|
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
|
|
|
@@ -993,6 +993,74 @@ describe('validateDeriveFromSources', () => {
|
|
|
993
993
|
const result = validateDeriveFromSources(manifest);
|
|
994
994
|
expect(result).toBeNull();
|
|
995
995
|
});
|
|
996
|
+
|
|
997
|
+
test('should accept $self: references regardless of source', () => {
|
|
998
|
+
// $self: points at another variable in this same manifest, so it
|
|
999
|
+
// doesn't introduce the cross-context mismatch the validator exists
|
|
1000
|
+
// to catch (e.g. authentik's auth_url derives from $self:domain).
|
|
1001
|
+
const manifest = {
|
|
1002
|
+
celilo_contract: '1.0' as const,
|
|
1003
|
+
id: 'authentik',
|
|
1004
|
+
name: 'Authentik',
|
|
1005
|
+
version: '1.0.0',
|
|
1006
|
+
requires: { capabilities: [] },
|
|
1007
|
+
provides: { capabilities: [] },
|
|
1008
|
+
variables: {
|
|
1009
|
+
owns: [
|
|
1010
|
+
{
|
|
1011
|
+
name: 'domain',
|
|
1012
|
+
type: 'string' as const,
|
|
1013
|
+
required: true,
|
|
1014
|
+
source: 'user' as const,
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
name: 'auth_url',
|
|
1018
|
+
type: 'string' as const,
|
|
1019
|
+
required: false,
|
|
1020
|
+
source: 'capability' as const,
|
|
1021
|
+
derive_from: 'https://auth.$self:domain',
|
|
1022
|
+
},
|
|
1023
|
+
],
|
|
1024
|
+
imports: [],
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
const result = validateDeriveFromSources(manifest);
|
|
1029
|
+
expect(result).toBeNull();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('should still reject mixed $self: and $system: when source is capability', () => {
|
|
1033
|
+
// Self-refs are exempt, but other foreign sources still trigger the
|
|
1034
|
+
// mismatch error. This guards against accidentally green-lighting
|
|
1035
|
+
// "$self:foo and $system:bar" combos.
|
|
1036
|
+
const manifest = {
|
|
1037
|
+
celilo_contract: '1.0' as const,
|
|
1038
|
+
id: 'test',
|
|
1039
|
+
name: 'Test',
|
|
1040
|
+
version: '1.0.0',
|
|
1041
|
+
requires: { capabilities: [] },
|
|
1042
|
+
provides: { capabilities: [] },
|
|
1043
|
+
variables: {
|
|
1044
|
+
owns: [
|
|
1045
|
+
{
|
|
1046
|
+
name: 'mixed',
|
|
1047
|
+
type: 'string' as const,
|
|
1048
|
+
required: false,
|
|
1049
|
+
source: 'capability' as const,
|
|
1050
|
+
derive_from: '$self:domain/$system:primary_domain',
|
|
1051
|
+
},
|
|
1052
|
+
],
|
|
1053
|
+
imports: [],
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
const result = validateDeriveFromSources(manifest);
|
|
1058
|
+
expect(result).not.toBeNull();
|
|
1059
|
+
if (result) {
|
|
1060
|
+
expect(result.errors[0]?.message).toContain('$system:');
|
|
1061
|
+
expect(result.errors[0]?.message).not.toContain('$self:');
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
996
1064
|
});
|
|
997
1065
|
|
|
998
1066
|
describe('validateHookContract', () => {
|
|
@@ -1178,3 +1246,78 @@ secrets:
|
|
|
1178
1246
|
expect(result.success).toBe(false);
|
|
1179
1247
|
});
|
|
1180
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/manifest/validate.ts
CHANGED
|
@@ -285,9 +285,13 @@ export function validateZoneRequirements(manifest: ModuleManifest): ValidationEr
|
|
|
285
285
|
*
|
|
286
286
|
* Rules:
|
|
287
287
|
* - `source: capability` → `derive_from` must only reference `$capability:`
|
|
288
|
-
* tokens (and may also include `{var}` placeholders).
|
|
288
|
+
* tokens (and may also include `{var}` and `$self:` placeholders).
|
|
289
289
|
* - `source: system` → `derive_from` must only reference `$system:` tokens
|
|
290
|
-
* (and `{var}` placeholders).
|
|
290
|
+
* (and `{var}` and `$self:` placeholders).
|
|
291
|
+
* - `$self:` references are allowed under *any* source. They point at
|
|
292
|
+
* another variable owned by the same manifest, so they don't introduce
|
|
293
|
+
* a cross-context mismatch — that's the bug class this validator was
|
|
294
|
+
* built to catch.
|
|
291
295
|
* - Other sources (`user`, `infrastructure`, `terraform`) — `derive_from` is
|
|
292
296
|
* optional and unconstrained; we don't check the prefix.
|
|
293
297
|
*/
|
|
@@ -306,7 +310,9 @@ export function validateDeriveFromSources(manifest: ModuleManifest): ValidationE
|
|
|
306
310
|
if (!expected) continue;
|
|
307
311
|
|
|
308
312
|
const tokens = [...variable.derive_from.matchAll(tokenPattern)].map((m) => m[1]);
|
|
309
|
-
|
|
313
|
+
// `self` is always allowed: it references another variable in this
|
|
314
|
+
// same manifest, which is the same context as the declared source.
|
|
315
|
+
const offending = tokens.filter((t) => t !== expected && t !== 'self');
|
|
310
316
|
if (offending.length > 0) {
|
|
311
317
|
const unique = Array.from(new Set(offending)).join(', ');
|
|
312
318
|
errors.push({
|
package/src/module/import.ts
CHANGED
|
@@ -599,6 +599,21 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
+
// Execution: Register event-bus subscriptions declared in the manifest.
|
|
603
|
+
// Best-effort: a bus problem shouldn't wedge an import — the operator
|
|
604
|
+
// can re-run the import after fixing the bus, and bus.subscribe is
|
|
605
|
+
// idempotent.
|
|
606
|
+
try {
|
|
607
|
+
const { registerModuleSubscriptions } = await import('../services/module-subscriptions');
|
|
608
|
+
registerModuleSubscriptions(manifest, targetPath);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
611
|
+
log.warn(`Failed to register event-bus subscriptions: ${msg}`);
|
|
612
|
+
log.warn(
|
|
613
|
+
'Module imported, but reactive flows on the event bus will not fire until this is fixed.',
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
602
617
|
// Execution: Store integrity data
|
|
603
618
|
// For .netapp packages: store checksums + signature
|
|
604
619
|
// 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.CELILO_EVENT_BUS_PATH = dbPath;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
process.env.CELILO_EVENT_BUS_PATH = 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.CELILO_EVENT_BUS_PATH = '/proc/no/such/place/events.db';
|
|
96
|
+
expect(() => emitDeployStarted({ module: 'x', startedAt: 0 })).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|