@celilo/cli 1.8.0 → 1.9.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.
Files changed (66) 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 +153 -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/define-hook.test.ts +1 -0
  17. package/src/hooks/executor.test.ts +7 -0
  18. package/src/hooks/executor.ts +245 -111
  19. package/src/hooks/hook-protocol.test.ts +192 -0
  20. package/src/hooks/hook-protocol.ts +275 -0
  21. package/src/hooks/hook-runner.ts +231 -0
  22. package/src/hooks/hook-state-dir.test.ts +109 -0
  23. package/src/hooks/hook-timeout.test.ts +104 -0
  24. package/src/hooks/hook-trespass.test.ts +202 -0
  25. package/src/hooks/injected-capabilities.test.ts +75 -0
  26. package/src/hooks/mount-set.test.ts +148 -0
  27. package/src/hooks/mount-set.ts +234 -0
  28. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  29. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  30. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  31. package/src/manifest/contracts/v1.ts +21 -6
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/packaging/module-state-directory.test.ts +105 -0
  35. package/src/module/packaging/package-rules.ts +10 -2
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/capability-shape.ts +13 -1
  38. package/src/policy/module-business-baseline.ts +36 -0
  39. package/src/policy/module-dep-reachability.test.ts +167 -0
  40. package/src/services/alerting/ack.test.ts +2 -2
  41. package/src/services/alerting/deferral.test.ts +2 -2
  42. package/src/services/alerting/delivery-loop.test.ts +2 -2
  43. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  44. package/src/services/alerting/inbound-poller.test.ts +2 -2
  45. package/src/services/alerting/inbound.test.ts +2 -2
  46. package/src/services/alerting/notification-responder.test.ts +2 -2
  47. package/src/services/alerting/run-monitor.test.ts +2 -2
  48. package/src/services/alerting/store.test.ts +2 -2
  49. package/src/services/alerting/sweep-runner.test.ts +2 -2
  50. package/src/services/alerting/tokens.test.ts +2 -2
  51. package/src/services/capability-bindings.test.ts +104 -0
  52. package/src/services/capability-bindings.ts +107 -0
  53. package/src/services/capability-table-rows.test.ts +2 -2
  54. package/src/services/consumer-cleanup.test.ts +40 -3
  55. package/src/services/dns-internal-records.test.ts +3 -3
  56. package/src/services/fleet-checks.test.ts +4 -4
  57. package/src/services/module-instances.test.ts +198 -0
  58. package/src/services/module-instances.ts +96 -0
  59. package/src/services/module-journal.test.ts +2 -2
  60. package/src/services/module-subscriptions.test.ts +1 -1
  61. package/src/services/port-forwards.test.ts +2 -2
  62. package/src/services/trusted-sources.test.ts +3 -3
  63. package/src/templates/ingress-ip.test.ts +31 -0
  64. package/src/test-utils/database.ts +31 -1
  65. package/src/test-utils/module-fixtures.ts +147 -35
  66. package/src/test-utils/setup-test-db.ts +0 -80
@@ -78,6 +78,28 @@ export function createFirewall(config, store, upstreamFirewall, logger) {
78
78
  }
