@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
@@ -87,6 +87,42 @@ export interface ProviderLiteralRow {
87
87
  }
88
88
 
89
89
  export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
90
+ {
91
+ file: 'apps/celilo/src/hooks/capability-loader.ts',
92
+ capability: 'web_routes',
93
+ count: 2,
94
+ why: 'PERMANENT — a provider VIEW, not a module-provided capability. The name was registered so it stops being invisible to audits (celilo#1007); these references predate that and are framework plumbing, not core branching on a provider',
95
+ },
96
+ {
97
+ file: 'apps/celilo/src/hooks/capability-loader.ts',
98
+ capability: 'firewall_registry',
99
+ count: 4,
100
+ why: 'PERMANENT — a provider VIEW, not a module-provided capability. The name was registered so it stops being invisible to audits (celilo#1007); these references predate that and are framework plumbing, not core branching on a provider',
101
+ },
102
+ {
103
+ file: 'apps/celilo/src/db/schema.ts',
104
+ capability: 'web_routes',
105
+ count: 1,
106
+ why: 'PERMANENT — the TABLE named web_routes, which predates the capability name entirely; the collision is nominal',
107
+ },
108
+ {
109
+ file: 'packages/capabilities/src/public-web.ts',
110
+ capability: 'web_routes',
111
+ count: 1,
112
+ why: 'PERMANENT — the public_web implementation naming its own table; the capability package doing its job',
113
+ },
114
+ {
115
+ file: 'packages/capabilities/src/capability-contract.ts',
116
+ capability: 'web_routes',
117
+ count: 1,
118
+ why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
119
+ },
120
+ {
121
+ file: 'packages/capabilities/src/capability-contract.ts',
122
+ capability: 'firewall_registry',
123
+ count: 1,
124
+ why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
125
+ },
90
126
  {
91
127
  file: 'apps/celilo/src/capabilities/well-known.ts',
92
128
  capability: 'dhcp_server',
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { type Route, alerts, modules, monitors } from '../../db/schema';
7
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
7
+ import { setupTestDatabaseAt } from '../../test-utils/database';
8
8
  import { acknowledgeAlert, findLiveAlertByKey, resolveAlertManually, silenceAlert } from './ack';
9
9
  import { moduleCheckAlertKey } from './keys';
10
10
  import { createPerson, createRoute } from './people';
@@ -26,7 +26,7 @@ describe('ack / silence / resolve', () => {
26
26
  dir = mkdtempSync(join(tmpdir(), 'ack-'));
27
27
  const dbPath = join(dir, 'celilo.db');
28
28
  process.env.CELILO_DB_PATH = dbPath;
29
- db = await setupTestDatabase(dbPath);
29
+ db = await setupTestDatabaseAt(dbPath);
30
30
 
31
31
  db.insert(modules)
32
32
  .values({
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { type Alert, type Route, alerts, modules, monitors } from '../../db/schema';
8
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
8
+ import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import { moduleCheckAlertKey } from './keys';
10
10
  import { deliverDeferred } from './notifier';
11
11
  import { createPerson, createRoute } from './people';
@@ -35,7 +35,7 @@ describe('quiet-hours deferral', () => {
35
35
  dir = mkdtempSync(join(tmpdir(), 'deferral-'));
36
36
  const dbPath = join(dir, 'celilo.db');
37
37
  process.env.CELILO_DB_PATH = dbPath;
38
- db = await setupTestDatabase(dbPath);
38
+ db = await setupTestDatabaseAt(dbPath);
39
39
  sent.length = 0;
40
40
 
41
41
  db.insert(modules)
@@ -20,7 +20,7 @@ import { tmpdir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
  import type { DbClient } from '../../db/client';
22
22
  import { type Alert, type Route, modules } from '../../db/schema';
23
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
23
+ import { setupTestDatabaseAt } from '../../test-utils/database';
24
24
  import type { EscalationStep, RouteForEscalation } from './escalation';
25
25
  import { interpretInbound } from './inbound';
26
26
  import {
@@ -217,7 +217,7 @@ describe('delivery loop against the signal-cli simulator', () => {
217
217
  dir = mkdtempSync(join(tmpdir(), 'delivery-'));
218
218
  const dbPath = join(dir, 'celilo.db');
219
219
  process.env.CELILO_DB_PATH = dbPath;
220
- db = await setupTestDatabase(dbPath);
220
+ db = await setupTestDatabaseAt(dbPath);
221
221
  await control('reset');
222
222
 
223
223
  db.insert(modules)
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { modules, monitors } from '../../db/schema';
7
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
7
+ import { setupTestDatabaseAt } from '../../test-utils/database';
8
8
  import {
9
9
  closeDeployWindows,
10
10
  ensureMonitorOnDeploy,
@@ -29,7 +29,7 @@ describe('deploy hooks', () => {
29
29
  dir = mkdtempSync(join(tmpdir(), 'deploy-hooks-'));
30
30
  const dbPath = join(dir, 'celilo.db');
31
31
  process.env.CELILO_DB_PATH = dbPath;
32
- db = await setupTestDatabase(dbPath);
32
+ db = await setupTestDatabaseAt(dbPath);
33
33
  });
34
34
 
35
35
  afterEach(() => {
@@ -6,7 +6,7 @@ import type { InboundMessage } from '@celilo/capabilities';
6
6
  import { eq } from 'drizzle-orm';
7
7
  import type { DbClient } from '../../db/client';
8
8
  import { type Route, alerts, modules, monitors, notificationDeliveries } from '../../db/schema';
9
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
9
+ import { setupTestDatabaseAt } from '../../test-utils/database';
10
10
  import {
11
11
  type InboundPollDeps,
12
12
  type TransportReadRecord,
@@ -53,7 +53,7 @@ describe('pollInbound', () => {
53
53
  dir = mkdtempSync(join(tmpdir(), 'inbound-poll-'));
54
54
  const dbPath = join(dir, 'celilo.db');
55
55
  process.env.CELILO_DB_PATH = dbPath;
56
- db = await setupTestDatabase(dbPath);
56
+ db = await setupTestDatabaseAt(dbPath);
57
57
  cursors.clear();
58
58
 
59
59
  db.insert(modules)
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { type NotificationDelivery, modules, people, routes } from '../../db/schema';
7
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
7
+ import { setupTestDatabaseAt } from '../../test-utils/database';
8
8
  import { interpretInbound, parseInbound } from './inbound';
9
9
  import { mintDelivery } from './tokens';
10
10
 
@@ -83,7 +83,7 @@ describe('interpretInbound', () => {
83
83
  dir = mkdtempSync(join(tmpdir(), 'inbound-'));
84
84
  const dbPath = join(dir, 'celilo.db');
85
85
  process.env.CELILO_DB_PATH = dbPath;
86
- db = await setupTestDatabase(dbPath);
86
+ db = await setupTestDatabaseAt(dbPath);
87
87
  outstanding.length = 0;
88
88
 
89
89
  db.insert(modules)
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { defineEvents, openBus } from '@celilo/event-bus';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { type Route, modules } from '../../db/schema';
8
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
8
+ import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import {
10
10
  type NotificationResponderHandle,
11
11
  startNotificationResponder,
@@ -60,7 +60,7 @@ describe('notification responder', () => {
60
60
  const dbPath = join(dir, 'celilo.db');
61
61
  busPath = join(dir, 'events.db');
62
62
  process.env.CELILO_DB_PATH = dbPath;
63
- db = await setupTestDatabase(dbPath);
63
+ db = await setupTestDatabaseAt(dbPath);
64
64
  sent = [];
65
65
  declined = [];
66
66
  sendFailures = [];
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { type Monitor, alerts, monitorRuns, monitors } from '../../db/schema';
8
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
8
+ import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import type { HealthCheckResult } from '../health-runner';
10
10
  import { moduleAlertKey, moduleCheckAlertKey } from './keys';
11
11
  import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
@@ -54,7 +54,7 @@ describe('runOneMonitor', () => {
54
54
  dir = mkdtempSync(join(tmpdir(), 'run-monitor-'));
55
55
  const dbPath = join(dir, 'celilo.db');
56
56
  process.env.CELILO_DB_PATH = dbPath;
57
- db = await setupTestDatabase(dbPath);
57
+ db = await setupTestDatabaseAt(dbPath);
58
58
  db.insert(monitors)
59
59
  .values({
60
60
  id: 'mon-1',
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { alerts, monitors } from '../../db/schema';
8
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
8
+ import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import { moduleAlertKey, moduleCheckAlertKey } from './keys';
10
10
  import type { ReconcileAction } from './reconcile';
11
11
  import {
@@ -41,7 +41,7 @@ describe('alert store', () => {
41
41
  dir = mkdtempSync(join(tmpdir(), 'alert-store-'));
42
42
  const dbPath = join(dir, 'celilo.db');
43
43
  process.env.CELILO_DB_PATH = dbPath;
44
- db = await setupTestDatabase(dbPath);
44
+ db = await setupTestDatabaseAt(dbPath);
45
45
  db.insert(monitors)
46
46
  .values({
47
47
  id: MONITOR,
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { type Alert, type Monitor, alerts, modules, monitors } from '../../db/schema';
8
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
8
+ import { setupTestDatabaseAt } from '../../test-utils/database';
9
9
  import type { HealthCheckResult } from '../health-runner';
10
10
  import { deleteModuleConfig, upsertModuleConfig } from '../module-config';
11
11
  import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, reconcileModuleWatchState } from './health-cadence';
@@ -68,7 +68,7 @@ describe('runSweep', () => {
68
68
  dir = mkdtempSync(join(tmpdir(), 'sweep-'));
69
69
  const dbPath = join(dir, 'celilo.db');
70
70
  process.env.CELILO_DB_PATH = dbPath;
71
- db = await setupTestDatabase(dbPath);
71
+ db = await setupTestDatabaseAt(dbPath);
72
72
  // The module row is load-bearing now: a `module_hook` monitor's cadence
73
73
  // resolves from the module's manifest and config, not from its own row.
74
74
  db.insert(modules)
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { modules, people, routes } from '../../db/schema';
7
- import { setupTestDatabase } from '../../test-utils/setup-test-db';
7
+ import { setupTestDatabaseAt } from '../../test-utils/database';
8
8
  import {
9
9
  consumeDelivery,
10
10
  deliveriesForAlert,
@@ -56,7 +56,7 @@ describe('delivery records', () => {
56
56
  dir = mkdtempSync(join(tmpdir(), 'tokens-'));
57
57
  const dbPath = join(dir, 'celilo.db');
58
58
  process.env.CELILO_DB_PATH = dbPath;
59
- db = await setupTestDatabase(dbPath);
59
+ db = await setupTestDatabaseAt(dbPath);
60
60
 
61
61
  db.insert(modules)
62
62
  .values({
@@ -0,0 +1,104 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test';
2
+ import type { DbClient } from '../db/client';
3
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
4
+ import {
5
+ listCapabilityBindings,
6
+ recordCapabilityBinding,
7
+ withBindingRecord,
8
+ } from './capability-bindings';
9
+
10
+ describe('withBindingRecord', () => {
11
+ test('reports once however many times the consumer calls', () => {
12
+ let calls = 0;
13
+ const iface = withBindingRecord({ a: () => 1, b: () => 2 }, () => {
14
+ calls++;
15
+ });
16
+
17
+ iface.a();
18
+ iface.a();
19
+ iface.b();
20
+
21
+ expect(calls).toBe(1);
22
+ });
23
+
24
+ test('reports nothing when no method is called', () => {
25
+ let calls = 0;
26
+ withBindingRecord({ publishStaticSite: () => 1 }, () => {
27
+ calls++;
28
+ });
29
+
30
+ expect(calls).toBe(0);
31
+ });
32
+
33
+ test('passes arguments and the return value through untouched', () => {
34
+ const iface = withBindingRecord({ add: (a: number, b: number) => a + b }, () => {});
35
+ expect(iface.add(2, 3)).toBe(5);
36
+ });
37
+
38
+ test('leaves a synchronous method synchronous', () => {
39
+ // Unlike `wrapWithLogging`, which awaits and therefore makes every method
40
+ // async. A capability's calling convention must not change because celilo
41
+ // decided to watch it.
42
+ const iface = withBindingRecord({ now: () => 'value' }, () => {});
43
+ expect(iface.now()).toBe('value');
44
+ });
45
+
46
+ test('passes non-function properties through, including the provider stamp', () => {
47
+ const iface = withBindingRecord({ providerModuleId: 'caddy', go: () => 1 }, () => {});
48
+ expect(iface.providerModuleId).toBe('caddy');
49
+ });
50
+ });
51
+
52
+ describe('recordCapabilityBinding', () => {
53
+ let db: DbClient;
54
+
55
+ beforeEach(async () => {
56
+ if (db) await cleanupTestDatabase(db);
57
+ db = await setupTestDatabase();
58
+ db.$client.run(
59
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('consumer', 'consumer', '1.0.0', '/tmp/consumer', '{}')`,
60
+ );
61
+ });
62
+
63
+ test('re-asserting the same binding keeps one row', () => {
64
+ recordCapabilityBinding(db, 'consumer', 'external_web', 'cpanel-host');
65
+ recordCapabilityBinding(db, 'consumer', 'external_web', 'cpanel-host');
66
+
67
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1);
68
+ });
69
+
70
+ test('a provider swap rewrites the row in place', () => {
71
+ recordCapabilityBinding(db, 'consumer', 'public_web', 'caddy');
72
+ recordCapabilityBinding(db, 'consumer', 'public_web', 'caddy-internal');
73
+
74
+ const bindings = listCapabilityBindings(db, 'consumer');
75
+ expect(bindings).toHaveLength(1);
76
+ expect(bindings[0].providerModuleId).toBe('caddy-internal');
77
+ });
78
+
79
+ test('boundAt is last-seen, not first-seen', () => {
80
+ // A binding last exercised forty deploys ago has to be a queryable fact
81
+ // rather than a silent lie, so the timestamp moves on every invocation.
82
+ recordCapabilityBinding(db, 'consumer', 'idp', 'authentik');
83
+ db.$client.run(
84
+ `UPDATE capability_bindings SET bound_at = 1000 WHERE consumer_module_id = 'consumer'`,
85
+ );
86
+
87
+ recordCapabilityBinding(db, 'consumer', 'idp', 'authentik');
88
+
89
+ expect(listCapabilityBindings(db, 'consumer')[0].boundAt.getTime()).toBeGreaterThan(
90
+ 1000 * 1000,
91
+ );
92
+ });
93
+
94
+ test('bindings are scoped to the consumer that made them', () => {
95
+ db.$client.run(
96
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('other', 'other', '1.0.0', '/tmp/other', '{}')`,
97
+ );
98
+ recordCapabilityBinding(db, 'consumer', 'source_forge', 'forgejo');
99
+ recordCapabilityBinding(db, 'other', 'source_forge', 'forgejo');
100
+
101
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1);
102
+ expect(listCapabilityBindings(db, 'other')).toHaveLength(1);
103
+ });
104
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Which provider a consumer actually resolved to (celilo#1072).
3
+ *
4
+ * `capabilities` is provider-side only, so celilo could answer "who could
5
+ * provide this" and never "who does this module actually use". Callers that
6
+ * needed the second question rebuilt the same approximation — the consumer's
7
+ * `requires` + `optional` crossed with `capabilities` — which names every
8
+ * provider a module MIGHT have bound to. `tango-nexus` declares four optional
9
+ * capabilities and is deployed against one host, so that approximation carries
10
+ * three edges that do not exist and nothing distinguishes them.
11
+ *
12
+ * The binding is the CALL, not the resolution. `loadCapabilityFunctions`
13
+ * injects every capability that has a registered provider, deliberately, and
14
+ * never reads the consumer's manifest — so what it resolves is a superset of
15
+ * even the permissive set. A consumer's hook invoking a method is the one event
16
+ * that separates the optional it uses from the three it does not.
17
+ *
18
+ * This runs BESIDE the permissive set, it does not replace it.
19
+ * `planConsumerCleanup` must still notify every provider that might hold minted
20
+ * state, including providers this table has no row for.
21
+ */
22
+
23
+ import { desc, eq, sql } from 'drizzle-orm';
24
+ import type { DbClient } from '../db/client';
25
+ import { capabilityBindings } from '../db/schema';
26
+
27
+ export interface CapabilityBinding {
28
+ capabilityName: string;
29
+ providerModuleId: string;
30
+ /** Last time the consumer called into this provider. */
31
+ boundAt: Date;
32
+ }
33
+
34
+ /**
35
+ * Record that `consumer` called into `provider` for `capability`.
36
+ *
37
+ * Upsert on (consumer, capability): a redeploy re-asserts the row rather than
38
+ * duplicating it, and a provider swap rewrites the provider in place. `boundAt`
39
+ * carries last-seen semantics — it moves on every invocation, so a binding last
40
+ * exercised forty deploys ago is a queryable fact rather than a silent lie.
41
+ */
42
+ export function recordCapabilityBinding(
43
+ db: DbClient,
44
+ consumerModuleId: string,
45
+ capabilityName: string,
46
+ providerModuleId: string,
47
+ ): void {
48
+ db.insert(capabilityBindings)
49
+ .values({ consumerModuleId, capabilityName, providerModuleId })
50
+ .onConflictDoUpdate({
51
+ target: [capabilityBindings.consumerModuleId, capabilityBindings.capabilityName],
52
+ set: { providerModuleId, boundAt: sql`(unixepoch())` },
53
+ })
54
+ .run();
55
+ }
56
+
57
+ /** Every provider this consumer has actually called into, most recent first. */
58
+ export function listCapabilityBindings(
59
+ db: DbClient,
60
+ consumerModuleId: string,
61
+ ): CapabilityBinding[] {
62
+ return db
63
+ .select()
64
+ .from(capabilityBindings)
65
+ .where(eq(capabilityBindings.consumerModuleId, consumerModuleId))
66
+ .orderBy(desc(capabilityBindings.boundAt))
67
+ .all()
68
+ .map((row) => ({
69
+ capabilityName: row.capabilityName,
70
+ providerModuleId: row.providerModuleId,
71
+ boundAt: row.boundAt,
72
+ }));
73
+ }
74
+
75
+ /**
76
+ * Wrap a capability interface so the first method call reports the binding.
77
+ *
78
+ * Modelled on `wrapWithLogging`: a new object over the same prototype, every
79
+ * function replaced, everything else passed through. Unlike that wrapper this
80
+ * one does NOT make the method async — it records and then returns the original
81
+ * call untouched, so a synchronous capability method stays synchronous.
82
+ *
83
+ * `onUse` fires once per wrapped interface. A hook that calls a capability
84
+ * twenty times writes one row.
85
+ */
86
+ export function withBindingRecord<T extends object>(iface: T, onUse: () => void): T {
87
+ let reported = false;
88
+ const wrapped = Object.create(Object.getPrototypeOf(iface));
89
+
90
+ for (const key of Reflect.ownKeys(iface)) {
91
+ const value = (iface as Record<string | symbol, unknown>)[key];
92
+ if (typeof value !== 'function') {
93
+ wrapped[key] = value;
94
+ continue;
95
+ }
96
+ const original = value as (...args: unknown[]) => unknown;
97
+ wrapped[key] = function (this: unknown, ...args: unknown[]): unknown {
98
+ if (!reported) {
99
+ reported = true;
100
+ onUse();
101
+ }
102
+ return original.apply(this, args);
103
+ };
104
+ }
105
+
106
+ return wrapped as T;
107
+ }
@@ -19,7 +19,7 @@ import { join } from 'node:path';
19
19
  import { allDeclaredTables } from '@celilo/capabilities';
20
20
  import type { DbClient } from '../db/client';
21
21
  import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema';
22
- import { setupTestDatabase } from '../test-utils/setup-test-db';
22
+ import { setupTestDatabaseAt } from '../test-utils/database';
23
23
  import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
24
24
 
25
25
  const FW = '192.168.0.254';
@@ -56,7 +56,7 @@ describe('deleteClaimedRows', () => {
56
56
  dir = mkdtempSync(join(tmpdir(), 'claimed-'));
57
57
  const dbPath = join(dir, 'celilo.db');
58
58
  process.env.CELILO_DB_PATH = dbPath;
59
- db = await setupTestDatabase(dbPath);
59
+ db = await setupTestDatabaseAt(dbPath);
60
60
 
61
61
  for (const id of ['caddy', 'forgejo', 'technitium']) {
62
62
  db.insert(modules)
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DbClient } from '../db/client';
6
6
  import type { ModuleManifest } from '../manifest/schema';
7
- import { setupTestDatabase } from '../test-utils/setup-test-db';
7
+ import { setupTestDatabaseAt } from '../test-utils/database';
8
8
  import {
9
9
  type CleanupTarget,
10
10
  type ProviderRow,
@@ -167,6 +167,43 @@ describe('planConsumerCleanup', () => {
167
167
  });
168
168
  });
169
169
 
170
+ describe('planConsumerCleanup stays permissive after celilo#1072', () => {
171
+ it('notifies a provider the consumer has no recorded binding to', () => {
172
+ // celilo#1072 added `capability_bindings`, which records the provider a
173
+ // consumer actually CALLED. That set is precise and it is deliberately not
174
+ // this one. Cleanup must reach every provider that MIGHT hold minted state,
175
+ // including one a hook resolved, minted against, and never called again —
176
+ // and including one whose binding row was never written because the state
177
+ // was minted by an older celilo. Narrowing this to the recorded set would
178
+ // leave a site block in caddy or a DNAT rule in a ruleset with nothing left
179
+ // in the registry to reconcile it away, which is the exact failure
180
+ // `consumer-cleanup.ts` exists to prevent.
181
+ //
182
+ // The inputs here are manifest + provider rows and NOTHING ELSE. That is
183
+ // the assertion: no binding table is consulted, so no binding table can
184
+ // shrink the result.
185
+ const plan = planConsumerCleanup(
186
+ 'tango-nexus',
187
+ manifest({ optional: ['external_web', 'public_web', 'source_forge', 'registry_publish'] }),
188
+ provides(
189
+ ['cpanel-host', 'external_web'],
190
+ ['caddy', 'public_web'],
191
+ ['forgejo', 'source_forge'],
192
+ ['celilo-registry', 'registry_publish'],
193
+ ),
194
+ deployed('cpanel-host', 'caddy', 'forgejo', 'celilo-registry'),
195
+ );
196
+
197
+ expect(plan.map((t) => t.providerId)).toEqual([
198
+ 'caddy',
199
+ 'celilo-registry',
200
+ 'cpanel-host',
201
+ 'forgejo',
202
+ ]);
203
+ expect(plan.every((t) => t.skip === undefined)).toBe(true);
204
+ });
205
+ });
206
+
170
207
  describe('runConsumerCleanup — the paused skip is reported, not silent', () => {
171
208
  let dir: string;
172
209
  let db: DbClient;
@@ -175,7 +212,7 @@ describe('runConsumerCleanup — the paused skip is reported, not silent', () =>
175
212
  dir = mkdtempSync(join(tmpdir(), 'cc-'));
176
213
  const dbPath = join(dir, 'celilo.db');
177
214
  process.env.CELILO_DB_PATH = dbPath;
178
- db = await setupTestDatabase(dbPath);
215
+ db = await setupTestDatabaseAt(dbPath);
179
216
  });
180
217
  afterEach(() => {
181
218
  db.$client.close();
@@ -244,7 +281,7 @@ describe('runConsumerCleanup — dispatch', () => {
244
281
  dir = mkdtempSync(join(tmpdir(), 'ccd-'));
245
282
  const dbPath = join(dir, 'celilo.db');
246
283
  process.env.CELILO_DB_PATH = dbPath;
247
- db = await setupTestDatabase(dbPath);
284
+ db = await setupTestDatabaseAt(dbPath);
248
285
  });
249
286
  afterEach(() => {
250
287
  db.$client.close();
@@ -7,7 +7,7 @@ import type { ViewOverride } from '@celilo/capabilities';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import type { DbClient } from '../db/client';
9
9
  import { dnsInternalRecords, modules } from '../db/schema';
10
- import { setupTestDatabase } from '../test-utils/setup-test-db';
10
+ import { setupTestDatabaseAt } from '../test-utils/database';
11
11
  import {
12
12
  listDnsInternalRecords,
13
13
  listViewOverrides,
@@ -25,7 +25,7 @@ describe('dns-internal-records ledger', () => {
25
25
  dir = mkdtempSync(join(tmpdir(), 'dns-int-'));
26
26
  dbPath = join(dir, 'celilo.db');
27
27
  process.env.CELILO_DB_PATH = dbPath;
28
- db = await setupTestDatabase(dbPath);
28
+ db = await setupTestDatabaseAt(dbPath);
29
29
  // FK targets: provider + consumer modules.
30
30
  for (const id of ['technitium', 'forgejo']) {
31
31
  db.insert(modules)
@@ -222,7 +222,7 @@ describe('provider cascade (celilo#1010)', () => {
222
222
  dir = mkdtempSync(join(tmpdir(), 'dns-cascade-'));
223
223
  const dbPath = join(dir, 'celilo.db');
224
224
  process.env.CELILO_DB_PATH = dbPath;
225
- db = await setupTestDatabase(dbPath);
225
+ db = await setupTestDatabaseAt(dbPath);
226
226
  for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) {
227
227
  db.insert(modules)
228
228
  .values({
@@ -13,7 +13,7 @@ import {
13
13
  systemConfig,
14
14
  } from '../db/schema';
15
15
  import type { ModuleManifest } from '../manifest/schema';
16
- import { setupTestDatabase } from '../test-utils/setup-test-db';
16
+ import { setupTestDatabaseAt } from '../test-utils/database';
17
17
  import { ensureInboundSubscriber, ensureSweepSubscriber } from './alerting/monitors';
18
18
  import { ensureBackupSweepSubscriber } from './backup-sweep';
19
19
  import { getDaemonUnitPath } from './events-daemon';
@@ -263,7 +263,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
263
263
  busPath = join(dir, 'events.db');
264
264
  process.env.CELILO_DB_PATH = dbPath;
265
265
  process.env.EVENT_BUS_DB = busPath;
266
- db = await setupTestDatabase(dbPath);
266
+ db = await setupTestDatabaseAt(dbPath);
267
267
  bus = openBus({ dbPath: busPath, events: defineEvents({}) });
268
268
  });
269
269
  afterEach(() => {
@@ -737,7 +737,7 @@ describe('checkControlPlaneNetwork', () => {
737
737
 
738
738
  beforeEach(async () => {
739
739
  cpDir = mkdtempSync(join(tmpdir(), 'fleet-cpn-'));
740
- db = await setupTestDatabase(join(cpDir, 'celilo.db'));
740
+ db = await setupTestDatabaseAt(join(cpDir, 'celilo.db'));
741
741
  });
742
742
 
743
743
  afterEach(() => {
@@ -971,7 +971,7 @@ describe('runFleetChecks includes host liveness (#728)', () => {
971
971
  dir = mkdtempSync(join(tmpdir(), 'fleet-liveness-'));
972
972
  process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
973
973
  process.env.EVENT_BUS_DB = join(dir, 'events.db');
974
- db = await setupTestDatabase(join(dir, 'celilo.db'));
974
+ db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
975
975
  bus = openBus({ dbPath: join(dir, 'events.db'), events: defineEvents({}) });
976
976
  });
977
977