@celilo/cli 2.3.0 → 3.0.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/CELILO_CORE_MODULES.md +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +16 -3
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +19 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +106 -28
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice-7 wiring, end to end: build-bus dispatcher → runNamedHook →
|
|
3
|
+
* invokeHook → a real defineHook child, with the verified PublishEvent
|
|
4
|
+
* arriving as the hook's `event` input (tasks.md 7.3/7.4).
|
|
5
|
+
*
|
|
6
|
+
* The scratch-module pattern is hook-state-dir.test.ts's: a temp module
|
|
7
|
+
* whose scripts resolve the WORKSPACE @celilo/capabilities through a
|
|
8
|
+
* symlink, because a real module in the store carries its own bundled copy
|
|
9
|
+
* (modules run their bundled capabilities, celilo#173) and the jail binds
|
|
10
|
+
* the module's tree and nothing above it.
|
|
11
|
+
*
|
|
12
|
+
* The database is an isolated client at a temp path, never the operator's
|
|
13
|
+
* live store.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
17
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import type { PublishEvent } from '@celilo/event-bus/build-bus';
|
|
21
|
+
import { type DbClient, createDbClient } from '../../db/client';
|
|
22
|
+
import { buildBusHookRuns, modules } from '../../db/schema';
|
|
23
|
+
import { startHookDispatcher } from './hook-dispatcher';
|
|
24
|
+
|
|
25
|
+
const CAPABILITIES_PACKAGE = resolve(
|
|
26
|
+
__dirname,
|
|
27
|
+
'..',
|
|
28
|
+
'..',
|
|
29
|
+
'..',
|
|
30
|
+
'..',
|
|
31
|
+
'..',
|
|
32
|
+
'packages',
|
|
33
|
+
'capabilities',
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
|
|
37
|
+
return {
|
|
38
|
+
eventId: 'evt-1',
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
registry: 'npm',
|
|
41
|
+
tag: 'latest',
|
|
42
|
+
package: { name: '@celilo/cli', version: '0.5.0' },
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A module whose on_upstream_publish hook echoes the event fields it
|
|
49
|
+
* received back as outputs, so the test can assert exactly what crossed
|
|
50
|
+
* the executor boundary. When `failing` is true the handler throws
|
|
51
|
+
* instead, for the ledger test below.
|
|
52
|
+
*/
|
|
53
|
+
function scratchUpstreamModule(failing = false): string {
|
|
54
|
+
const root = mkdtempSync(join(tmpdir(), 'celilo-upstream-module-'));
|
|
55
|
+
mkdirSync(join(root, 'scripts'), { recursive: true });
|
|
56
|
+
const handlerBody = failing
|
|
57
|
+
? ` throw new Error('boom from the jailed hook');`
|
|
58
|
+
: ` const event = ctx.event as { package?: { name?: string; version?: string } };
|
|
59
|
+
// Two channels on purpose: the outputs come back through the broker,
|
|
60
|
+
// and the file is an independent read the test can make without
|
|
61
|
+
// trusting the dispatcher's result shape.
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(ctx.stateDir as string, 'received.json'),
|
|
64
|
+
JSON.stringify({ name: event?.package?.name ?? null, version: event?.package?.version ?? null }),
|
|
65
|
+
);
|
|
66
|
+
return { received_name: event?.package?.name ?? null, received_version: event?.package?.version ?? null };`;
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(root, 'scripts', 'on-upstream-publish.ts'),
|
|
69
|
+
`
|
|
70
|
+
import { defineHook } from '@celilo/capabilities';
|
|
71
|
+
import { writeFileSync } from 'node:fs';
|
|
72
|
+
import { join } from 'node:path';
|
|
73
|
+
export default defineHook({
|
|
74
|
+
requires: [] as const,
|
|
75
|
+
handler: async (ctx) => {
|
|
76
|
+
${handlerBody}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
|
+
// See the file docblock: the fixture must carry its own capabilities link.
|
|
82
|
+
mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true });
|
|
83
|
+
symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities'));
|
|
84
|
+
return root;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let db: DbClient;
|
|
88
|
+
let dir: string;
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-upstream-dispatch-'));
|
|
92
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
93
|
+
process.env.CELILO_EVENT_BUS_PATH = join(dir, 'events.db');
|
|
94
|
+
process.env.CELILO_DATA_DIR = join(dir, 'data');
|
|
95
|
+
mkdirSync(join(dir, 'data'), { recursive: true });
|
|
96
|
+
db = createDbClient({ path: process.env.CELILO_DB_PATH });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
db.$client.close();
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('the dispatcher dispatches on_upstream_publish through the ordinary executor', () => {
|
|
105
|
+
test('the hook receives the verified PublishEvent as its event input', async () => {
|
|
106
|
+
const moduleRoot = scratchUpstreamModule();
|
|
107
|
+
const hookEntry = {
|
|
108
|
+
name: 'echo-publish',
|
|
109
|
+
match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
|
|
110
|
+
script: './scripts/on-upstream-publish.ts',
|
|
111
|
+
timeout: 60_000,
|
|
112
|
+
};
|
|
113
|
+
db.insert(modules)
|
|
114
|
+
.values({
|
|
115
|
+
id: 'upstream-fixture',
|
|
116
|
+
name: 'upstream-fixture',
|
|
117
|
+
version: '1.0.0',
|
|
118
|
+
state: 'INSTALLED',
|
|
119
|
+
sourcePath: moduleRoot,
|
|
120
|
+
manifestData: {
|
|
121
|
+
id: 'upstream-fixture',
|
|
122
|
+
name: 'upstream-fixture',
|
|
123
|
+
version: '1.0.0',
|
|
124
|
+
celilo_contract: '1.0',
|
|
125
|
+
provides: { capabilities: [] },
|
|
126
|
+
requires: { capabilities: [] },
|
|
127
|
+
hooks: { on_upstream_publish: [hookEntry] },
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
.run();
|
|
131
|
+
|
|
132
|
+
const dispatcher = await startHookDispatcher({
|
|
133
|
+
db,
|
|
134
|
+
// The loader reads manifest.yml from disk; the fixture module keeps its
|
|
135
|
+
// manifest in the DB row (runNamedHook's source) and the context here.
|
|
136
|
+
loadModules: () => [
|
|
137
|
+
{ moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] },
|
|
138
|
+
],
|
|
139
|
+
controlPlaneInstalled: () => false,
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
const results = await dispatcher.handleEvent(
|
|
143
|
+
buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
|
|
144
|
+
);
|
|
145
|
+
expect(results).toHaveLength(1);
|
|
146
|
+
expect(results[0].module).toBe('upstream-fixture');
|
|
147
|
+
expect(results[0].hookName).toBe('echo-publish');
|
|
148
|
+
expect(results[0].success).toBe(true);
|
|
149
|
+
// The event crossed the executor into the hook's typed context,
|
|
150
|
+
// proven by the hook's own file (independent of the dispatcher's
|
|
151
|
+
// result shape, which carries no outputs).
|
|
152
|
+
const received = JSON.parse(
|
|
153
|
+
readFileSync(join(moduleRoot, 'state', 'received.json'), 'utf-8'),
|
|
154
|
+
) as { name: string | null; version: string | null };
|
|
155
|
+
expect(received).toEqual({ name: '@celilo/cli', version: '0.5.0' });
|
|
156
|
+
} finally {
|
|
157
|
+
await dispatcher.stop();
|
|
158
|
+
rmSync(moduleRoot, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a non-matching event does not run the hook', async () => {
|
|
163
|
+
const moduleRoot = scratchUpstreamModule();
|
|
164
|
+
const hookEntry = {
|
|
165
|
+
name: 'echo-publish',
|
|
166
|
+
match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
|
|
167
|
+
script: './scripts/on-upstream-publish.ts',
|
|
168
|
+
timeout: 60_000,
|
|
169
|
+
};
|
|
170
|
+
db.insert(modules)
|
|
171
|
+
.values({
|
|
172
|
+
id: 'upstream-fixture',
|
|
173
|
+
name: 'upstream-fixture',
|
|
174
|
+
version: '1.0.0',
|
|
175
|
+
state: 'INSTALLED',
|
|
176
|
+
sourcePath: moduleRoot,
|
|
177
|
+
manifestData: {
|
|
178
|
+
id: 'upstream-fixture',
|
|
179
|
+
name: 'upstream-fixture',
|
|
180
|
+
version: '1.0.0',
|
|
181
|
+
celilo_contract: '1.0',
|
|
182
|
+
provides: { capabilities: [] },
|
|
183
|
+
requires: { capabilities: [] },
|
|
184
|
+
hooks: { on_upstream_publish: [hookEntry] },
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
.run();
|
|
188
|
+
|
|
189
|
+
const dispatcher = await startHookDispatcher({
|
|
190
|
+
db,
|
|
191
|
+
loadModules: () => [
|
|
192
|
+
{ moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] },
|
|
193
|
+
],
|
|
194
|
+
controlPlaneInstalled: () => false,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const results = await dispatcher.handleEvent(
|
|
198
|
+
buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }),
|
|
199
|
+
);
|
|
200
|
+
expect(results).toEqual([]);
|
|
201
|
+
} finally {
|
|
202
|
+
await dispatcher.stop();
|
|
203
|
+
rmSync(moduleRoot, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The durable outcome record (celilo#1304): every dispatched run lands in
|
|
210
|
+
* the `build_bus_hook_runs` ledger via the production default recordRun —
|
|
211
|
+
* no injectable capture between the dispatcher and the DB. The jailed
|
|
212
|
+
* executor (slice 7) exposes no exit codes or captured stdio, so the row
|
|
213
|
+
* carries the script path (the result doesn't), a null exit code, and the
|
|
214
|
+
* executor's error text in stderr_tail.
|
|
215
|
+
*/
|
|
216
|
+
test('a failing hook run lands in the build_bus_hook_runs ledger', async () => {
|
|
217
|
+
const moduleRoot = scratchUpstreamModule(true);
|
|
218
|
+
const hookEntry = {
|
|
219
|
+
name: 'echo-publish',
|
|
220
|
+
match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
|
|
221
|
+
script: './scripts/on-upstream-publish.ts',
|
|
222
|
+
timeout: 60_000,
|
|
223
|
+
};
|
|
224
|
+
db.insert(modules)
|
|
225
|
+
.values({
|
|
226
|
+
id: 'upstream-fixture',
|
|
227
|
+
name: 'upstream-fixture',
|
|
228
|
+
version: '1.0.0',
|
|
229
|
+
state: 'INSTALLED',
|
|
230
|
+
sourcePath: moduleRoot,
|
|
231
|
+
manifestData: {
|
|
232
|
+
id: 'upstream-fixture',
|
|
233
|
+
name: 'upstream-fixture',
|
|
234
|
+
version: '1.0.0',
|
|
235
|
+
celilo_contract: '1.0',
|
|
236
|
+
provides: { capabilities: [] },
|
|
237
|
+
requires: { capabilities: [] },
|
|
238
|
+
hooks: { on_upstream_publish: [hookEntry] },
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
.run();
|
|
242
|
+
|
|
243
|
+
const dispatcher = await startHookDispatcher({
|
|
244
|
+
db,
|
|
245
|
+
loadModules: () => [
|
|
246
|
+
{ moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] },
|
|
247
|
+
],
|
|
248
|
+
controlPlaneInstalled: () => false,
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
await dispatcher.handleEvent(
|
|
252
|
+
buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const rows = db.select().from(buildBusHookRuns).all();
|
|
256
|
+
expect(rows).toHaveLength(1);
|
|
257
|
+
const row = rows[0];
|
|
258
|
+
expect(row.eventId).toBe('evt-1');
|
|
259
|
+
expect(row.packageName).toBe('@celilo/cli');
|
|
260
|
+
expect(row.moduleId).toBe('upstream-fixture');
|
|
261
|
+
expect(row.hookName).toBe('echo-publish');
|
|
262
|
+
expect(row.scriptPath).toBe('./scripts/on-upstream-publish.ts');
|
|
263
|
+
expect(row.exitCode).toBeNull();
|
|
264
|
+
expect(row.stderrTail).toContain('boom from the jailed hook');
|
|
265
|
+
} finally {
|
|
266
|
+
await dispatcher.stop();
|
|
267
|
+
rmSync(moduleRoot, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
@@ -1,37 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* End-to-end
|
|
3
|
-
*
|
|
4
|
-
* `bun add -g` replaced by a noop so the test doesn't mutate the
|
|
5
|
-
* operator's global install).
|
|
2
|
+
* End-to-end tests for the framework self-update on a build-bus publish
|
|
3
|
+
* event (module-orchestrator-primitives slice 7, tasks.md 7.2).
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* celilo-mgmt's on_upstream_publish bash hook is gone: self-update is
|
|
6
|
+
* framework work in the dispatcher now (design D6 — class H's answer).
|
|
7
|
+
* These tests pin the three things that must hold:
|
|
8
|
+
*
|
|
9
|
+
* 1. The real celilo-mgmt manifest declares NO on_upstream_publish — the
|
|
10
|
+
* work lives in the framework, and a hook creeping back is a second
|
|
11
|
+
* self-update racing the framework one.
|
|
12
|
+
* 2. planSelfUpdate reproduces the old manifest's match rules exactly
|
|
13
|
+
* (npm + latest + the two binary packages).
|
|
14
|
+
* 3. The dispatcher runs the update ONLY when the control-plane module
|
|
15
|
+
* is installed, and logs — never throws — when it fails.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
-
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
14
19
|
import { readFileSync } from 'node:fs';
|
|
15
|
-
import { tmpdir } from 'node:os';
|
|
16
20
|
import { join } from 'node:path';
|
|
17
21
|
import type { PublishEvent } from '@celilo/event-bus/build-bus';
|
|
18
22
|
import { parse as parseYaml } from 'yaml';
|
|
19
|
-
import type { ModuleHookContext, UpstreamHookEntry } from './hook-dispatch';
|
|
20
23
|
import { startHookDispatcher } from './hook-dispatcher';
|
|
24
|
+
import { type SelfUpdatePlan, planSelfUpdate } from './self-update';
|
|
21
25
|
|
|
22
26
|
const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..');
|
|
23
27
|
|
|
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
28
|
function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
|
|
36
29
|
return {
|
|
37
30
|
eventId: 'evt-1',
|
|
@@ -43,163 +36,122 @@ function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
|
|
|
43
36
|
};
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
describe('
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
39
|
+
describe('celilo-mgmt declares no on_upstream_publish (self-update is framework work)', () => {
|
|
40
|
+
test('the real manifest has no on_upstream_publish block', () => {
|
|
41
|
+
const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
|
|
42
|
+
const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
|
|
43
|
+
hooks?: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
expect(parsed.hooks?.on_upstream_publish).toBeUndefined();
|
|
46
|
+
// The module is still the canonical build-bus subscriber via its
|
|
47
|
+
// registry-poll subscription — that half did not move.
|
|
48
|
+
expect(Object.keys(parsed.hooks ?? {})).toContain('on_backup');
|
|
66
49
|
});
|
|
50
|
+
});
|
|
67
51
|
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
describe('planSelfUpdate (the old manifest match rules, now in the framework)', () => {
|
|
53
|
+
test('a @celilo/cli@latest npm event self-updates the cli binary', () => {
|
|
54
|
+
const plan = planSelfUpdate(buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }));
|
|
55
|
+
expect(plan).toEqual({ packageName: '@celilo/cli', version: '0.5.0', binary: 'celilo' });
|
|
70
56
|
});
|
|
71
57
|
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// testing.
|
|
77
|
-
return {
|
|
78
|
-
moduleId: 'celilo-mgmt',
|
|
79
|
-
sourcePath: tmpModuleDir,
|
|
80
|
-
hooks,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
58
|
+
test('a @celilo/e2e@latest npm event self-updates the e2e binary', () => {
|
|
59
|
+
const plan = planSelfUpdate(buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }));
|
|
60
|
+
expect(plan).toEqual({ packageName: '@celilo/e2e', version: '0.8.0', binary: 'cele2e' });
|
|
61
|
+
});
|
|
83
62
|
|
|
84
|
-
test('
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
63
|
+
test('an alpha-tag event for @celilo/cli does NOT fire (tag must be "latest")', () => {
|
|
64
|
+
expect(
|
|
65
|
+
planSelfUpdate(
|
|
66
|
+
buildEvent({ tag: 'alpha', package: { name: '@celilo/cli', version: '0.5.0-alpha.0' } }),
|
|
67
|
+
),
|
|
68
|
+
).toBeNull();
|
|
89
69
|
});
|
|
90
70
|
|
|
91
|
-
test('a
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
|
|
97
|
-
);
|
|
98
|
-
await dispatcher.stop();
|
|
71
|
+
test('a non-celilo package does NOT fire', () => {
|
|
72
|
+
expect(
|
|
73
|
+
planSelfUpdate(buildEvent({ package: { name: 'left-pad', version: '1.3.0' } })),
|
|
74
|
+
).toBeNull();
|
|
75
|
+
});
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
expect(
|
|
102
|
-
|
|
77
|
+
test('a transitive @celilo package (no standalone binary) does NOT fire', () => {
|
|
78
|
+
expect(
|
|
79
|
+
planSelfUpdate(buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } })),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
});
|
|
103
82
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
83
|
+
test('a non-npm registry event does NOT fire', () => {
|
|
84
|
+
expect(
|
|
85
|
+
planSelfUpdate(
|
|
86
|
+
buildEvent({
|
|
87
|
+
registry: 'celilo-registry',
|
|
88
|
+
package: { name: '@celilo/cli', version: '0.5.0' },
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
).toBeNull();
|
|
107
92
|
});
|
|
93
|
+
});
|
|
108
94
|
|
|
109
|
-
|
|
110
|
-
|
|
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();
|
|
95
|
+
describe('dispatcher runs the framework self-update behind the control-plane gate', () => {
|
|
96
|
+
let selfUpdateCalls: SelfUpdatePlan[];
|
|
117
97
|
|
|
118
|
-
|
|
119
|
-
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
selfUpdateCalls = [];
|
|
120
100
|
});
|
|
121
101
|
|
|
122
|
-
|
|
123
|
-
|
|
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([]);
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
mock.restore();
|
|
131
104
|
});
|
|
132
105
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
loadModules: () => [
|
|
106
|
+
function start(controls: { installed: boolean }) {
|
|
107
|
+
return startHookDispatcher({
|
|
108
|
+
loadModules: () => [],
|
|
109
|
+
controlPlaneInstalled: () => controls.installed,
|
|
110
|
+
selfUpdate: (plan) => {
|
|
111
|
+
selfUpdateCalls.push(plan);
|
|
112
|
+
return {
|
|
113
|
+
packageName: plan.packageName,
|
|
114
|
+
version: plan.version,
|
|
115
|
+
beforeVersion: '0.4.0',
|
|
116
|
+
updated: true,
|
|
117
|
+
verified: true,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
136
120
|
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test('control-plane installed + matching event → update runs with the right plan', async () => {
|
|
124
|
+
const dispatcher = await start({ installed: true });
|
|
137
125
|
const results = await dispatcher.handleEvent(
|
|
138
|
-
buildEvent({ package: { name: '@celilo/
|
|
126
|
+
buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
|
|
139
127
|
);
|
|
140
128
|
await dispatcher.stop();
|
|
141
129
|
expect(results).toEqual([]);
|
|
130
|
+
expect(selfUpdateCalls).toEqual([
|
|
131
|
+
{ packageName: '@celilo/cli', version: '0.5.0', binary: 'celilo' },
|
|
132
|
+
]);
|
|
142
133
|
});
|
|
143
134
|
|
|
144
|
-
test('
|
|
145
|
-
const dispatcher = await
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const results = await dispatcher.handleEvent(
|
|
149
|
-
buildEvent({
|
|
150
|
-
registry: 'celilo-registry',
|
|
151
|
-
package: { name: '@celilo/cli', version: '0.5.0' },
|
|
152
|
-
}),
|
|
135
|
+
test('control-plane NOT installed → no self-update even on a matching event', async () => {
|
|
136
|
+
const dispatcher = await start({ installed: false });
|
|
137
|
+
await dispatcher.handleEvent(
|
|
138
|
+
buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
|
|
153
139
|
);
|
|
154
140
|
await dispatcher.stop();
|
|
155
|
-
expect(
|
|
141
|
+
expect(selfUpdateCalls).toEqual([]);
|
|
156
142
|
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The durable outcome record (celilo#1304): every dispatched run lands in
|
|
161
|
-
* the `build_bus_hook_runs` ledger via the injectable recordRun. Without it
|
|
162
|
-
* a failed self-update existed only as daemon console output, which no
|
|
163
|
-
* surface reads.
|
|
164
|
-
*/
|
|
165
|
-
describe('hook-dispatcher outcome record', () => {
|
|
166
|
-
test('a failing script records exit code, stderr tail, and the event identity', async () => {
|
|
167
|
-
const failDir = mkdtempSync(join(tmpdir(), 'hook-record-test-'));
|
|
168
|
-
const scriptPath = join(failDir, 'fail.sh');
|
|
169
|
-
writeFileSync(scriptPath, '#!/usr/bin/env bash\necho "boom" >&2\nexit 7\n');
|
|
170
|
-
chmodSync(scriptPath, 0o755);
|
|
171
|
-
|
|
172
|
-
const recorded: Array<{
|
|
173
|
-
event: PublishEvent;
|
|
174
|
-
result: { exitCode: number | null; stderr: string };
|
|
175
|
-
}> = [];
|
|
176
|
-
const dispatcher = await startHookDispatcher({
|
|
177
|
-
loadModules: () => [
|
|
178
|
-
{
|
|
179
|
-
moduleId: 'celilo-mgmt',
|
|
180
|
-
sourcePath: failDir,
|
|
181
|
-
hooks: [
|
|
182
|
-
{
|
|
183
|
-
name: 'self-update-celilo-cli',
|
|
184
|
-
match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
|
|
185
|
-
script: 'fail.sh',
|
|
186
|
-
},
|
|
187
|
-
],
|
|
188
|
-
},
|
|
189
|
-
],
|
|
190
|
-
recordRun: (event, result) => recorded.push({ event, result }),
|
|
191
|
-
});
|
|
192
143
|
|
|
144
|
+
test('a non-binary package (e.g. @celilo/event-bus) never self-updates', async () => {
|
|
145
|
+
const dispatcher = await start({ installed: true });
|
|
193
146
|
await dispatcher.handleEvent(
|
|
194
|
-
buildEvent({ package: { name: '@celilo/
|
|
147
|
+
buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } }),
|
|
195
148
|
);
|
|
196
149
|
await dispatcher.stop();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
expect(recorded).toHaveLength(1);
|
|
200
|
-
expect(recorded[0].event.eventId).toBe('evt-1');
|
|
201
|
-
expect(recorded[0].event.package.name).toBe('@celilo/cli');
|
|
202
|
-
expect(recorded[0].result.exitCode).toBe(7);
|
|
203
|
-
expect(recorded[0].result.stderr).toContain('boom');
|
|
150
|
+
expect(selfUpdateCalls).toEqual([]);
|
|
204
151
|
});
|
|
205
152
|
});
|
|
153
|
+
|
|
154
|
+
// The durable outcome record (celilo#1304) is exercised in
|
|
155
|
+
// hook-dispatch-executor.test.ts, against the real ledger and the real
|
|
156
|
+
// jailed executor — the old injectable-capture form here asserted the
|
|
157
|
+
// bash path's exit code and stderr, which slice 7 deleted.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate for module-orchestrator-primitives slice 7
|
|
3
|
+
* (tasks.md 7.1): exactly ONE hook dispatch path.
|
|
4
|
+
*
|
|
5
|
+
* A module-declared hook is anything a manifest declares under `hooks:`.
|
|
6
|
+
* Since slice 7, every such hook — including `on_upstream_publish` — is
|
|
7
|
+
* executed by the ordinary executor (`apps/celilo/src/hooks/executor.ts`
|
|
8
|
+
* → `hook-runner.ts`, jailed). No other celilo service may spawn a
|
|
9
|
+
* process to run one. That second path is how `bash <script>` with the
|
|
10
|
+
* whole of celilo's `process.env` reached a module-supplied script
|
|
11
|
+
* (design D6).
|
|
12
|
+
*
|
|
13
|
+
* The scan asserts on the DISPATCH, not on an interpreter name: it flags
|
|
14
|
+
* any file that (a) reaches a child process AND (b) consumes what a
|
|
15
|
+
* module declares under `hooks:`. Asserting on the literal `bash` would
|
|
16
|
+
* pass the moment someone renamed the interpreter, and would miss a
|
|
17
|
+
* second path built on `sh`, `bun run` or a direct `Bun.spawn`.
|
|
18
|
+
*
|
|
19
|
+
* `apps/celilo/src/hooks/**` is the one sanctioned path and is exempt:
|
|
20
|
+
* `executor.ts` and `hook-runner.ts` are where module-declared hooks are
|
|
21
|
+
* supposed to be spawned, jailed. Every other service file that both
|
|
22
|
+
* spawns and reads hook declarations is a second dispatch path.
|
|
23
|
+
*
|
|
24
|
+
* Seen-fail note (Rule 7.6): this scan was written RED against
|
|
25
|
+
* `hook-dispatch.ts` at the spawn it then contained, and re-proven RED in
|
|
26
|
+
* task 7.5 by restoring that spawn in a scratch copy of the tree.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, expect, test } from 'bun:test';
|
|
30
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
const SRC_ROOT = join(__dirname, '..', '..');
|
|
34
|
+
|
|
35
|
+
/** Directories that ARE the one hook dispatch path. Spawning there is the design. */
|
|
36
|
+
const EXEMPT_DIRS = new Set(['hooks']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* (a) The file reaches a child process. Covers `node:child_process` in
|
|
40
|
+
* every spelling plus Bun's own spawner.
|
|
41
|
+
*/
|
|
42
|
+
const CHILD_PROCESS_REACH = /node:child_process|Bun\.spawn/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* (b) The file consumes a hook's declared SCRIPT for dispatch — the input
|
|
46
|
+
* side of a hook execution. Deliberately narrow: a service that merely
|
|
47
|
+
* PEEKS at the manifest hooks block to gate a framework call (e.g.
|
|
48
|
+
* `system update` reading `hooks?.on_backup` to skip a backup) reads hook
|
|
49
|
+
* declarations without dispatching one, and stays out of scope. Comment
|
|
50
|
+
* mentions are stripped before matching: a docblock naming the hook this
|
|
51
|
+
* gate replaced is prose, not a dispatch.
|
|
52
|
+
*/
|
|
53
|
+
const HOOK_DISPATCH_MARKERS = ['UpstreamHookEntry', 'planHookDispatch', 'hook.script'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Strip line and block comments so a prose mention of a marker does not
|
|
57
|
+
* flag a file. Conservative on purpose: strings containing `//` (URLs) can
|
|
58
|
+
* lose a tail, which is safe here — it can only REMOVE a match, and a
|
|
59
|
+
* string carrying a hook-dispatch marker is not itself a dispatch.
|
|
60
|
+
*/
|
|
61
|
+
function stripComments(src: string): string {
|
|
62
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/^\s*\/\/.*$/gm, ' ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function listTsFiles(dir: string, out: string[] = []): string[] {
|
|
66
|
+
for (const entry of readdirSync(dir)) {
|
|
67
|
+
const full = join(dir, entry);
|
|
68
|
+
if (statSync(full).isDirectory()) {
|
|
69
|
+
if (EXEMPT_DIRS.has(entry)) continue;
|
|
70
|
+
listTsFiles(full, out);
|
|
71
|
+
} else if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) {
|
|
72
|
+
out.push(full);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('exactly one hook dispatch path (module-orchestrator-primitives 7.1)', () => {
|
|
79
|
+
test('no service outside apps/celilo/src/hooks spawns a process for a module-declared hook', () => {
|
|
80
|
+
const violations: string[] = [];
|
|
81
|
+
for (const file of listTsFiles(SRC_ROOT)) {
|
|
82
|
+
const src = stripComments(readFileSync(file, 'utf-8'));
|
|
83
|
+
if (!CHILD_PROCESS_REACH.test(src)) continue;
|
|
84
|
+
if (!HOOK_DISPATCH_MARKERS.some((m) => src.includes(m))) continue;
|
|
85
|
+
violations.push(file.replace(`${SRC_ROOT}/`, ''));
|
|
86
|
+
}
|
|
87
|
+
expect(violations).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the one sanctioned path still exists (executor.ts)', () => {
|
|
91
|
+
const executor = readFileSync(join(SRC_ROOT, 'hooks', 'executor.ts'), 'utf-8');
|
|
92
|
+
expect(executor).toContain('export async function invokeHook');
|
|
93
|
+
expect(executor).toContain('export async function executeHookScript');
|
|
94
|
+
});
|
|
95
|
+
});
|