79
79
  `;
80
80
 
81
+ /**
82
+ * Reports back the FirewallConfig it was handed, so a test can assert on what
83
+ * the loader actually forwarded rather than on a downstream effect.
84
+ *
85
+ * The real iptables module reads `defaultRouteZone` and `isolateTransitNetwork`
86
+ * off this object and nothing else in celilo constructs one. So if the loader
87
+ * drops a field, the setting is simply dead: the operator sets it, the config
88
+ * row exists, `module config get` shows it, and no rule changes. That is what
89
+ * happened to both of these, and it is invisible from every surface except the
90
+ * rendered ruleset.
91
+ */
92
+ const MOCK_CONFIG_REPORTER = `
93
+ export function createFirewall(config, store, upstreamFirewall, logger) {
94
+ return {
95
+ receivedConfig: () => config,
96
+ exposeService: async (opts) => ({ externalIp: '203.0.113.10', natIp: config.natIp }),
97
+ unexposeService: async () => {},
98
+ listExposedServices: async () => [],
99
+ };
100
+ }
101
+ `;
102
+
81
103
  describe('Firewall Chain Building', () => {
82
104
  let db: DbClient;
83
105
  let tempDir: string;
@@ -235,4 +257,90 @@ describe('Firewall Chain Building', () => {
235
257
  // But buildFirewallChain is only called with >1 providers
236
258
  // Single provider loads normally via the standard path
237
259
  });
260
+
261
+ describe('operator settings reach the module', () => {
262
+ /**
263
+ * Both settings are opt-in and both default to a no-op, which is exactly
264
+ * why losing them is silent. A dropped `isolateTransitNetwork` renders the
265
+ * ruleset celilo has always rendered, and a dropped `defaultRouteZone`
266
+ * falls back to `internal`, which is the common case. Nothing errors,
267
+ * nothing warns, and the only observable difference is a DROP that is
268
+ * absent from a chain nobody reads.
269
+ */
270
+ function installReporter(moduleId: string, capabilityData: string, zones: string) {
271
+ const path = join(tempDir, moduleId);
272
+ const scripts = join(path, 'scripts');
273
+ mkdirSync(scripts, { recursive: true });
274
+ writeFileSync(join(scripts, 'firewall-functions.ts'), MOCK_CONFIG_REPORTER);
275
+ db.$client.run(
276
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${path}', '{}')`,
277
+ );
278
+ db.$client.run(
279
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('${moduleId}', 'firewall', '1.0.0', '${capabilityData}', '${zones}')`,
280
+ );
281
+ }
282
+
283
+ async function loadedConfig(): Promise<Record<string, unknown>> {
284
+ const result = await loadCapabilityFunctions('test-consumer', db, noopLogger);
285
+ const fw = result.firewall as { receivedConfig: () => Record<string, unknown> };
286
+ expect(fw, 'no firewall capability loaded').toBeTruthy();
287
+ return fw.receivedConfig();
288
+ }
289
+
290
+ test('single provider: default_route_zone and isolate_transit_network are forwarded', async () => {
291
+ installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]');
292
+ upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
293
+ upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
294
+ upsertModuleConfig(db, 'iptables', 'default_route_zone', 'isp-transit');
295
+ upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
296
+
297
+ const config = await loadedConfig();
298
+ expect(config.defaultRouteZone).toBe('isp-transit');
299
+ // A REAL boolean, not the string 'true'. `parseStoredConfigValue`
300
+ // preserves the manifest-declared type, and the module tests
301
+ // `state.isolateTransitNetwork && ...` — where 'false' would be truthy.
302
+ expect(config.isolateTransitNetwork).toBe(true);
303
+ });
304
+
305
+ test('chained provider: the downstream layer gets them too', async () => {
306
+ // greenwave owns the WAN, so iptables is built through buildFirewallChain
307
+ // rather than the single-provider path. That is a SECOND construction
308
+ // site with its own field list, and it is the one a real downstream
309
+ // firewall goes through.
310
+ const gwPath = join(tempDir, 'greenwave');
311
+ const gwScripts = join(gwPath, 'scripts');
312
+ mkdirSync(gwScripts, { recursive: true });
313
+ writeFileSync(join(gwScripts, 'firewall-functions.ts'), MOCK_GREENWAVE_MODULE);
314
+ db.$client.run(
315
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('greenwave', 'GreenWave', '1.0.0', '${gwPath}', '{}')`,
316
+ );
317
+ db.$client.run(
318
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`,
319
+ );
320
+ upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1');
321
+
322
+ installReporter('iptables', '{}', '["dmz","app","secure"]');
323
+ upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
324
+ upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
325
+ upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
326
+ upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
327
+
328
+ const config = await loadedConfig();
329
+ expect(config.defaultRouteZone).toBe('internal');
330
+ expect(config.isolateTransitNetwork).toBe(true);
331
+ });
332
+
333
+ test('unset settings arrive undefined, not as a wrong default', async () => {
334
+ installReporter('iptables', '{"has_external":true}', '["dmz"]');
335
+ upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
336
+ upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
337
+
338
+ const config = await loadedConfig();
339
+ // The module owns both defaults (`?? 'internal'` and `?? false`). The
340
+ // loader must not invent one, or a future change to the module's default
341
+ // would be silently overridden by a stale copy here.
342
+ expect(config.defaultRouteZone).toBeUndefined();
343
+ expect(config.isolateTransitNetwork).toBeUndefined();
344
+ });
345
+ });
238
346
  });
@@ -161,6 +161,14 @@ describe('Capability Loader', () => {
161
161
  db.$client.run(
162
162
  `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`,
163
163
  );
164
+ // The two route CONSUMERS, as real rows. `web_routes.module_id` is a foreign
165
+ // key onto `modules`, and seeding a route without its module was accepted
166
+ // only because the test helper ran with foreign keys off (celilo#1074).
167
+ for (const consumer of ['apt-repo', 'authentik']) {
168
+ db.$client.run(
169
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${consumer}', '${consumer}', '1.0.0', '${tempDir}/${consumer}', '{}')`,
170
+ );
171
+ }
164
172
  db.$client.run(
165
173
  `INSERT INTO web_routes (slug, module_id, type, path, hostname, target_host, target_port, websocket) VALUES ('apt--root', 'apt-repo', 'reverse_proxy', '/', 'apt.example.com', '10.0.20.50', 8080, 0)`,
166
174
  );
