@celilo/cli 1.8.0 → 1.9.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.
Files changed (58) hide show
  1. package/CELILO_CORE_MODULES.md +2 -0
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/drizzle/0028_capability_bindings.sql +26 -0
  4. package/drizzle/0029_module_instances.sql +58 -0
  5. package/drizzle/meta/_journal.json +14 -0
  6. package/package.json +2 -2
  7. package/src/cli/commands/module-show.ts +1 -0
  8. package/src/db/foreign-keys.test.ts +101 -0
  9. package/src/db/schema.ts +161 -5
  10. package/src/hooks/broker.test.ts +152 -0
  11. package/src/hooks/broker.ts +307 -0
  12. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  13. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  14. package/src/hooks/capability-loader.test.ts +10 -2
  15. package/src/hooks/capability-loader.ts +59 -2
  16. package/src/hooks/executor.ts +234 -111
  17. package/src/hooks/hook-protocol.test.ts +192 -0
  18. package/src/hooks/hook-protocol.ts +275 -0
  19. package/src/hooks/hook-runner.ts +231 -0
  20. package/src/hooks/hook-timeout.test.ts +103 -0
  21. package/src/hooks/hook-trespass.test.ts +201 -0
  22. package/src/hooks/injected-capabilities.test.ts +75 -0
  23. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  24. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  25. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  26. package/src/manifest/validate-provider-views.test.ts +61 -0
  27. package/src/manifest/validate.ts +21 -14
  28. package/src/module/packaging/module-state-directory.test.ts +99 -0
  29. package/src/module/packaging/package-rules.ts +10 -2
  30. package/src/policy/capability-shape-baseline.ts +8 -0
  31. package/src/policy/capability-shape.ts +13 -1
  32. package/src/policy/module-business-baseline.ts +36 -0
  33. package/src/services/alerting/ack.test.ts +2 -2
  34. package/src/services/alerting/deferral.test.ts +2 -2
  35. package/src/services/alerting/delivery-loop.test.ts +2 -2
  36. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  37. package/src/services/alerting/inbound-poller.test.ts +2 -2
  38. package/src/services/alerting/inbound.test.ts +2 -2
  39. package/src/services/alerting/notification-responder.test.ts +2 -2
  40. package/src/services/alerting/run-monitor.test.ts +2 -2
  41. package/src/services/alerting/store.test.ts +2 -2
  42. package/src/services/alerting/sweep-runner.test.ts +2 -2
  43. package/src/services/alerting/tokens.test.ts +2 -2
  44. package/src/services/capability-bindings.test.ts +104 -0
  45. package/src/services/capability-bindings.ts +107 -0
  46. package/src/services/capability-table-rows.test.ts +2 -2
  47. package/src/services/consumer-cleanup.test.ts +40 -3
  48. package/src/services/dns-internal-records.test.ts +3 -3
  49. package/src/services/fleet-checks.test.ts +4 -4
  50. package/src/services/module-instances.test.ts +198 -0
  51. package/src/services/module-instances.ts +96 -0
  52. package/src/services/module-journal.test.ts +2 -2
  53. package/src/services/module-subscriptions.test.ts +1 -1
  54. package/src/services/port-forwards.test.ts +2 -2
  55. package/src/services/trusted-sources.test.ts +3 -3
  56. package/src/templates/ingress-ip.test.ts +31 -0
  57. package/src/test-utils/database.ts +31 -1
  58. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The broker: the capability surface crossing a process boundary.
