@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +6 -5
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-dispatch tests. The planner is pure (no I/O); the runner
|
|
3
|
+
* spawns bash. We test:
|
|
4
|
+
*
|
|
5
|
+
* - planHookDispatch: rule matching, env construction, scriptPath
|
|
6
|
+
* resolution.
|
|
7
|
+
* - runHookDispatch: real bash spawn with a fixture script.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { PublishEvent } from '@celilo/event-bus/build-bus';
|
|
15
|
+
import { type ModuleHookContext, planHookDispatch, runHookDispatch } from './hook-dispatch';
|
|
16
|
+
|
|
17
|
+
function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
|
|
18
|
+
return {
|
|
19
|
+
eventId: 'evt-1',
|
|
20
|
+
timestamp: new Date().toISOString(),
|
|
21
|
+
registry: 'npm',
|
|
22
|
+
tag: 'latest',
|
|
23
|
+
package: { name: '@celilo/cli', version: '0.4.0' },
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('planHookDispatch', () => {
|
|
29
|
+
test('empty module list → empty plan', () => {
|
|
30
|
+
expect(planHookDispatch(buildEvent(), [])).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('module with no matching hooks → empty plan', () => {
|
|
34
|
+
const modules: ModuleHookContext[] = [
|
|
35
|
+
{
|
|
36
|
+
moduleId: 'lunacycle',
|
|
37
|
+
sourcePath: '/modules/lunacycle',
|
|
38
|
+
hooks: [{ match: { registry: 'celilo-registry' }, script: './reload.sh' }],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
expect(planHookDispatch(buildEvent({ registry: 'npm' }), modules)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('matching hook → plan with resolved scriptPath + env', () => {
|
|
45
|
+
const modules: ModuleHookContext[] = [
|
|
46
|
+
{
|
|
47
|
+
moduleId: 'lunacycle',
|
|
48
|
+
sourcePath: '/modules/lunacycle',
|
|
49
|
+
hooks: [
|
|
50
|
+
{
|
|
51
|
+
name: 'rerun-e2e',
|
|
52
|
+
match: { registry: 'npm', package_pattern: '@celilo/*' },
|
|
53
|
+
script: './hooks/rerun.sh',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const plan = planHookDispatch(buildEvent(), modules);
|
|
59
|
+
expect(plan).toHaveLength(1);
|
|
60
|
+
expect(plan[0].scriptPath).toBe('/modules/lunacycle/hooks/rerun.sh');
|
|
61
|
+
expect(plan[0].env.CELILO_EVENT_REGISTRY).toBe('npm');
|
|
62
|
+
expect(plan[0].env.CELILO_EVENT_TAG).toBe('latest');
|
|
63
|
+
expect(plan[0].env.CELILO_EVENT_PACKAGE_NAME).toBe('@celilo/cli');
|
|
64
|
+
expect(plan[0].env.CELILO_EVENT_PACKAGE_VERSION).toBe('0.4.0');
|
|
65
|
+
const payload = JSON.parse(plan[0].env.CELILO_EVENT_PAYLOAD) as PublishEvent;
|
|
66
|
+
expect(payload.package.name).toBe('@celilo/cli');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('multiple modules + hooks → one plan entry per match', () => {
|
|
70
|
+
const modules: ModuleHookContext[] = [
|
|
71
|
+
{
|
|
72
|
+
moduleId: 'lunacycle',
|
|
73
|
+
sourcePath: '/modules/lunacycle',
|
|
74
|
+
hooks: [
|
|
75
|
+
{ match: { registry: 'npm', package_pattern: '@celilo/cli' }, script: './a.sh' },
|
|
76
|
+
{ match: { registry: 'npm', package_pattern: '@celilo/e2e' }, script: './b.sh' },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
moduleId: 'mgmt',
|
|
81
|
+
sourcePath: '/modules/mgmt',
|
|
82
|
+
hooks: [{ match: { registry: 'celilo-registry' }, script: './c.sh' }],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const plan = planHookDispatch(
|
|
86
|
+
buildEvent({ package: { name: '@celilo/cli', version: '0.4.0' } }),
|
|
87
|
+
modules,
|
|
88
|
+
);
|
|
89
|
+
expect(plan).toHaveLength(1);
|
|
90
|
+
expect(plan[0].module.moduleId).toBe('lunacycle');
|
|
91
|
+
expect(plan[0].hook.script).toBe('./a.sh');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('snake_case package_pattern in manifest maps to camelCase packagePattern in matcher', () => {
|
|
95
|
+
// Documents the YAML↔JS naming bridge.
|
|
96
|
+
const modules: ModuleHookContext[] = [
|
|
97
|
+
{
|
|
98
|
+
moduleId: 'x',
|
|
99
|
+
sourcePath: '/m',
|
|
100
|
+
hooks: [{ match: { package_pattern: '@celilo/*' }, script: './h.sh' }],
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
expect(planHookDispatch(buildEvent(), modules)).toHaveLength(1);
|
|
104
|
+
expect(
|
|
105
|
+
planHookDispatch(buildEvent({ package: { name: '@other/x', version: '1.0.0' } }), modules),
|
|
106
|
+
).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('runHookDispatch', () => {
|
|
111
|
+
let scriptDir: string;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
scriptDir = mkdtempSync(join(tmpdir(), 'celilo-hook-dispatch-'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
rmSync(scriptDir, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function writeScript(name: string, body: string): string {
|
|
122
|
+
const path = join(scriptDir, name);
|
|
123
|
+
writeFileSync(path, body);
|
|
124
|
+
chmodSync(path, 0o755);
|
|
125
|
+
return path;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('runs a script and captures exit 0 + stdout', async () => {
|
|
129
|
+
const scriptPath = writeScript(
|
|
130
|
+
'h.sh',
|
|
131
|
+
'#!/bin/bash\necho "hello $CELILO_EVENT_PACKAGE_NAME"\n',
|
|
132
|
+
);
|
|
133
|
+
const result = await runHookDispatch({
|
|
134
|
+
module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
|
|
135
|
+
hook: { match: {}, script: 'h.sh' },
|
|
136
|
+
scriptPath,
|
|
137
|
+
env: {
|
|
138
|
+
CELILO_EVENT_PAYLOAD: '{}',
|
|
139
|
+
CELILO_EVENT_REGISTRY: 'npm',
|
|
140
|
+
CELILO_EVENT_TAG: 'latest',
|
|
141
|
+
CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
|
|
142
|
+
CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
expect(result.exitCode).toBe(0);
|
|
146
|
+
expect(result.stdout.trim()).toBe('hello @celilo/cli');
|
|
147
|
+
expect(result.timedOut).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('captures non-zero exit + stderr', async () => {
|
|
151
|
+
const scriptPath = writeScript('fail.sh', '#!/bin/bash\necho "boom" >&2\nexit 17\n');
|
|
152
|
+
const result = await runHookDispatch({
|
|
153
|
+
module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
|
|
154
|
+
hook: { match: {}, script: 'fail.sh' },
|
|
155
|
+
scriptPath,
|
|
156
|
+
env: {
|
|
157
|
+
CELILO_EVENT_PAYLOAD: '{}',
|
|
158
|
+
CELILO_EVENT_REGISTRY: 'npm',
|
|
159
|
+
CELILO_EVENT_TAG: 'latest',
|
|
160
|
+
CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
|
|
161
|
+
CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
expect(result.exitCode).toBe(17);
|
|
165
|
+
expect(result.stderr.trim()).toBe('boom');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('respects timeout (kills the script and reports timedOut=true)', async () => {
|
|
169
|
+
const scriptPath = writeScript('slow.sh', '#!/bin/bash\nsleep 5\n');
|
|
170
|
+
const result = await runHookDispatch({
|
|
171
|
+
module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
|
|
172
|
+
hook: { match: {}, script: 'slow.sh', timeout: 1 },
|
|
173
|
+
scriptPath,
|
|
174
|
+
env: {
|
|
175
|
+
CELILO_EVENT_PAYLOAD: '{}',
|
|
176
|
+
CELILO_EVENT_REGISTRY: 'npm',
|
|
177
|
+
CELILO_EVENT_TAG: 'latest',
|
|
178
|
+
CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
|
|
179
|
+
CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
expect(result.timedOut).toBe(true);
|
|
183
|
+
// SIGTERM + 500ms grace → SIGKILL means the inner `sleep` should
|
|
184
|
+
// be reaped within ~2s of the 1-second timeout firing. Give a
|
|
185
|
+
// generous 4s ceiling so we're not flaky on slow CI hosts.
|
|
186
|
+
expect(result.durationMs).toBeLessThan(4000);
|
|
187
|
+
}, 10000);
|
|
188
|
+
|
|
189
|
+
test('records spawn error when the script does not exist', async () => {
|
|
190
|
+
const result = await runHookDispatch({
|
|
191
|
+
module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
|
|
192
|
+
hook: { match: {}, script: 'does-not-exist.sh' },
|
|
193
|
+
scriptPath: join(scriptDir, 'does-not-exist.sh'),
|
|
194
|
+
env: {
|
|
195
|
+
CELILO_EVENT_PAYLOAD: '{}',
|
|
196
|
+
CELILO_EVENT_REGISTRY: 'npm',
|
|
197
|
+
CELILO_EVENT_TAG: 'latest',
|
|
198
|
+
CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
|
|
199
|
+
CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
// bash exits with non-zero when the script file doesn't exist
|
|
203
|
+
// (status 127). Either non-zero exit or a null/spawn-error is
|
|
204
|
+
// acceptable here; we just want NOT-success.
|
|
205
|
+
expect(result.exitCode === 0).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-dispatch planning + execution for the build bus
|
|
3
|
+
* ([[v2/BUILD_BUS.md]] Phase 4).
|
|
4
|
+
*
|
|
5
|
+
* Pure: planHookDispatch(event, modules) → HookDispatchPlan[].
|
|
6
|
+
* Decides which module hooks match the event without spawning
|
|
7
|
+
* anything; the executor (runHookDispatch) takes a plan and
|
|
8
|
+
* spawns the actual scripts.
|
|
9
|
+
*
|
|
10
|
+
* The hook script gets the event in its environment:
|
|
11
|
+
* CELILO_EVENT_PAYLOAD — full PublishEvent as JSON
|
|
12
|
+
* CELILO_EVENT_REGISTRY — convenience (also in payload)
|
|
13
|
+
* CELILO_EVENT_TAG — convenience
|
|
14
|
+
* CELILO_EVENT_PACKAGE_NAME — convenience
|
|
15
|
+
* CELILO_EVENT_PACKAGE_VERSION — convenience
|
|
16
|
+
*
|
|
17
|
+
* Non-zero exit is logged but doesn't gate the receiver — failures
|
|
18
|
+
* are operational signal for the operator, not a publish blocker.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { type PublishEvent, matchesRule } from '@celilo/event-bus/build-bus';
|
|
24
|
+
|
|
25
|
+
/** Mirrors UpstreamPublishHookSchema in the manifest schema. */
|
|
26
|
+
export interface UpstreamHookEntry {
|
|
27
|
+
name?: string;
|
|
28
|
+
match: { registry?: string; tag?: string; package_pattern?: string };
|
|
29
|
+
script: string;
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What the dispatcher knows about a single installed module that
|
|
35
|
+
* declares on_upstream_publish hooks. Built by the dispatcher's
|
|
36
|
+
* loader (which reads the celilo modules table + each module's
|
|
37
|
+
* manifest.yml); injected as a list into planHookDispatch.
|
|
38
|
+
*/
|
|
39
|
+
export interface ModuleHookContext {
|
|
40
|
+
moduleId: string;
|
|
41
|
+
sourcePath: string;
|
|
42
|
+
hooks: UpstreamHookEntry[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HookDispatchPlan {
|
|
46
|
+
module: ModuleHookContext;
|
|
47
|
+
hook: UpstreamHookEntry;
|
|
48
|
+
/** Absolute path the executor will spawn. */
|
|
49
|
+
scriptPath: string;
|
|
50
|
+
/** Env vars to pass to the script. */
|
|
51
|
+
env: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pure: walk every (module, hook) combination, keep the ones whose
|
|
56
|
+
* match rule fires for this event. Same world → same plan; injects
|
|
57
|
+
* nothing.
|
|
58
|
+
*
|
|
59
|
+
* The manifest's match uses snake_case (`package_pattern`) to match
|
|
60
|
+
* YAML conventions, but the @celilo/event-bus/build-bus matcher
|
|
61
|
+
* uses camelCase (`packagePattern`). Bridge that here so the
|
|
62
|
+
* manifest stays YAML-idiomatic and the typed-API stays JS-idiomatic.
|
|
63
|
+
*/
|
|
64
|
+
export function planHookDispatch(
|
|
65
|
+
event: PublishEvent,
|
|
66
|
+
modules: ModuleHookContext[],
|
|
67
|
+
): HookDispatchPlan[] {
|
|
68
|
+
const plans: HookDispatchPlan[] = [];
|
|
69
|
+
const env = buildHookEnv(event);
|
|
70
|
+
for (const module of modules) {
|
|
71
|
+
for (const hook of module.hooks) {
|
|
72
|
+
const rule = {
|
|
73
|
+
registry: hook.match.registry,
|
|
74
|
+
tag: hook.match.tag,
|
|
75
|
+
packagePattern: hook.match.package_pattern,
|
|
76
|
+
};
|
|
77
|
+
if (!matchesRule(event, rule)) continue;
|
|
78
|
+
plans.push({
|
|
79
|
+
module,
|
|
80
|
+
hook,
|
|
81
|
+
scriptPath: join(module.sourcePath, hook.script),
|
|
82
|
+
env,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return plans;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildHookEnv(event: PublishEvent): Record<string, string> {
|
|
90
|
+
return {
|
|
91
|
+
CELILO_EVENT_PAYLOAD: JSON.stringify(event),
|
|
92
|
+
CELILO_EVENT_REGISTRY: event.registry,
|
|
93
|
+
CELILO_EVENT_TAG: event.tag,
|
|
94
|
+
CELILO_EVENT_PACKAGE_NAME: event.package.name,
|
|
95
|
+
CELILO_EVENT_PACKAGE_VERSION: event.package.version,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_TIMEOUT_SEC = 600;
|
|
100
|
+
|
|
101
|
+
export interface HookRunResult {
|
|
102
|
+
module: string;
|
|
103
|
+
hookName: string;
|
|
104
|
+
scriptPath: string;
|
|
105
|
+
exitCode: number | null;
|
|
106
|
+
durationMs: number;
|
|
107
|
+
stdout: string;
|
|
108
|
+
stderr: string;
|
|
109
|
+
timedOut: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run one plan entry. Spawns `bash -c <scriptPath>` so shell features
|
|
114
|
+
* (pipe, redirect, env-var expansion) work inside the hook. Captures
|
|
115
|
+
* stdout/stderr for the operator log; kills the process on timeout.
|
|
116
|
+
*
|
|
117
|
+
* Doesn't throw — the result carries the outcome. Caller (dispatcher)
|
|
118
|
+
* logs failures and keeps going to the next plan entry.
|
|
119
|
+
*/
|
|
120
|
+
export async function runHookDispatch(plan: HookDispatchPlan): Promise<HookRunResult> {
|
|
121
|
+
const timeoutSec = plan.hook.timeout ?? DEFAULT_TIMEOUT_SEC;
|
|
122
|
+
const hookName = plan.hook.name ?? plan.hook.script;
|
|
123
|
+
const startedAt = Date.now();
|
|
124
|
+
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
// `detached: true` puts bash + its children in their own
|
|
127
|
+
// process group so the timeout can kill the whole subtree (not
|
|
128
|
+
// just the bash parent, which would otherwise leave a `sleep`
|
|
129
|
+
// running). `process.kill(-pid, …)` signals the group.
|
|
130
|
+
const child = spawn('bash', [plan.scriptPath], {
|
|
131
|
+
env: { ...process.env, ...plan.env },
|
|
132
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
detached: true,
|
|
134
|
+
});
|
|
135
|
+
const pgid = child.pid; // process group leader == its own PID
|
|
136
|
+
|
|
137
|
+
let stdout = '';
|
|
138
|
+
let stderr = '';
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
141
|
+
const killGroup = (signal: NodeJS.Signals) => {
|
|
142
|
+
if (pgid == null) return;
|
|
143
|
+
try {
|
|
144
|
+
process.kill(-pgid, signal);
|
|
145
|
+
} catch {
|
|
146
|
+
// Group already gone — fall back to direct child signal.
|
|
147
|
+
try {
|
|
148
|
+
child.kill(signal);
|
|
149
|
+
} catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
timedOut = true;
|
|
156
|
+
killGroup('SIGTERM');
|
|
157
|
+
// SIGTERM may not be honored by long-running children
|
|
158
|
+
// (`sleep`, network waits). Follow up with SIGKILL after a
|
|
159
|
+
// brief grace period.
|
|
160
|
+
killTimer = setTimeout(() => killGroup('SIGKILL'), 200);
|
|
161
|
+
}, timeoutSec * 1000);
|
|
162
|
+
|
|
163
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
164
|
+
stdout += chunk.toString('utf-8');
|
|
165
|
+
});
|
|
166
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
167
|
+
stderr += chunk.toString('utf-8');
|
|
168
|
+
});
|
|
169
|
+
child.on('error', (err) => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
if (killTimer) clearTimeout(killTimer);
|
|
172
|
+
resolve({
|
|
173
|
+
module: plan.module.moduleId,
|
|
174
|
+
hookName,
|
|
175
|
+
scriptPath: plan.scriptPath,
|
|
176
|
+
exitCode: null,
|
|
177
|
+
durationMs: Date.now() - startedAt,
|
|
178
|
+
stdout,
|
|
179
|
+
stderr: `${stderr}\nspawn error: ${err.message}`,
|
|
180
|
+
timedOut: false,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
child.on('close', (code) => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
if (killTimer) clearTimeout(killTimer);
|
|
186
|
+
resolve({
|
|
187
|
+
module: plan.module.moduleId,
|
|
188
|
+
hookName,
|
|
189
|
+
scriptPath: plan.scriptPath,
|
|
190
|
+
exitCode: code,
|
|
191
|
+
durationMs: Date.now() - startedAt,
|
|
192
|
+
stdout,
|
|
193
|
+
stderr,
|
|
194
|
+
timedOut,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-bus hook dispatcher service.
|
|
3
|
+
*
|
|
4
|
+
* Stateful wrapper around `planHookDispatch` + `runHookDispatch`:
|
|
5
|
+
* loads installed-module manifests from the celilo DB, dispatches
|
|
6
|
+
* matching `on_upstream_publish` hooks when a verified PublishEvent
|
|
7
|
+
* arrives, logs outcomes.
|
|
8
|
+
*
|
|
9
|
+
* Started by the `celilo subscribers serve` daemon (in-process,
|
|
10
|
+
* sharing the same lifetime); the receiver-server calls
|
|
11
|
+
* `dispatcher.handleEvent(event)` on every verified webhook.
|
|
12
|
+
*
|
|
13
|
+
* Module loading is injectable so tests can drive the dispatcher
|
|
14
|
+
* with synthetic ModuleHookContext lists. Production loads from the
|
|
15
|
+
* DB + filesystem (manifest.yml under each module's source_path).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import type { PublishEvent } from '@celilo/event-bus/build-bus';
|
|
21
|
+
import { parse as parseYaml } from 'yaml';
|
|
22
|
+
import { getDb } from '../../db/client';
|
|
23
|
+
import { modules } from '../../db/schema';
|
|
24
|
+
import {
|
|
25
|
+
type HookRunResult,
|
|
26
|
+
type ModuleHookContext,
|
|
27
|
+
type UpstreamHookEntry,
|
|
28
|
+
planHookDispatch,
|
|
29
|
+
runHookDispatch,
|
|
30
|
+
} from './hook-dispatch';
|
|
31
|
+
|
|
32
|
+
export interface HookDispatcher {
|
|
33
|
+
/** Run the matching hooks for a single event. Returns the per-hook results. */
|
|
34
|
+
handleEvent(event: PublishEvent): Promise<HookRunResult[]>;
|
|
35
|
+
/** Clean up (currently a no-op; included for symmetry with the receiver). */
|
|
36
|
+
stop(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HookDispatcherOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Override the module-loading step. Tests inject a fixture; in
|
|
42
|
+
* production we omit and the dispatcher reads from the celilo
|
|
43
|
+
* modules table.
|
|
44
|
+
*/
|
|
45
|
+
loadModules?: () => ModuleHookContext[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function startHookDispatcher(
|
|
49
|
+
opts: HookDispatcherOptions = {},
|
|
50
|
+
): Promise<HookDispatcher> {
|
|
51
|
+
const loadModules = opts.loadModules ?? loadInstalledModulesWithHooks;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async handleEvent(event) {
|
|
55
|
+
const moduleContexts = loadModules();
|
|
56
|
+
const plans = planHookDispatch(event, moduleContexts);
|
|
57
|
+
if (plans.length === 0) {
|
|
58
|
+
console.log(
|
|
59
|
+
`[build-bus] ${event.package.name}@${event.package.version} (${event.tag}) — no module hooks match`,
|
|
60
|
+
);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const results: HookRunResult[] = [];
|
|
64
|
+
for (const plan of plans) {
|
|
65
|
+
console.log(
|
|
66
|
+
`[build-bus] ${event.package.name}@${event.package.version} → ${plan.module.moduleId} (${plan.hook.name ?? plan.hook.script})`,
|
|
67
|
+
);
|
|
68
|
+
const result = await runHookDispatch(plan);
|
|
69
|
+
results.push(result);
|
|
70
|
+
if (result.exitCode === 0) {
|
|
71
|
+
console.log(` ✓ exit 0 in ${result.durationMs}ms`);
|
|
72
|
+
} else if (result.timedOut) {
|
|
73
|
+
console.warn(` ✗ timed out after ${result.durationMs}ms`);
|
|
74
|
+
} else {
|
|
75
|
+
console.warn(` ✗ exit ${result.exitCode} in ${result.durationMs}ms`);
|
|
76
|
+
if (result.stderr) console.warn(` stderr: ${result.stderr.trim().slice(0, 500)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
},
|
|
81
|
+
async stop() {
|
|
82
|
+
// No long-running connections to clean up — modules are
|
|
83
|
+
// re-loaded per event so we can hot-pick up new installs.
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Load every installed module's `on_upstream_publish` hooks from the
|
|
90
|
+
* celilo DB + each module's manifest.yml. Modules without the hook
|
|
91
|
+
* type are filtered out. Modules whose manifest fails to parse are
|
|
92
|
+
* silently skipped — the operator sees this when they next run
|
|
93
|
+
* `celilo module check`.
|
|
94
|
+
*/
|
|
95
|
+
function loadInstalledModulesWithHooks(): ModuleHookContext[] {
|
|
96
|
+
const db = getDb();
|
|
97
|
+
const rows = db.select({ id: modules.id, sourcePath: modules.sourcePath }).from(modules).all();
|
|
98
|
+
const out: ModuleHookContext[] = [];
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
if (!row.sourcePath) continue;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = parseYaml(readFileSync(join(row.sourcePath, 'manifest.yml'), 'utf-8')) as {
|
|
103
|
+
hooks?: { on_upstream_publish?: UpstreamHookEntry[] };
|
|
104
|
+
};
|
|
105
|
+
const hooks = parsed.hooks?.on_upstream_publish ?? [];
|
|
106
|
+
if (hooks.length === 0) continue;
|
|
107
|
+
out.push({ moduleId: row.id, sourcePath: row.sourcePath, hooks });
|
|
108
|
+
} catch {
|
|
109
|
+
// Skip modules whose manifests can't be loaded — log when this
|
|
110
|
+
// turns out to be a real problem rather than during routine
|
|
111
|
+
// event dispatch.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-bus service surface for the celilo app.
|
|
3
|
+
*
|
|
4
|
+
* `@celilo/event-bus/build-bus` carries the wire types + crypto;
|
|
5
|
+
* this layer adds the operator-side concerns:
|
|
6
|
+
*
|
|
7
|
+
* - subscriber-store: read/write the static-config subscriber
|
|
8
|
+
* list under getDataDir()
|
|
9
|
+
* - fan-out: signed HTTP delivery with retry
|
|
10
|
+
* - event-factory: build PublishEvents from the publish flow's
|
|
11
|
+
* output
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
subscriberStorePath,
|
|
16
|
+
loadSubscribers,
|
|
17
|
+
saveSubscribers,
|
|
18
|
+
addSubscriber,
|
|
19
|
+
removeSubscriberByUrl,
|
|
20
|
+
} from './subscriber-store';
|
|
21
|
+
|
|
22
|
+
export { fanOut, formatDeliveryResult } from './fan-out';
|
|
23
|
+
export type { FanOutOptions } from './fan-out';
|
|
24
|
+
|
|
25
|
+
export { eventsForPublished } from './event-factory';
|
|
26
|
+
export type { EventFactoryInput, PublishedItem } from './event-factory';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
recordDeliveryOutcome,
|
|
30
|
+
WEBHOOK_DELIVERED_EVENT,
|
|
31
|
+
WEBHOOK_FAILED_EVENT,
|
|
32
|
+
} from './delivery-events';
|
|
33
|
+
export type { WebhookDeliveryPayload } from './delivery-events';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
aggregateSubscriberStatus,
|
|
37
|
+
describeAgo,
|
|
38
|
+
formatStatus,
|
|
39
|
+
loadSubscriberStatus,
|
|
40
|
+
} from './status';
|
|
41
|
+
export type { DeliveryRecord, FailureSnapshot, SubscriberStatus } from './status';
|