@@ -172,10 +180,10 @@ describe('Capability Loader', () => {
172
180
  const provider = await loadCapabilityFunctions('caddy', db, noopLogger);
173
181
  expect(provider).toHaveProperty('web_routes');
174
182
  const view = provider.web_routes as RouteReadView;
175
- const all = view.getAllRoutes();
183
+ const all = await view.getAllRoutes();
176
184
  expect(all).toHaveLength(2);
177
185
  expect(all.map((r) => r.hostname).sort()).toEqual(['apt.example.com', 'auth.example.com']);
178
- expect(view.getRoutes('apt-repo').map((r) => r.hostname)).toEqual(['apt.example.com']);
186
+ expect((await view.getRoutes('apt-repo')).map((r) => r.hostname)).toEqual(['apt.example.com']);
179
187
 
180
188
  // A consumer (not the provider) never sees the route table.
181
189
  const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger);
@@ -40,6 +40,7 @@ import {
40
40
  } from '../db/schema';
41
41
  import { decryptSecret } from '../secrets/encryption';
42
42
  import { getOrCreateMasterKey } from '../secrets/master-key';
43
+ import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
43
44
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
44
45
  import { getModuleSystems } from '../services/deployed-systems';
45
46
  import { withDnsInternalLedger } from '../services/dns-internal-records';
@@ -224,6 +225,11 @@ export async function loadCapabilityFunctions(
224
225
  logger: HookLogger,
225
226
  ): Promise<Record<string, unknown>> {
226
227
  const result: Record<string, unknown> = {};
228
+ // Which provider each injected capability came from, so the binding recorded
229
+ // at the return names the provider the consumer actually reached. Provider
230
+ // self-views (`firewall_registry`, `web_routes`) are deliberately absent —
231
+ // a module reading its own registry has not bound to anything.
232
+ const providerByCapability = new Map<string, string>();
227
233
 
228
234
  const masterKey = await getOrCreateMasterKey();
229
235
 
@@ -270,6 +276,11 @@ export async function loadCapabilityFunctions(
270
276
  );
271
277
  if (chain) {
272
278
  result[capName] = chain;
279
+ // The consumer talks to the OUTERMOST layer, which is not necessarily
280
+ // the edge provider. Every layer is `stampProvider`'d on the way out,
281
+ // so the one it was handed names itself.
282
+ const chainProvider = (chain as { providerModuleId?: string }).providerModuleId;
283
+ if (chainProvider) providerByCapability.set(capName, chainProvider);
273
284
  }
274
285
  // The chain hands a CONSUMER the innermost layer, which is not this
275
286
  // provider's own layer when it sits further out. `on_consumer_removed`
@@ -377,6 +388,7 @@ export async function loadCapabilityFunctions(
377
388
  // greenwave — true, useless, and a violation of the contract's
378
389
  // requirement to name the provider.
379
390
  result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId));
391
+ providerByCapability.set(capName, capability.moduleId);
380
392
  debugLog(`${capName}: loaded via defineCapabilityFunction`);
381
393
  continue;
382
394
  }
