@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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan-out + subscriber-store integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Uses real localhost HTTP servers (Bun.serve) as subscriber stand-
|
|
5
|
+
* ins, not verdaccio — these tests exercise the webhook flow itself
|
|
6
|
+
* (signing, retry, parallel fan-out), not anything package-publish-
|
|
7
|
+
* specific. Each test spins up its own server on a random port and
|
|
8
|
+
* tears it down in afterEach.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import type { PublishEvent, Subscriber, WebhookEnvelope } from '@celilo/event-bus/build-bus';
|
|
16
|
+
import { verifyEvent } from '@celilo/event-bus/build-bus';
|
|
17
|
+
import { fanOut } from './fan-out';
|
|
18
|
+
import {
|
|
19
|
+
addSubscriber,
|
|
20
|
+
loadSubscribers,
|
|
21
|
+
removeSubscriberByUrl,
|
|
22
|
+
subscriberStorePath,
|
|
23
|
+
} from './subscriber-store';
|
|
24
|
+
|
|
25
|
+
function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
|
|
26
|
+
return {
|
|
27
|
+
eventId: '11111111-1111-4111-8111-111111111111',
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
registry: 'npm',
|
|
30
|
+
tag: 'latest',
|
|
31
|
+
package: { name: '@celilo/cli', version: '0.4.0' },
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TestServer {
|
|
37
|
+
url: string;
|
|
38
|
+
/** Latest envelope POST'd to this server. */
|
|
39
|
+
received: WebhookEnvelope[];
|
|
40
|
+
/** Latest x-celilo-signature header POSTed. */
|
|
41
|
+
signatures: string[];
|
|
42
|
+
stop(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Spin up a tiny HTTP server with configurable response behavior.
|
|
47
|
+
* Returns its URL so the caller can register it as a subscriber.
|
|
48
|
+
*/
|
|
49
|
+
function startServer(
|
|
50
|
+
respond: (envelope: WebhookEnvelope, callCount: number) => Response,
|
|
51
|
+
): TestServer {
|
|
52
|
+
const received: WebhookEnvelope[] = [];
|
|
53
|
+
const signatures: string[] = [];
|
|
54
|
+
let callCount = 0;
|
|
55
|
+
const server = Bun.serve({
|
|
56
|
+
port: 0,
|
|
57
|
+
fetch: async (req) => {
|
|
58
|
+
callCount++;
|
|
59
|
+
const body = await req.json();
|
|
60
|
+
received.push(body as WebhookEnvelope);
|
|
61
|
+
signatures.push(req.headers.get('x-celilo-signature') ?? '');
|
|
62
|
+
return respond(body as WebhookEnvelope, callCount);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
url: `http://localhost:${server.port}/`,
|
|
67
|
+
received,
|
|
68
|
+
signatures,
|
|
69
|
+
stop: () => server.stop(true),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('fanOut', () => {
|
|
74
|
+
test('delivers a signed envelope to a single subscriber on 200', async () => {
|
|
75
|
+
const server = startServer(() => new Response('ok', { status: 200 }));
|
|
76
|
+
try {
|
|
77
|
+
const subscriber: Subscriber = {
|
|
78
|
+
name: 'test-sub',
|
|
79
|
+
url: server.url,
|
|
80
|
+
secret: 'shared-secret',
|
|
81
|
+
match: {},
|
|
82
|
+
};
|
|
83
|
+
const event = buildEvent();
|
|
84
|
+
const results = await fanOut(event, [subscriber]);
|
|
85
|
+
|
|
86
|
+
expect(results).toHaveLength(1);
|
|
87
|
+
expect(results[0].ok).toBe(true);
|
|
88
|
+
expect(results[0].status).toBe(200);
|
|
89
|
+
expect(results[0].attempts).toBe(1);
|
|
90
|
+
|
|
91
|
+
// The receiver saw the envelope and the signature header.
|
|
92
|
+
expect(server.received).toHaveLength(1);
|
|
93
|
+
expect(server.received[0].event.eventId).toBe(event.eventId);
|
|
94
|
+
expect(server.signatures[0]).toMatch(/^sha256=[0-9a-f]{64}$/);
|
|
95
|
+
|
|
96
|
+
// The signature actually verifies against the secret.
|
|
97
|
+
expect(verifyEvent(server.received[0].event, server.signatures[0], 'shared-secret')).toBe(
|
|
98
|
+
true,
|
|
99
|
+
);
|
|
100
|
+
// ...and doesn't verify against a different secret.
|
|
101
|
+
expect(verifyEvent(server.received[0].event, server.signatures[0], 'wrong')).toBe(false);
|
|
102
|
+
} finally {
|
|
103
|
+
server.stop();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('skips subscribers whose match rule does not fire', async () => {
|
|
108
|
+
const server = startServer(() => new Response('ok'));
|
|
109
|
+
try {
|
|
110
|
+
const event = buildEvent({ registry: 'npm', tag: 'latest' });
|
|
111
|
+
const results = await fanOut(event, [
|
|
112
|
+
{
|
|
113
|
+
url: server.url,
|
|
114
|
+
secret: 'k',
|
|
115
|
+
match: { tag: 'alpha' }, // doesn't match event.tag=latest
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
expect(results).toHaveLength(0);
|
|
119
|
+
expect(server.received).toHaveLength(0);
|
|
120
|
+
} finally {
|
|
121
|
+
server.stop();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('retries on 5xx and eventually succeeds', async () => {
|
|
126
|
+
const server = startServer((_e, n) =>
|
|
127
|
+
n < 3 ? new Response('boom', { status: 503 }) : new Response('ok'),
|
|
128
|
+
);
|
|
129
|
+
try {
|
|
130
|
+
const results = await fanOut(buildEvent(), [{ url: server.url, secret: 'k', match: {} }], {
|
|
131
|
+
maxAttempts: 5,
|
|
132
|
+
baseBackoffMs: 1,
|
|
133
|
+
});
|
|
134
|
+
expect(results[0].ok).toBe(true);
|
|
135
|
+
expect(results[0].attempts).toBe(3);
|
|
136
|
+
} finally {
|
|
137
|
+
server.stop();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('does NOT retry on 4xx (client error is permanent)', async () => {
|
|
142
|
+
const server = startServer(() => new Response('bad request', { status: 400 }));
|
|
143
|
+
try {
|
|
144
|
+
const results = await fanOut(buildEvent(), [{ url: server.url, secret: 'k', match: {} }], {
|
|
145
|
+
maxAttempts: 5,
|
|
146
|
+
baseBackoffMs: 1,
|
|
147
|
+
});
|
|
148
|
+
expect(results[0].ok).toBe(false);
|
|
149
|
+
expect(results[0].status).toBe(400);
|
|
150
|
+
expect(results[0].attempts).toBe(1);
|
|
151
|
+
expect(results[0].error).toContain('no retry on 4xx');
|
|
152
|
+
} finally {
|
|
153
|
+
server.stop();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('gives up after maxAttempts of 5xx', async () => {
|
|
158
|
+
const server = startServer(() => new Response('boom', { status: 503 }));
|
|
159
|
+
try {
|
|
160
|
+
const results = await fanOut(buildEvent(), [{ url: server.url, secret: 'k', match: {} }], {
|
|
161
|
+
maxAttempts: 3,
|
|
162
|
+
baseBackoffMs: 1,
|
|
163
|
+
});
|
|
164
|
+
expect(results[0].ok).toBe(false);
|
|
165
|
+
expect(results[0].status).toBe(503);
|
|
166
|
+
expect(results[0].attempts).toBe(3);
|
|
167
|
+
} finally {
|
|
168
|
+
server.stop();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('fans out to multiple subscribers in parallel', async () => {
|
|
173
|
+
const serverA = startServer(() => new Response('ok'));
|
|
174
|
+
const serverB = startServer(() => new Response('ok'));
|
|
175
|
+
try {
|
|
176
|
+
const event = buildEvent();
|
|
177
|
+
const results = await fanOut(event, [
|
|
178
|
+
{ url: serverA.url, secret: 'a', match: {} },
|
|
179
|
+
{ url: serverB.url, secret: 'b', match: {} },
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
expect(results).toHaveLength(2);
|
|
183
|
+
expect(results.every((r) => r.ok)).toBe(true);
|
|
184
|
+
expect(serverA.received).toHaveLength(1);
|
|
185
|
+
expect(serverB.received).toHaveLength(1);
|
|
186
|
+
|
|
187
|
+
// Each subscriber got its OWN signature (per-target secret).
|
|
188
|
+
expect(serverA.signatures[0]).not.toBe(serverB.signatures[0]);
|
|
189
|
+
expect(verifyEvent(serverA.received[0].event, serverA.signatures[0], 'a')).toBe(true);
|
|
190
|
+
expect(verifyEvent(serverB.received[0].event, serverB.signatures[0], 'b')).toBe(true);
|
|
191
|
+
} finally {
|
|
192
|
+
serverA.stop();
|
|
193
|
+
serverB.stop();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('records network failure as ok=false', async () => {
|
|
198
|
+
const results = await fanOut(
|
|
199
|
+
buildEvent(),
|
|
200
|
+
[{ url: 'http://127.0.0.1:1/no-such-port', secret: 'k', match: {} }],
|
|
201
|
+
{
|
|
202
|
+
maxAttempts: 1,
|
|
203
|
+
timeoutMs: 200,
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
expect(results[0].ok).toBe(false);
|
|
207
|
+
expect(results[0].error).toBeDefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('subscriber-store', () => {
|
|
212
|
+
let storeDir: string;
|
|
213
|
+
let originalEnv: string | undefined;
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
storeDir = mkdtempSync(join(tmpdir(), 'celilo-build-bus-store-'));
|
|
217
|
+
originalEnv = process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH;
|
|
218
|
+
process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH = join(storeDir, 'subscribers.json');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(() => {
|
|
222
|
+
process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH = originalEnv;
|
|
223
|
+
rmSync(storeDir, { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('subscriberStorePath honors the env override', () => {
|
|
227
|
+
expect(subscriberStorePath()).toBe(join(storeDir, 'subscribers.json'));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('loadSubscribers returns [] when the file is missing', () => {
|
|
231
|
+
expect(loadSubscribers()).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('round-trip: add → load → remove', () => {
|
|
235
|
+
const sub: Subscriber = {
|
|
236
|
+
name: 'lunacycle',
|
|
237
|
+
url: 'https://lunacycle.lab/build-bus',
|
|
238
|
+
secret: 'hex-secret-here',
|
|
239
|
+
match: { registry: 'npm', packagePattern: '@celilo/*' },
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const addResult = addSubscriber(sub);
|
|
243
|
+
expect(addResult.replaced).toBeUndefined();
|
|
244
|
+
|
|
245
|
+
const after = loadSubscribers();
|
|
246
|
+
expect(after).toHaveLength(1);
|
|
247
|
+
expect(after[0]).toEqual(sub);
|
|
248
|
+
|
|
249
|
+
const removed = removeSubscriberByUrl(sub.url);
|
|
250
|
+
expect(removed).toEqual(sub);
|
|
251
|
+
expect(loadSubscribers()).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('adding a subscriber with an existing URL replaces it (idempotent rotation)', () => {
|
|
255
|
+
const original: Subscriber = {
|
|
256
|
+
url: 'https://x.test/',
|
|
257
|
+
secret: 'old',
|
|
258
|
+
match: { registry: 'npm' },
|
|
259
|
+
};
|
|
260
|
+
addSubscriber(original);
|
|
261
|
+
|
|
262
|
+
const rotated: Subscriber = {
|
|
263
|
+
url: 'https://x.test/',
|
|
264
|
+
secret: 'new',
|
|
265
|
+
match: { registry: 'celilo-registry' },
|
|
266
|
+
};
|
|
267
|
+
const result = addSubscriber(rotated);
|
|
268
|
+
|
|
269
|
+
expect(result.replaced).toEqual(original);
|
|
270
|
+
const after = loadSubscribers();
|
|
271
|
+
expect(after).toHaveLength(1);
|
|
272
|
+
expect(after[0].secret).toBe('new');
|
|
273
|
+
expect(after[0].match.registry).toBe('celilo-registry');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('removeSubscriberByUrl returns undefined when URL not registered', () => {
|
|
277
|
+
expect(removeSubscriberByUrl('https://not-here.test/')).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook fan-out for the build bus ([[v2/BUILD_BUS.md]] Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Given a PublishEvent and a subscriber list, this module:
|
|
5
|
+
* 1. Filters subscribers whose match rule fires for the event.
|
|
6
|
+
* 2. Builds a signed WebhookEnvelope per subscriber (each gets
|
|
7
|
+
* its own HMAC signature with the subscriber's per-target
|
|
8
|
+
* secret).
|
|
9
|
+
* 3. Fires concurrent HTTP POSTs with exponential-backoff retry.
|
|
10
|
+
* 4. Returns per-subscriber DeliveryResults so the caller can
|
|
11
|
+
* surface failures to the operator.
|
|
12
|
+
*
|
|
13
|
+
* Best-effort by design — fan-out failures do NOT fail the publish.
|
|
14
|
+
* The spec calls for delivery to be at-least-once with operator-
|
|
15
|
+
* visible failure (§9), not a publish blocker.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type DeliveryResult,
|
|
20
|
+
type PublishEvent,
|
|
21
|
+
type Subscriber,
|
|
22
|
+
type WebhookEnvelope,
|
|
23
|
+
signEvent,
|
|
24
|
+
subscribersFor,
|
|
25
|
+
} from '@celilo/event-bus/build-bus';
|
|
26
|
+
|
|
27
|
+
export interface FanOutOptions {
|
|
28
|
+
/** Total attempts (1 = no retry). Default: 4. */
|
|
29
|
+
maxAttempts?: number;
|
|
30
|
+
/** Per-attempt request timeout. Default: 10s. */
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
/** First retry waits this long; subsequent doubles. Default: 500ms. */
|
|
33
|
+
baseBackoffMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Override the fetch implementation. Tests inject a stub so they
|
|
36
|
+
* don't have to actually open sockets. Production uses the global
|
|
37
|
+
* fetch.
|
|
38
|
+
*/
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
/**
|
|
41
|
+
* "Now" injection for deterministic durationMs in tests. Default:
|
|
42
|
+
* Date.now.
|
|
43
|
+
*/
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_MAX_ATTEMPTS = 4;
|
|
48
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
49
|
+
const DEFAULT_BASE_BACKOFF_MS = 500;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fan out a single event to every matching subscriber. Returns one
|
|
53
|
+
* DeliveryResult per matched subscriber. Subscribers whose match
|
|
54
|
+
* rules don't fire are silently filtered out and don't appear in
|
|
55
|
+
* the result.
|
|
56
|
+
*/
|
|
57
|
+
export async function fanOut(
|
|
58
|
+
event: PublishEvent,
|
|
59
|
+
subscribers: Subscriber[],
|
|
60
|
+
opts: FanOutOptions = {},
|
|
61
|
+
): Promise<DeliveryResult[]> {
|
|
62
|
+
const matched = subscribersFor(event, subscribers);
|
|
63
|
+
return Promise.all(matched.map((s) => deliverOne(event, s, opts)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function deliverOne(
|
|
67
|
+
event: PublishEvent,
|
|
68
|
+
subscriber: Subscriber,
|
|
69
|
+
opts: FanOutOptions,
|
|
70
|
+
): Promise<DeliveryResult> {
|
|
71
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
72
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
73
|
+
const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
|
|
74
|
+
const fetchFn = opts.fetch ?? fetch;
|
|
75
|
+
const now = opts.now ?? Date.now;
|
|
76
|
+
|
|
77
|
+
const envelope: WebhookEnvelope = {
|
|
78
|
+
event,
|
|
79
|
+
signature: signEvent(event, subscriber.secret),
|
|
80
|
+
};
|
|
81
|
+
const body = JSON.stringify(envelope);
|
|
82
|
+
const startedAt = now();
|
|
83
|
+
|
|
84
|
+
let lastError: string | undefined;
|
|
85
|
+
let lastStatus: number | undefined;
|
|
86
|
+
|
|
87
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetchFn(subscriber.url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'content-type': 'application/json',
|
|
95
|
+
'x-celilo-signature': envelope.signature,
|
|
96
|
+
'x-celilo-event-id': event.eventId,
|
|
97
|
+
},
|
|
98
|
+
body,
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
});
|
|
101
|
+
lastStatus = response.status;
|
|
102
|
+
if (response.ok) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
return {
|
|
105
|
+
subscriber,
|
|
106
|
+
ok: true,
|
|
107
|
+
status: response.status,
|
|
108
|
+
attempts: attempt,
|
|
109
|
+
durationMs: now() - startedAt,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
lastError = `HTTP ${response.status}`;
|
|
113
|
+
// 4xx is a client error — won't recover by retrying. Don't burn
|
|
114
|
+
// attempts on it (saves time + makes the operator-visible
|
|
115
|
+
// error actionable).
|
|
116
|
+
if (response.status >= 400 && response.status < 500) {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
return {
|
|
119
|
+
subscriber,
|
|
120
|
+
ok: false,
|
|
121
|
+
status: response.status,
|
|
122
|
+
attempts: attempt,
|
|
123
|
+
error: `HTTP ${response.status} (no retry on 4xx)`,
|
|
124
|
+
durationMs: now() - startedAt,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (attempt < maxAttempts) {
|
|
134
|
+
const backoff = baseBackoffMs * 2 ** (attempt - 1);
|
|
135
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
subscriber,
|
|
141
|
+
ok: false,
|
|
142
|
+
status: lastStatus,
|
|
143
|
+
attempts: maxAttempts,
|
|
144
|
+
error: lastError ?? 'unknown delivery failure',
|
|
145
|
+
durationMs: now() - startedAt,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render a single delivery result for the operator. Operator
|
|
151
|
+
* surface in `celilo publish` summary + `celilo subscribers status`.
|
|
152
|
+
*/
|
|
153
|
+
export function formatDeliveryResult(result: DeliveryResult): string {
|
|
154
|
+
const target = result.subscriber.name
|
|
155
|
+
? `${result.subscriber.name} (${result.subscriber.url})`
|
|
156
|
+
: result.subscriber.url;
|
|
157
|
+
if (result.ok) {
|
|
158
|
+
return `✓ ${target} — ${result.status} in ${result.durationMs}ms (${result.attempts} attempt${result.attempts === 1 ? '' : 's'})`;
|
|
159
|
+
}
|
|
160
|
+
return `✗ ${target} — ${result.error} (${result.attempts} attempt${result.attempts === 1 ? '' : 's'}, ${result.durationMs}ms)`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end test: build-bus hook dispatcher → modules/celilo-mgmt's
|
|
3
|
+
* on_upstream_publish rules → self-update script (with the actual
|
|
4
|
+
* `bun add -g` replaced by a noop so the test doesn't mutate the
|
|
5
|
+
* operator's global install).
|
|
6
|
+
*
|
|
7
|
+
* Confirms the manifest's match rules pick the right hook for a
|
|
8
|
+
* given event AND that the dispatcher passes the expected env vars
|
|
9
|
+
* to the script.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import type { PublishEvent } from '@celilo/event-bus/build-bus';
|
|
18
|
+
import { parse as parseYaml } from 'yaml';
|
|
19
|
+
import type { ModuleHookContext, UpstreamHookEntry } from './hook-dispatch';
|
|
20
|
+
import { startHookDispatcher } from './hook-dispatcher';
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..');
|
|
23
|
+
|
|
24
|
+
function loadCeliloMgmtHooks(): UpstreamHookEntry[] {
|
|
25
|
+
// Parse the real modules/celilo-mgmt/manifest.yml so this test
|
|
26
|
+
// tracks the actual deployed manifest — drift here is a test
|
|
27
|
+
// failure rather than silent breakage.
|
|
28
|
+
const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
|
|
29
|
+
const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
|
|
30
|
+
hooks?: { on_upstream_publish?: UpstreamHookEntry[] };
|
|
31
|
+
};
|
|
32
|
+
return parsed.hooks?.on_upstream_publish ?? [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
|
|
36
|
+
return {
|
|
37
|
+
eventId: 'evt-1',
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
registry: 'npm',
|
|
40
|
+
tag: 'latest',
|
|
41
|
+
package: { name: '@celilo/cli', version: '0.4.0' },
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('hook-dispatcher → modules/celilo-mgmt', () => {
|
|
47
|
+
let tmpModuleDir: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
// Use the real manifest's hooks but point them at a noop script
|
|
51
|
+
// in a temp dir so we don't actually shell out to `bun add -g`.
|
|
52
|
+
tmpModuleDir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-hook-test-'));
|
|
53
|
+
mkdirSync(join(tmpModuleDir, 'scripts'), { recursive: true });
|
|
54
|
+
const scriptPath = join(tmpModuleDir, 'scripts', 'on_upstream_publish.sh');
|
|
55
|
+
// Record the env we received so the test can verify the dispatcher
|
|
56
|
+
// passed the right vars. Sentinel file lives in tmpModuleDir.
|
|
57
|
+
const logPath = join(tmpModuleDir, 'invocations.log');
|
|
58
|
+
writeFileSync(
|
|
59
|
+
scriptPath,
|
|
60
|
+
`#!/usr/bin/env bash
|
|
61
|
+
echo "name=$CELILO_EVENT_PACKAGE_NAME version=$CELILO_EVENT_PACKAGE_VERSION tag=$CELILO_EVENT_TAG" >> ${logPath}
|
|
62
|
+
exit 0
|
|
63
|
+
`,
|
|
64
|
+
);
|
|
65
|
+
chmodSync(scriptPath, 0o755);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
rmSync(tmpModuleDir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function buildModuleContext(): ModuleHookContext {
|
|
73
|
+
const hooks = loadCeliloMgmtHooks();
|
|
74
|
+
// Rewrite the script path to point at the temp noop. The match
|
|
75
|
+
// rules from the real manifest are preserved — that's what we're
|
|
76
|
+
// testing.
|
|
77
|
+
return {
|
|
78
|
+
moduleId: 'celilo-mgmt',
|
|
79
|
+
sourcePath: tmpModuleDir,
|
|
80
|
+
hooks,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('the real manifest declares two on_upstream_publish hooks (cli + e2e)', () => {
|
|
85
|
+
const hooks = loadCeliloMgmtHooks();
|
|
86
|
+
expect(hooks).toHaveLength(2);
|
|
87
|
+
const names = hooks.map((h) => h.name).sort();
|
|
88
|
+
expect(names).toEqual(['self-update-celilo-cli', 'self-update-celilo-e2e']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a @celilo/cli@latest event fires the celilo-cli hook only', async () => {
|
|
92
|
+
const dispatcher = await startHookDispatcher({
|
|
93
|
+
loadModules: () => [buildModuleContext()],
|
|
94
|
+
});
|
|
95
|
+
const results = await dispatcher.handleEvent(
|
|
96
|
+
buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
|
|
97
|
+
);
|
|
98
|
+
await dispatcher.stop();
|
|
99
|
+
|
|
100
|
+
expect(results).toHaveLength(1);
|
|
101
|
+
expect(results[0].hookName).toBe('self-update-celilo-cli');
|
|
102
|
+
expect(results[0].exitCode).toBe(0);
|
|
103
|
+
|
|
104
|
+
// The log written by the noop script should show the env it received.
|
|
105
|
+
const log = readFileSync(join(tmpModuleDir, 'invocations.log'), 'utf-8');
|
|
106
|
+
expect(log).toContain('name=@celilo/cli version=0.5.0 tag=latest');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a @celilo/e2e@latest event fires the celilo-e2e hook only', async () => {
|
|
110
|
+
const dispatcher = await startHookDispatcher({
|
|
111
|
+
loadModules: () => [buildModuleContext()],
|
|
112
|
+
});
|
|
113
|
+
const results = await dispatcher.handleEvent(
|
|
114
|
+
buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }),
|
|
115
|
+
);
|
|
116
|
+
await dispatcher.stop();
|
|
117
|
+
|
|
118
|
+
expect(results).toHaveLength(1);
|
|
119
|
+
expect(results[0].hookName).toBe('self-update-celilo-e2e');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('an alpha-tag event for @celilo/cli does NOT fire (tag must be "latest")', async () => {
|
|
123
|
+
const dispatcher = await startHookDispatcher({
|
|
124
|
+
loadModules: () => [buildModuleContext()],
|
|
125
|
+
});
|
|
126
|
+
const results = await dispatcher.handleEvent(
|
|
127
|
+
buildEvent({ tag: 'alpha', package: { name: '@celilo/cli', version: '0.5.0-alpha.0' } }),
|
|
128
|
+
);
|
|
129
|
+
await dispatcher.stop();
|
|
130
|
+
expect(results).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a non-celilo package (e.g. @celilo/event-bus) does NOT fire', async () => {
|
|
134
|
+
const dispatcher = await startHookDispatcher({
|
|
135
|
+
loadModules: () => [buildModuleContext()],
|
|
136
|
+
});
|
|
137
|
+
const results = await dispatcher.handleEvent(
|
|
138
|
+
buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } }),
|
|
139
|
+
);
|
|
140
|
+
await dispatcher.stop();
|
|
141
|
+
expect(results).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a non-npm registry event (e.g. celilo-registry module publish) does NOT fire', async () => {
|
|
145
|
+
const dispatcher = await startHookDispatcher({
|
|
146
|
+
loadModules: () => [buildModuleContext()],
|
|
147
|
+
});
|
|
148
|
+
const results = await dispatcher.handleEvent(
|
|
149
|
+
buildEvent({
|
|
150
|
+
registry: 'celilo-registry',
|
|
151
|
+
package: { name: '@celilo/cli', version: '0.5.0' },
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
await dispatcher.stop();
|
|
155
|
+
expect(results).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
});
|