3
+ *
4
+ * The claim under test is design D2's — that one generic proxy covers all
5
+ * twelve capabilities because every hook-facing method is already
6
+ * `(request: JSON) => Promise<JSON>`. So these tests fix the SHAPES a call can
7
+ * take (returns, throws, a structured throw the framework reads, an absent
8
+ * optional method, an unknown method) and say nothing about any particular
9
+ * capability. A per-method suite would prove the same thing thirty-seven times
10
+ * and go stale the moment a provider gained a method.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+ import { execSync } from 'node:child_process';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { MissingProviderInputError } from '@celilo/capabilities';
19
+ import { capabilityShape } from './broker';
20
+ import { executeHookScript } from './executor';
21
+ import { createCapturingLogger } from './logger';
22
+ import type { HookContext } from './types';
23
+
24
+ const FIXTURES = join(__dirname, 'test-fixtures');
25
+
26
+ function demoCapabilities(): Record<string, unknown> {
27
+ return {
28
+ demo: {
29
+ providerModuleId: 'demo-provider',
30
+ version: '1.0.0',
31
+ echo: async (request: unknown) => ({ echoed: request }),
32
+ returnsNothing: async () => undefined,
33
+ boom: async () => {
34
+ throw new Error('plain failure');
35
+ },
36
+ missingInput: async () => {
37
+ throw new MissingProviderInputError({
38
+ providerModuleId: 'caddy',
39
+ ensureId: 'hostnames',
40
+ value: 'foo.example.com',
41
+ humanContext: 'so the route resolves',
42
+ });
43
+ },
44
+ },
45
+ };
46
+ }
47
+
48
+ async function runCapabilityHook(): Promise<Record<string, unknown>> {
49
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-'));
50
+ try {
51
+ const context: HookContext = {
52
+ config: {},
53
+ secrets: {},
54
+ systems: [],
55
+ logger: createCapturingLogger().logger,
56
+ debug: false,
57
+ screenshotDir: dir,
58
+ capabilities: demoCapabilities(),
59
+ };
60
+ return await executeHookScript(
61
+ join(FIXTURES, 'capability-calling-hook.ts'),
62
+ context,
63
+ 30_000,
64
+ 30_000,
65
+ );
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ describe('capabilityShape', () => {
72
+ test('splits functions from data', () => {
73
+ const shape = capabilityShape(demoCapabilities());
74
+ expect(shape.demo.methods.sort()).toEqual(['boom', 'echo', 'missingInput', 'returnsNothing']);
75
+ expect(shape.demo.data).toEqual({ providerModuleId: 'demo-provider', version: '1.0.0' });
76
+ });
77
+
78
+ test('an unimplemented optional method is simply absent', () => {
79
+ // Not "present and throwing". `if (cap.registerTrustedSource)` is real
80
+ // code in the wireguard module and it has to keep answering correctly.
81
+ expect(capabilityShape(demoCapabilities()).demo.methods).not.toContain('sometimesAbsent');
82
+ });
83
+
84
+ test('symbol keys are dropped — they cannot cross JSON', () => {
85
+ const brand = Symbol('brand');
86
+ const shape = capabilityShape({ demo: { [brand]: 'x', ok: async () => 1 } });
87
+ expect(shape.demo.methods).toEqual(['ok']);
88
+ expect(shape.demo.data).toEqual({});
89
+ });
90
+
91
+ test('a value JSON cannot carry is left out of data rather than corrupted', () => {
92
+ const shape = capabilityShape({
93
+ demo: { nan: Number.NaN, inf: Number.POSITIVE_INFINITY, n: 1 },
94
+ });
95
+ expect(shape.demo.data).toEqual({ n: 1 });
96
+ });
97
+
98
+ test('a non-object capability entry is skipped, not crashed on', () => {
99
+ expect(capabilityShape({ broken: null, alsoBroken: 'string' })).toEqual({});
100
+ });
101
+ });
102
+
103
+ describe('capability calls across the boundary', () => {
104
+ test('every call shape survives the round trip', async () => {
105
+ const outputs = await runCapabilityHook();
106
+
107
+ expect(outputs.providerModuleId).toBe('demo-provider');
108
+ expect(outputs.version).toBe('1.0.0');
109
+ expect(outputs.optionalMethodAbsent).toBe(true);
110
+ expect(outputs.returned).toEqual({ echoed: { x: 1, nested: { y: [2, 3] } } });
111
+ expect(outputs.undefinedBecomesNull).toBeNull();
112
+
113
+ expect(outputs.plainThrow).toEqual({
114
+ isError: true,
115
+ name: 'Error',
116
+ message: 'plain failure',
117
+ hasStack: true,
118
+ });
119
+
120
+ // The one error the framework READS rather than displays. Lose these four
121
+ // fields and the cross-module ensure interview never runs — the deploy
122
+ // fails with a message where it should have asked a question.
123
+ expect(outputs.missingProviderInput).toEqual({
124
+ recognised: true,
125
+ providerModuleId: 'caddy',
126
+ ensureId: 'hostnames',
127
+ value: 'foo.example.com',
128
+ humanContext: 'so the route resolves',
129
+ });
130
+
131
+ // A method that is not in the shape is not on the proxy, so this fails on
132
+ // this side and never reaches the broker — the same TypeError a hook gets
133
+ // in-process today. The broker's own "no such method" guard is for a
134
+ // shape/map disagreement, which is a skew bug and not this path.
135
+ expect(outputs.unknownMethod).toContain('not a function');
136
+ }, 30_000);
137
+
138
+ test('the hook ran in a process of its own and left none behind', async () => {
139
+ await runCapabilityHook();
140
+
141
+ // By parent pid, not by name: the hook runner is a direct child of this
142
+ // process, and matching on the script name instead picks up whatever shell
143
+ // happens to have the filename in its own command line.
144
+ const orphans = execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' })
145
+ .split('\n')
146
+ .filter(
147
+ (line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'),
148
+ );
149
+
150
+ expect(orphans).toEqual([]);
151
+ }, 30_000);
152
+ });
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The hook broker.
3
+ *
4
+ * celilo keeps the database, the master key and the live capability objects.
5
+ * The hook runs in its own process and reaches them only by asking. This is
6
+ * the answering half; `hook-runner.ts` is the asking half.
7
+ *
8
+ * **It does not know what a capability is** (design D2). At handshake it sends
9
+ * a shape descriptor built by walking the object `loadCapabilityFunctions`
10
+ * already returns, and thereafter it dispatches `call` frames against that
11
+ * same object by name. That works because every hook-facing capability method
12
+ * is already `(request: JSON) => Promise<JSON>` — measured: twelve
13
+ * capabilities, thirty-seven methods, no callbacks, no streams, no handles —
14
+ * and because `wrapWithLogging` already treats a capability as an opaque table
15
+ * of async methods keyed by name. This is that walk with a process in the
16
+ * middle.
17
+ *
18
+ * Execution function (Rule 10.1) — owns the socket and performs the calls.
19
+ */
20
+
21
+ import { mkdtempSync, rmSync } from 'node:fs';
22
+ import { type Server, type Socket, createServer } from 'node:net';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import {
26
+ type CapabilityShape,
27
+ type HookError,
28
+ createLineReader,
29
+ encodeFrame,
30
+ parseChildFrame,
31
+ serializeError,
32
+ versionMismatch,
33
+ } from './hook-protocol';
34
+ import type { HookLogger } from './types';
35
+
36
+ /**
37
+ * How long to wait after the child exits for its last frame to arrive. Short,
38
+ * because by this point the writer is already gone and the bytes are either in
39
+ * the buffer or they are never coming.
40
+ */
41
+ const DRAIN_GRACE_MS = 1_000;
42
+
43
+ /** What the hook returned, or what it threw. */
44
+ export type HookOutcome =
45
+ | { ok: true; outputs: Record<string, unknown> }
46
+ | { ok: false; error: HookError };
47
+
48
+ export interface BrokerOptions {
49
+ /** The live capability map, exactly as `loadCapabilityFunctions` built it. */
50
+ capabilities: Record<string, unknown>;
51
+ /** The serialisable half of `HookContext` — no `logger`, no `capabilities`. */
52
+ context: Record<string, unknown>;
53
+ /** Absolute path of the hook script, sent to the child rather than argv'd. */
54
+ scriptPath: string;
55
+ /** Where the hook's own log lines go. */
56
+ logger: HookLogger;
57
+ /** Called on every frame, so the idle timer measures the whole channel. */
58
+ onActivity: () => void;
59
+ }
60
+
61
+ export interface Broker {
62
+ /** Unix socket path, handed to the child in its environment. */
63
+ socketPath: string;
64
+ /**
65
+ * Resolves once the child's connection has closed, so its last frame has
66
+ * certainly been delivered.
67
+ *
68
+ * The child writes its terminal frame and then exits, and process exit is
69
+ * what the executor waits on — but the two are different channels. The exit
70
+ * can be observed while the frame is still sitting in the kernel buffer,
71
+ * unread, which would look exactly like a hook that exited without
72
+ * returning a result. Bounded, so a socket that never closes cannot hang
73
+ * celilo waiting for a process that has already gone.
74
+ */
75
+ drained(): Promise<void>;
76
+ /**
77
+ * The hook's terminal frame, or `undefined` if it never sent one — which is
78
+ * what a crash, a `process.exit` or a kill looks like from here. Read after
79
+ * the child has exited, so there is no race to lose.
80
+ */
81
+ outcome(): HookOutcome | undefined;
82
+ /** Every protocol-level complaint, for the error message when one matters. */
83
+ faults(): string[];
84
+ /** Refuse further calls. Idempotent. Called on kill and again on cleanup. */
85
+ stop(): void;
86
+ /** Close the socket and remove its directory. */
87
+ close(): void;
88
+ }
89
+
90
+ /**
91
+ * Walk a capability map into the descriptor the child rebuilds proxies from.
92
+ *
93
+ * Function-valued keys become `methods`; everything else is copied into `data`,
94
+ * which is where `stampProvider` puts `providerModuleId` — a hook reads it to
95
+ * name the provider that could not supply what it asked for.
96
+ *
97
+ * Own string keys only, the way `wrapWithLogging` walks. Every capability the
98
+ * loader builds is a plain object (a spread, an `Object.assign`, or
99
+ * `Object.create` plus own properties), so a prototype walk would find nothing
100
+ * a hook can call today — and if that ever stopped being true, auto-logging
101
+ * would break in the same breath, which is a louder signal than this would be.
102
+ * Symbol keys cannot cross JSON and are dropped.
103
+ *
104
+ * Planning function (Rule 10.4) — pure, so the descriptor is testable without
105
+ * a socket.
106
+ */
107
+ export function capabilityShape(
108
+ capabilities: Record<string, unknown>,
109
+ ): Record<string, CapabilityShape> {
110
+ const shape: Record<string, CapabilityShape> = {};
111
+
112
+ for (const [name, capability] of Object.entries(capabilities)) {
113
+ if (!capability || typeof capability !== 'object') continue;
114
+
115
+ const methods: string[] = [];
116
+ const data: Record<string, unknown> = {};
117
+
118
+ for (const key of Reflect.ownKeys(capability)) {
119
+ if (typeof key !== 'string') continue;
120
+ const value = (capability as Record<string, unknown>)[key];
121
+ if (typeof value === 'function') {
122
+ methods.push(key);
123
+ } else if (isJsonSafe(value)) {
124
+ data[key] = value;
125
+ }
126
+ }
127
+
128
+ shape[name] = { methods, data };
129
+ }
130
+
131
+ return shape;
132
+ }
133
+
134
+ /** Everything `JSON.stringify` round-trips without inventing or losing a value. */
135
+ function isJsonSafe(value: unknown): boolean {
136
+ if (value === null) return true;
137
+ const type = typeof value;
138
+ if (type === 'string' || type === 'boolean') return true;
139
+ if (type === 'number') return Number.isFinite(value as number);
140
+ if (type !== 'object') return false;
141
+ try {
142
+ return JSON.parse(JSON.stringify(value)) !== undefined;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Bind a Unix socket in a fresh per-run directory and answer one hook.
150
+ *
151
+ * The socket, not stdout: seventeen module script files spawn subprocesses,
152
+ * and a grandchild writing to fd 1 would corrupt the frame stream (design D3).
153
+ * The directory is short-named because `sun_path` is 104 bytes on macOS and
154
+ * the platform temp directory already spends half of it.
155
+ */
156
+ export async function startBroker(options: BrokerOptions): Promise<Broker> {
157
+ const directory = mkdtempSync(join(tmpdir(), 'celilo-hook-'));
158
+ const socketPath = join(directory, 's');
159
+
160
+ let outcome: HookOutcome | undefined;
161
+ let stopped = false;
162
+ const faults: string[] = [];
163
+ let connection: Socket | undefined;
164
+ // Already resolved: no connection means nothing left to deliver.
165
+ let connectionClosed: Promise<void> = Promise.resolve();
166
+
167
+ const server: Server = createServer((socket) => {
168
+ if (connection) {
169
+ // One hook, one connection. A second is either a bug or a hook trying
170
+ // to hold the channel open past its own run.
171
+ faults.push('a second connection to the hook socket was refused');
172
+ socket.destroy();
173
+ return;
174
+ }
175
+ connection = socket;
176
+ connectionClosed = new Promise<void>((resolve) => socket.once('close', () => resolve()));
177
+ socket.setEncoding('utf-8');
178
+
179
+ const send = (frame: Parameters<typeof encodeFrame>[0]) => {
180
+ if (!socket.destroyed) socket.write(encodeFrame(frame));
181
+ };
182
+
183
+ const feed = createLineReader((line) => {
184
+ options.onActivity();
185
+
186
+ const parsed = parseChildFrame(line);
187
+ if (!parsed.ok) {
188
+ faults.push(`malformed frame from the hook: ${parsed.error}`);
189
+ return;
190
+ }
191
+
192
+ const frame = parsed.frame;
193
+ switch (frame.type) {
194
+ case 'ready': {
195
+ const mismatch = versionMismatch(frame.protocolVersion, 'the hook runner');
196
+ if (mismatch) {
197
+ faults.push(mismatch);
198
+ socket.destroy();
199
+ return;
200
+ }
201
+ send({
202
+ type: 'context',
203
+ protocolVersion: 1,
204
+ scriptPath: options.scriptPath,
205
+ context: options.context,
206
+ });
207
+ send({ type: 'capabilities', shape: capabilityShape(options.capabilities) });
208
+ return;
209
+ }
210
+
211
+ case 'log':
212
+ options.logger[frame.level](frame.message);
213
+ return;
214
+
215
+ case 'call':
216
+ void dispatch(frame.id, frame.capability, frame.method, frame.args, send);
217
+ return;
218
+
219
+ case 'result':
220
+ outcome = { ok: true, outputs: frame.outputs };
221
+ return;
222
+
223
+ case 'throw':
224
+ outcome = { ok: false, error: frame.error };
225
+ return;
226
+ }
227
+ });
228
+
229
+ socket.on('data', feed);
230
+ socket.on('error', (error) => faults.push(`hook socket error: ${error.message}`));
231
+ });
232
+
233
+ async function dispatch(
234
+ id: string,
235
+ capabilityName: string,
236
+ method: string,
237
+ args: unknown[],
238
+ send: (frame: Parameters<typeof encodeFrame>[0]) => void,
239
+ ): Promise<void> {
240
+ if (stopped) {
241
+ // The run is over — killed at its timeout, most likely. Refusing here is
242
+ // what makes the kill real: the whole failure celilo#1003 describes is a
243
+ // hook still registering DNS records after celilo moved on.
244
+ send({
245
+ type: 'throw',
246
+ id,
247
+ error: {
248
+ name: 'Error',
249
+ message: `Hook run has ended; refusing ${capabilityName}.${method}.`,
250
+ },
251
+ });
252
+ return;
253
+ }
254
+
255
+ const capability = options.capabilities[capabilityName];
256
+ const fn =
257
+ capability && typeof capability === 'object'
258
+ ? (capability as Record<string, unknown>)[method]
259
+ : undefined;
260
+
261
+ if (typeof fn !== 'function') {
262
+ send({
263
+ type: 'throw',
264
+ id,
265
+ error: {
266
+ name: 'TypeError',
267
+ message: `Capability '${capabilityName}' has no method '${method}'.`,
268
+ },
269
+ });
270
+ return;
271
+ }
272
+
273
+ try {
274
+ const value = await (fn as (...a: unknown[]) => unknown).apply(capability, args);
275
+ options.onActivity();
276
+ send({ type: 'return', id, value: value === undefined ? null : value });
277
+ } catch (error) {
278
+ options.onActivity();
279
+ send({ type: 'throw', id, error: serializeError(error) });
280
+ }
281
+ }
282
+
283
+ await new Promise<void>((resolve, reject) => {
284
+ server.once('error', reject);
285
+ server.listen(socketPath, resolve);
286
+ });
287
+
288
+ return {
289
+ socketPath,
290
+ drained: () =>
291
+ Promise.race([
292
+ connectionClosed,
293
+ new Promise<void>((resolve) => setTimeout(resolve, DRAIN_GRACE_MS).unref()),
294
+ ]),
295
+ outcome: () => outcome,
296
+ faults: () => [...faults],
297
+ stop: () => {
298
+ stopped = true;
299
+ },
300
+ close: () => {
301
+ stopped = true;
302
+ connection?.destroy();
303
+ server.close();
304
+ rmSync(directory, { recursive: true, force: true });
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The recurrence gate for celilo#1072.
3
+ *
4
+ * celilo could name every provider a module MIGHT be bound to and none of the
5
+ * ones it IS. The live case: `tango-nexus` declares four optional capabilities
6
+ * (external_web, public_web, source_forge, registry_publish), is deployed
7
+ * against one off-fleet host, and nothing recorded which of the four was real.
8
+ *
9
+ * This models that shape with two capabilities the loader injects and one the
10
+ * consumer calls. The gate is the NEGATIVE half: the capability that was
11
+ * injected and never used must report zero bindings. Anything that records at
12
+ * resolution time — which is where the obvious fix goes — passes the positive
13
+ * assertion and fails this one.
14
+ *
15
+ * Two different flavours of over-recording are caught here, and the second is
16
+ * easy to miss when reading this file as being about one thing. The first is a
17
+ * consumer credited with a capability it never called. The second is the
18
+ * self-binding case: `dhcp-provider` running its OWN hook is also handed
19
+ * `notification` from `notify-provider`, because the loader hands over
20
+ * everything registered. Resolution-time recording books that as a binding
21
+ * `dhcp-provider` never made, so the "not bound to itself" test fails for a
22
+ * reason that has nothing to do with self-binding.
23
+ */
24
+
25
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
26
+ import { mkdirSync, writeFileSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import type { HookLogger } from '@celilo/capabilities';
30
+ import type { DbClient } from '../db/client';
31
+ import { listCapabilityBindings } from '../services/capability-bindings';
32
+ import { upsertModuleConfig } from '../services/module-config';
33
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
34
+ import { loadCapabilityFunctions } from './capability-loader';
35
+
36
+ const noopLogger: HookLogger = {
37
+ info() {},
38
+ warn() {},
39
+ error() {},
40
+ success() {},
41
+ };
42
+
43
+ const DHCP_MODULE = `
44
+ export default function createDhcpServer(context) {
45
+ return {
46
+ async setDnsServers() {},
47
+ async getDnsServers() { return [context.config.marker]; },
48
+ async setDomainName() {},
49
+ async getDomainName() { return context.config.marker; },
50
+ };
51
+ }
52
+ `;
53
+
54
+ const NOTIFICATION_MODULE = `
55
+ export default function createNotification() {
56
+ return {
57
+ async send() { return { delivered: true }; },
58
+ };
59
+ }
60
+ `;
61
+
62
+ function installProvider(
63
+ db: DbClient,
64
+ tempDir: string,
65
+ moduleId: string,
66
+ script: string,
67
+ source: string,
68
+ capabilityName: string,
69
+ ): void {
70
+ const modulePath = join(tempDir, moduleId);
71
+ const scriptsDir = join(modulePath, 'scripts');
72
+ mkdirSync(scriptsDir, { recursive: true });
73
+ writeFileSync(join(scriptsDir, script), source);
74
+ db.$client.run(
75
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${modulePath}', '{}')`,
76
+ );
77
+ upsertModuleConfig(db, moduleId, 'marker', moduleId);
78
+ db.$client.run(
79
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('${moduleId}', '${capabilityName}', '1.0.0', '{}', NULL, unixepoch())`,
80
+ );
81
+ }
82
+
83
+ describe('capability bindings recorded from the loader', () => {
84
+ let db: DbClient;
85
+ let tempDir: string;
86
+
87
+ beforeEach(async () => {
88
+ db = await setupTestDatabase();
89
+ // `setupTestDatabase` does not run `PRAGMA foreign_keys = ON` and
90
+ // `db/client.ts:70` does, so every cascade in the schema is enforced in
91
+ // production and off in the suite (celilo#1074). Enabled here so the
92
+ // cascade assertion below measures the real behaviour. Delete this line
93
+ // when #1074 lands.
94
+ db.$client.run('PRAGMA foreign_keys = ON');
95
+ tempDir = join(tmpdir(), `celilo-binding-${Date.now()}-${Math.random().toString(36).slice(2)}`);
96
+ mkdirSync(tempDir, { recursive: true });
97
+
98
+ installProvider(
99
+ db,
100
+ tempDir,
101
+ 'dhcp-provider',
102
+ 'dhcp-server-functions.ts',
103
+ DHCP_MODULE,
104
+ 'dhcp_server',
105
+ );
106
+ installProvider(
107
+ db,
108
+ tempDir,
109
+ 'notify-provider',
110
+ 'notification.ts',
111
+ NOTIFICATION_MODULE,
112
+ 'notification',
113
+ );
114
+ db.$client.run(
115
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('consumer', 'consumer', '1.0.0', '${tempDir}', '{}')`,
116
+ );
117
+ });
118
+
119
+ afterEach(async () => {
120
+ await cleanupTestDatabase(db);
121
+ });
122
+
123
+ test('an injected capability the consumer never calls reports zero bindings', async () => {
124
+ const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger);
125
+
126
+ // Both are injected — the loader hands over every registered capability
127
+ // regardless of what the consumer declared.
128
+ expect(capabilities.dhcp_server).toBeTruthy();
129
+ expect(capabilities.notification).toBeTruthy();
130
+
131
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
132
+
133
+ const bindings = listCapabilityBindings(db, 'consumer');
134
+ expect(bindings.map((b) => b.capabilityName)).toEqual(['dhcp_server']);
135
+ expect(bindings[0].providerModuleId).toBe('dhcp-provider');
136
+ });
137
+
138
+ test('a redeploy re-asserts the binding rather than duplicating it', async () => {
139
+ for (let i = 0; i < 3; i++) {
140
+ const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger);
141
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
142
+ }
143
+
144
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1);
145
+ });
146
+
147
+ test('the binding dies with the consumer', async () => {
148
+ const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger);
149
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
150
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1);
151
+
152
+ db.$client.run(`DELETE FROM modules WHERE id = 'consumer'`);
153
+
154
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(0);
155
+ });
156
+
157
+ test('a provider consuming its own capability is not bound to itself', async () => {
158
+ const capabilities = await loadCapabilityFunctions('dhcp-provider', db, noopLogger);
159
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
160
+
161
+ expect(listCapabilityBindings(db, 'dhcp-provider')).toHaveLength(0);
162
+ });
163
+ });