@@ -403,6 +415,7 @@ export async function loadCapabilityFunctions(
403
415
  result[capName] = withLedger(
404
416
  wrapWithLogging(capabilityInterface as object, logger, capName),
405
417
  );
418
+ providerByCapability.set(capName, capability.moduleId);
406
419
  debugLog(`${capName}: loaded via legacy factory`);
407
420
  // Sole firewall provider running its OWN hook: the interface just built
408
421
  // IS its layer, so hand it back under the provider-view name too.
@@ -544,6 +557,10 @@ export async function loadCapabilityFunctions(
544
557
  }
545
558
 
546
559
  try {
560
+ // The name comes off the row rather than being restated here: core
561
+ // naming a capability is what `no-module-business-in-core` Scan B counts,
562
+ // and this is bookkeeping, not a branch.
563
+ providerByCapability.set(provider.capabilityName, provider.moduleId);
547
564
  result.public_web = createPublicWeb({
548
565
  moduleId: consumingModuleId,
549
566
  logger,
@@ -610,9 +627,12 @@ export async function loadCapabilityFunctions(
610
627
  // — symmetric with how consumers get the public_web capability. Consumers
611
628
  // never see this; only the provider does.
612
629
  if (consumingModuleId === provider.moduleId) {
630
+ // Async because the consumer is a hook, which now runs in its own
631
+ // process. `routeOps` itself stays synchronous: it is bun:sqlite and it
632
+ // is called in-process by `createPublicWeb`.
613
633
  const routeView: RouteReadView = {
614
- getAllRoutes: () => routeOps.getAllRoutes(),
615
- getRoutes: (m: string) => routeOps.getRoutes(m),
634
+ getAllRoutes: async () => routeOps.getAllRoutes(),
635
+ getRoutes: async (m: string) => routeOps.getRoutes(m),
616
636
  };
617
637
  result.web_routes = routeView;
618
638
  debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`);
@@ -621,6 +641,32 @@ export async function loadCapabilityFunctions(
621
641
  debugLog('public_web: not registered in DB, skipping');
622
642
  }
623
643
 
644
+ // celilo#1072: the CALL is the binding, not the resolution. Everything above
645
+ // is injected whether or not the consumer declared it — the loop's own
646
+ // comment says "not just required ones" — so recording what was resolved
647
+ // would name every provider on the fleet. A method invocation is the only
648
+ // event that separates the optional capability a module uses from the ones it
649
+ // merely declares.
650
+ for (const [capName, iface] of Object.entries(result)) {
651
+ const providerModuleId = providerByCapability.get(capName);
652
+ // A module that provides and consumes the same capability is not bound to
653
+ // itself, and a provider reading its own registry is not a consumer.
654
+ if (!providerModuleId || providerModuleId === consumingModuleId) continue;
655
+ if (!iface || typeof iface !== 'object') continue;
656
+ result[capName] = withBindingRecord(iface as object, () => {
657
+ try {
658
+ recordCapabilityBinding(db, consumingModuleId, capName, providerModuleId);
659
+ } catch (error) {
660
+ // Bookkeeping must never fail the call it is observing. Loud, not silent.
661
+ logger.warn(
662
+ `Could not record the ${capName} binding ${consumingModuleId} → ${providerModuleId}: ${
663
+ error instanceof Error ? error.message : String(error)
664
+ }`,
665
+ );
666
+ }
667
+ });
668
+ }
669
+
624
670
  return result;
625
671
  }
626
672
 
@@ -674,6 +720,11 @@ function buildCapabilityInterface(
674
720
  trustedSubnets: zones?.trustedSubnets ?? [],
675
721
  controlPlaneSubnet: zones?.controlPlaneSubnet,
676
722
  frontedSubnets: zones?.frontedSubnets ?? [],
723
+ // Operator settings, forwarded verbatim. `parseStoredConfigValue`
724
+ // preserves each manifest-declared type, so the boolean arrives as a
725
+ // boolean and needs no coercion here.
726
+ defaultRouteZone: config.default_route_zone as string | undefined,
727
+ isolateTransitNetwork: config.isolate_transit_network as boolean | undefined,
677
728
  },
678
729
  store,
679
730
  undefined, // no upstream — the chain path handles that
@@ -1191,6 +1242,12 @@ async function buildFirewallChain(
1191
1242
  // Absent means the box has never converged cleanly, so an interface
1192
1243
  // celilo cannot attribute refuses rather than being disabled.
1193
1244
  interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline),
1245
+ // Which declared zone carries this firewall's default route, and
1246
+ // whether fronted zones may initiate into it. Both are operator
1247
+ // settings and both are read HERE or nowhere: the module declares them
1248
+ // on `FirewallConfig`, and nothing else in celilo constructs one.
1249
+ defaultRouteZone: provConfig.default_route_zone as string | undefined,
1250
+ isolateTransitNetwork: provConfig.isolate_transit_network as boolean | undefined,
1194
1251
  },
1195
1252
  store,
1196
1253
  currentUpstream,
@@ -49,6 +49,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
49
49
  consumerModuleId: 'test-consumer',
50
50
  debug: false,
51
51
  screenshotDir: '',
52
+ stateDir: '',
52
53
  capabilities: {},
53
54
  ...overrides,
54
55
  };
@@ -118,6 +118,7 @@ describe('Hook Executor', () => {
118
118
  logger,
119
119
  debug: false,
120
120
  screenshotDir: '/tmp',
121
+ stateDir: '/tmp',
121
122
  capabilities: {},
122
123
  vps_ip: '10.0.0.1',
123
124
  });
@@ -138,6 +139,7 @@ describe('Hook Executor', () => {
138
139
  logger,
139
140
  debug: false,
140
141
  screenshotDir: '/tmp',
142
+ stateDir: '/tmp',
141
143
  capabilities: {},
142
144
  }),
143
145
  ).rejects.toThrow('Hook script not found');
@@ -155,6 +157,7 @@ describe('Hook Executor', () => {
155
157
  logger,
156
158
  debug: false,
157
159
  screenshotDir: '/tmp',
160
+ stateDir: '/tmp',
158
161
  capabilities: {},
159
162
  }),
160
163
  ).rejects.toThrow('must export a default function');
@@ -176,6 +179,7 @@ describe('Hook Executor', () => {
176
179
  logger,
177
180
  debug: false,
178
181
  screenshotDir: '/tmp',
182
+ stateDir: '/tmp',
179
183
  capabilities: {},
180
184
  }),
181
185
  ).rejects.toThrow('does not use defineHook()');
@@ -192,6 +196,7 @@ describe('Hook Executor', () => {
192
196
  logger,
193
197
  debug: false,
194
198
  screenshotDir: '/tmp',
199
+ stateDir: '/tmp',
195
200
  capabilities: {},
196
201
  });
197
202
 
@@ -210,6 +215,7 @@ describe('Hook Executor', () => {
210
215
  logger,
211
216
  debug: false,
212
217
  screenshotDir: '/tmp',
218
+ stateDir: '/tmp',
213
219
  capabilities: {},
214
220
  }),
215
221
  ).rejects.toThrow('Hook execution failed: simulated error');
@@ -229,6 +235,7 @@ describe('Hook Executor', () => {
229
235
  logger,
230
236
  debug: false,
231
237
  screenshotDir: '/tmp',
238
+ stateDir: '/tmp',
232
239
  capabilities: {},
233
240
  };
234
241