@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
|
@@ -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
|
|
|
@@ -295,7 +346,6 @@ export async function deployModule(
|
|
|
295
346
|
}
|
|
296
347
|
}
|
|
297
348
|
|
|
298
|
-
log.info(`Deploying module: ${moduleId}`);
|
|
299
349
|
const validation = await validateAndPrepareDeployment(moduleId, db);
|
|
300
350
|
phases.validation = validation.success;
|
|
301
351
|
phases.autoGenerated = validation.autoGenerated;
|
|
@@ -469,11 +519,9 @@ export async function deployModule(
|
|
|
469
519
|
}
|
|
470
520
|
}
|
|
471
521
|
|
|
472
|
-
log.success(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
log.message(' Module built');
|
|
476
|
-
}
|
|
522
|
+
log.success(
|
|
523
|
+
validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
|
|
524
|
+
);
|
|
477
525
|
|
|
478
526
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
479
527
|
if (manifest.hooks?.validate_config) {
|
|
@@ -613,7 +661,7 @@ export async function deployModule(
|
|
|
613
661
|
// Run on_install hook for config-only modules (e.g. publishing static files to caddy)
|
|
614
662
|
if (manifest.hooks?.on_install) {
|
|
615
663
|
const onInstallDef = manifest.hooks.on_install;
|
|
616
|
-
log.
|
|
664
|
+
log.success('Running on_install hook');
|
|
617
665
|
|
|
618
666
|
const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
|
|
619
667
|
const installConfigs = db
|
|
@@ -695,7 +743,6 @@ export async function deployModule(
|
|
|
695
743
|
// Run health checks for config-only modules too
|
|
696
744
|
if (manifest.hooks?.health_check) {
|
|
697
745
|
const { runModuleHealthCheck } = await import('./health-runner');
|
|
698
|
-
log.info('Running health checks...');
|
|
699
746
|
const healthResult = await runModuleHealthCheck(moduleId, db, {
|
|
700
747
|
debug: options.debug,
|
|
701
748
|
noInteractive: options.noInteractive,
|
|
@@ -710,7 +757,6 @@ export async function deployModule(
|
|
|
710
757
|
.join('\n');
|
|
711
758
|
return { success: false, phases, error: `Health checks failed:\n${failedChecks}` };
|
|
712
759
|
}
|
|
713
|
-
log.success('Health checks passed → VERIFIED');
|
|
714
760
|
}
|
|
715
761
|
|
|
716
762
|
return {
|
|
@@ -855,7 +901,7 @@ export async function deployModule(
|
|
|
855
901
|
|
|
856
902
|
if (!containerCreatedHook) continue;
|
|
857
903
|
|
|
858
|
-
log.
|
|
904
|
+
log.message(
|
|
859
905
|
`Running container_created hook on ${capRecord.moduleId} (provides ${requiredCap.name})`,
|
|
860
906
|
);
|
|
861
907
|
|
|
@@ -988,7 +1034,6 @@ export async function deployModule(
|
|
|
988
1034
|
let machineId: string | undefined;
|
|
989
1035
|
if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
|
|
990
1036
|
machineId = plan.infrastructure.machineId;
|
|
991
|
-
log.info(`Writing temporary SSH key for machine: ${machineId}`);
|
|
992
1037
|
await writeTemporarySshKey(machineId);
|
|
993
1038
|
}
|
|
994
1039
|
|
|
@@ -1012,15 +1057,16 @@ export async function deployModule(
|
|
|
1012
1057
|
plan.infrastructure?.type === 'container_service' &&
|
|
1013
1058
|
plan.infrastructure.serviceId
|
|
1014
1059
|
) {
|
|
1015
|
-
// TODO:
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1060
|
+
// TODO: extract Terraform outputs and persist them on
|
|
1061
|
+
// module_infrastructure.containerMetadata. Until that lands,
|
|
1062
|
+
// the deploy still succeeds — we just don't track which
|
|
1063
|
+
// container ID/IP got assigned per module in the DB.
|
|
1018
1064
|
}
|
|
1019
1065
|
|
|
1020
1066
|
// Run on_install hook (post-deploy actions like port forwarding, DNS registration)
|
|
1021
1067
|
if (manifest.hooks?.on_install) {
|
|
1022
1068
|
const onInstallDef = manifest.hooks.on_install;
|
|
1023
|
-
log.
|
|
1069
|
+
log.success('Running on_install hook');
|
|
1024
1070
|
|
|
1025
1071
|
const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
|
|
1026
1072
|
const installConfigs = db
|
|
@@ -1120,7 +1166,6 @@ export async function deployModule(
|
|
|
1120
1166
|
// Run health checks after successful deployment
|
|
1121
1167
|
if (manifest.hooks?.health_check) {
|
|
1122
1168
|
const { runModuleHealthCheck } = await import('./health-runner');
|
|
1123
|
-
log.info('Running post-deploy health checks...');
|
|
1124
1169
|
const healthResult = await runModuleHealthCheck(moduleId, db, {
|
|
1125
1170
|
debug: options.debug,
|
|
1126
1171
|
noInteractive: options.noInteractive,
|
|
@@ -1144,10 +1189,6 @@ export async function deployModule(
|
|
|
1144
1189
|
error: `Health checks failed:\n${failedChecks}`,
|
|
1145
1190
|
};
|
|
1146
1191
|
}
|
|
1147
|
-
const summary = healthResult.checks
|
|
1148
|
-
.map((c) => ` ${c.status === 'pass' ? '✓' : '⚠'} ${c.name}`)
|
|
1149
|
-
.join('\n');
|
|
1150
|
-
log.success(`Health checks passed → VERIFIED\n${summary}`);
|
|
1151
1192
|
}
|
|
1152
1193
|
|
|
1153
1194
|
// Auto-register module hostname in internal DNS (if available)
|
|
@@ -1167,7 +1208,7 @@ export async function deployModule(
|
|
|
1167
1208
|
);
|
|
1168
1209
|
}
|
|
1169
1210
|
|
|
1170
|
-
|
|
1211
|
+
log.success(`Module '${moduleId}' deployed successfully`);
|
|
1171
1212
|
return {
|
|
1172
1213
|
success: true,
|
|
1173
1214
|
phases,
|
|
@@ -1175,7 +1216,6 @@ export async function deployModule(
|
|
|
1175
1216
|
} finally {
|
|
1176
1217
|
// Clean up temporary SSH key
|
|
1177
1218
|
if (machineId) {
|
|
1178
|
-
log.info(`Cleaning up temporary SSH key for machine: ${machineId}`);
|
|
1179
1219
|
deleteTemporarySshKey(machineId);
|
|
1180
1220
|
}
|
|
1181
1221
|
}
|
|
@@ -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
|
+
}
|
|
@@ -491,9 +491,6 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
} else {
|
|
494
|
-
// Module doesn't require infrastructure provisioning (e.g., VPS-based modules)
|
|
495
|
-
// Skip infrastructure selection
|
|
496
|
-
log.info('Skipping infrastructure selection (no requires.machine specified)');
|
|
497
494
|
infrastructureSelection = undefined;
|
|
498
495
|
}
|
|
499
496
|
|
|
@@ -525,12 +522,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
525
522
|
if (!allocation) {
|
|
526
523
|
// First generation - allocate VMID and IP
|
|
527
524
|
allocation = await allocateForModule(moduleId, zone, db, db.$client);
|
|
528
|
-
log.
|
|
525
|
+
log.success(
|
|
529
526
|
`Allocated VMID ${allocation.vmid} and IP ${allocation.containerIp} for ${moduleId}`,
|
|
530
527
|
);
|
|
531
528
|
} else {
|
|
532
529
|
// Reuse existing allocation
|
|
533
|
-
log.
|
|
530
|
+
log.success(
|
|
534
531
|
`Using existing allocation: VMID ${allocation.vmid}, IP ${allocation.containerIp}`,
|
|
535
532
|
);
|
|
536
533
|
}
|
|
@@ -615,7 +612,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
615
612
|
.run();
|
|
616
613
|
}
|
|
617
614
|
|
|
618
|
-
log.
|
|
615
|
+
log.success(
|
|
619
616
|
`Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
|
|
620
617
|
);
|
|
621
618
|
}
|
|
@@ -940,11 +940,13 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
940
940
|
});
|
|
941
941
|
});
|
|
942
942
|
|
|
943
|
-
describe('$self:
|
|
944
|
-
// $self:
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
943
|
+
describe('$self: in derive_from', () => {
|
|
944
|
+
// $self: is the canonical reference syntax in capability data blocks
|
|
945
|
+
// (e.g. authentik's `data.auth_url: $self:auth_url`), so it must
|
|
946
|
+
// resolve in derive_from too — otherwise capability-sourced variables
|
|
947
|
+
// that derive from another self variable can't be persisted.
|
|
948
|
+
// Equivalent to the {var} syntax; reads from selfConfig.
|
|
949
|
+
test('resolves $self: against selfConfig like {var}', () => {
|
|
948
950
|
const manifest: ModuleManifest = {
|
|
949
951
|
celilo_contract: '1.0',
|
|
950
952
|
id: 'test-module',
|
|
@@ -959,7 +961,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
959
961
|
type: 'string',
|
|
960
962
|
required: false,
|
|
961
963
|
source: 'user',
|
|
962
|
-
derive_from: '$self:hostname',
|
|
964
|
+
derive_from: '$self:hostname',
|
|
963
965
|
},
|
|
964
966
|
],
|
|
965
967
|
imports: [],
|
|
@@ -977,8 +979,91 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
977
979
|
|
|
978
980
|
applyDeclarativeDerivations(manifest, context);
|
|
979
981
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
+
expect(context.selfConfig.hostname_copy).toBe('my-host');
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test('resolves $self: inside string interpolation (authentik auth_url shape)', () => {
|
|
986
|
+
// Mirrors the authentik manifest pattern that motivated the fix:
|
|
987
|
+
// auth_url derives from "https://auth.$self:domain" where domain
|
|
988
|
+
// is another self-owned variable set by the user.
|
|
989
|
+
const manifest: ModuleManifest = {
|
|
990
|
+
celilo_contract: '1.0',
|
|
991
|
+
id: 'authentik',
|
|
992
|
+
name: 'Authentik',
|
|
993
|
+
version: '1.0.0',
|
|
994
|
+
requires: { capabilities: [] },
|
|
995
|
+
provides: { capabilities: [] },
|
|
996
|
+
variables: {
|
|
997
|
+
owns: [
|
|
998
|
+
{
|
|
999
|
+
name: 'domain',
|
|
1000
|
+
type: 'string',
|
|
1001
|
+
required: true,
|
|
1002
|
+
source: 'user',
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
name: 'auth_url',
|
|
1006
|
+
type: 'string',
|
|
1007
|
+
required: false,
|
|
1008
|
+
source: 'capability',
|
|
1009
|
+
derive_from: 'https://auth.$self:domain',
|
|
1010
|
+
},
|
|
1011
|
+
],
|
|
1012
|
+
imports: [],
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const context: ResolutionContext = {
|
|
1017
|
+
moduleId: 'authentik',
|
|
1018
|
+
selfConfig: { domain: 'iamtheinternet.org' },
|
|
1019
|
+
systemConfig: {},
|
|
1020
|
+
systemSecrets: {},
|
|
1021
|
+
secrets: {},
|
|
1022
|
+
capabilities: {},
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
applyDeclarativeDerivations(manifest, context);
|
|
1026
|
+
|
|
1027
|
+
expect(context.selfConfig.auth_url).toBe('https://auth.iamtheinternet.org');
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test('throws when $self: references an undeclared variable', () => {
|
|
1031
|
+
const manifest: ModuleManifest = {
|
|
1032
|
+
celilo_contract: '1.0',
|
|
1033
|
+
id: 'test',
|
|
1034
|
+
name: 'Test',
|
|
1035
|
+
version: '1.0.0',
|
|
1036
|
+
requires: { capabilities: [] },
|
|
1037
|
+
provides: { capabilities: [] },
|
|
1038
|
+
variables: {
|
|
1039
|
+
owns: [
|
|
1040
|
+
{
|
|
1041
|
+
name: 'derived',
|
|
1042
|
+
type: 'string',
|
|
1043
|
+
required: false,
|
|
1044
|
+
source: 'user',
|
|
1045
|
+
derive_from: '$self:nonexistent',
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
imports: [],
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
const context: ResolutionContext = {
|
|
1053
|
+
moduleId: 'test',
|
|
1054
|
+
selfConfig: {},
|
|
1055
|
+
systemConfig: {},
|
|
1056
|
+
systemSecrets: {},
|
|
1057
|
+
secrets: {},
|
|
1058
|
+
capabilities: {},
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// Optional variable + missing dep → derivation fails silently per
|
|
1062
|
+
// applyDeclarativeDerivations' optional-variable rule. The error
|
|
1063
|
+
// is thrown from substituteVariables but caught by the optional
|
|
1064
|
+
// branch in applyDeclarativeDerivations, leaving selfConfig clean.
|
|
1065
|
+
applyDeclarativeDerivations(manifest, context);
|
|
1066
|
+
expect(context.selfConfig.derived).toBeUndefined();
|
|
982
1067
|
});
|
|
983
1068
|
});
|
|
984
1069
|
|
|
@@ -65,6 +65,21 @@ function substituteVariables(
|
|
|
65
65
|
return value;
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
// Replace $self:key patterns (both $self:key and ${self:key} forms).
|
|
69
|
+
// Same lookup target as the {var} syntax below — both read selfConfig —
|
|
70
|
+
// but $self: is the form that appears in user-authored manifests and
|
|
71
|
+
// capability data blocks, so it must resolve here too. Without this,
|
|
72
|
+
// a `derive_from: "https://auth.$self:domain"` stays literally
|
|
73
|
+
// unresolved and the defensive guard in applyDeclarativeDerivations
|
|
74
|
+
// refuses to store it, breaking downstream capability consumers.
|
|
75
|
+
result = result.replace(/\$\{?self:([a-zA-Z0-9_]+)\}?/g, (_match, key) => {
|
|
76
|
+
const value = context.selfConfig[key];
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
throw new Error(`Missing self variable: ${key} (required by variable '${variableName}')`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
});
|
|
82
|
+
|
|
68
83
|
// Replace {variable_name} patterns
|
|
69
84
|
result = result.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, varName) => {
|
|
70
85
|
const value = context.selfConfig[varName];
|