@celilo/cli 1.7.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 (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The declared coverage and the aspect that does the covering must agree.
3
+ *
4
+ * `lxc-dns-at-birth` splits ownership: terraform owns birth DNS, the
5
+ * base-module aspect owns ongoing DNS. Terraform injects
6
+ * `lifecycle { ignore_changes = [nameserver] }`, so it can never correct the
7
+ * birth value — a zone the aspect does not cover has NO owner for ongoing DNS
8
+ * and its birth list is permanent. That is why the nameserver composition only
9
+ * strips the public resolvers for a zone the deployed provider's aspect covers
10
+ * (design D5d).
11
+ *
12
+ * Core reads that coverage from the provider's CAPABILITY DATA rather than
13
+ * looking up the provider's manifest, because core naming a capability to find
14
+ * its provider is what the module-business gate exists to stop. The cost of
15
+ * that choice is two lists in one manifest instead of one, and drift between
16
+ * them is not cosmetic: a zone listed as covered but absent from the aspect
17
+ * loses its public resolvers with nothing owning what replaces them.
18
+ *
19
+ * So this test is the thing that makes the choice safe. It is the reason the
20
+ * duplication is acceptable.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { join, resolve } from 'node:path';
26
+ import { parse } from 'yaml';
27
+
28
+ function repoRoot(): string {
29
+ let dir = import.meta.dir;
30
+ for (let i = 0; i < 8; i++) {
31
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
32
+ dir = resolve(dir, '..');
33
+ }
34
+ throw new Error('could not locate repo root');
35
+ }
36
+
37
+ interface ProviderManifest {
38
+ provides?: {
39
+ capabilities?: { name: string; data?: { aspect?: { covered_zones?: string[] } } }[];
40
+ };
41
+ base_module_aspect?: { applicable_zones?: string[] };
42
+ }
43
+
44
+ /** Every module declaring a `dns_internal` capability, with its manifest. */
45
+ function dnsInternalProviders(): { id: string; manifest: ProviderManifest }[] {
46
+ const modulesDir = join(repoRoot(), 'modules');
47
+ const found: { id: string; manifest: ProviderManifest }[] = [];
48
+ for (const id of readdirSync(modulesDir)) {
49
+ const path = join(modulesDir, id, 'manifest.yml');
50
+ if (!existsSync(path)) continue;
51
+ let manifest: ProviderManifest;
52
+ try {
53
+ manifest = parse(readFileSync(path, 'utf-8')) as ProviderManifest;
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (manifest.provides?.capabilities?.some((c) => c.name === 'dns_internal')) {
58
+ found.push({ id, manifest });
59
+ }
60
+ }
61
+ return found;
62
+ }
63
+
64
+ describe('dns_internal providers declare the coverage their aspect actually has', () => {
65
+ const providers = dnsInternalProviders();
66
+
67
+ test('the scan found the providers (sanity — it actually ran)', () => {
68
+ // knot-unbound-internal and technitium. A scan that silently found nothing
69
+ // would make every assertion below vacuously true.
70
+ expect(providers.map((p) => p.id).sort()).toEqual(['knot-unbound-internal', 'technitium']);
71
+ });
72
+
73
+ test.each(dnsInternalProviders().map((p) => [p.id, p] as const))(
74
+ '%s: declared covered_zones equals base_module_aspect.applicable_zones',
75
+ (_id, provider) => {
76
+ const capability = provider.manifest.provides?.capabilities?.find(
77
+ (c) => c.name === 'dns_internal',
78
+ );
79
+ const declared = capability?.data?.aspect?.covered_zones;
80
+ const applicable = provider.manifest.base_module_aspect?.applicable_zones;
81
+
82
+ expect(declared, 'dns_internal.data.aspect.covered_zones is missing').toBeDefined();
83
+ expect(applicable, 'base_module_aspect.applicable_zones is missing').toBeDefined();
84
+ expect([...(declared ?? [])].sort()).toEqual([...(applicable ?? [])].sort());
85
+ },
86
+ );
87
+
88
+ test('no provider claims to cover `external`', () => {
89
+ // An `external` system is a cloud VPS outside the perimeter with no route
90
+ // to a dmz-resident resolver. Its public resolvers are the only working
91
+ // configuration, not a fallback that might mask a split-horizon error, so
92
+ // claiming coverage there would strip the only addresses it can reach.
93
+ for (const provider of providers) {
94
+ const declared =
95
+ provider.manifest.provides?.capabilities?.find((c) => c.name === 'dns_internal')?.data
96
+ ?.aspect?.covered_zones ?? [];
97
+ expect(declared, `${provider.id} claims to cover external`).not.toContain('external');
98
+ }
99
+ });
100
+ });
@@ -33,21 +33,28 @@
33
33
  * never go away.
34
34
  */
35
35
 
36
+ import { allDeclaredTables } from '@celilo/capabilities';
37
+
36
38
  /**
37
39
  * Tables tagged `@owner capability:*` in `db/schema.ts`, and the capability
38
40
  * each belongs to. Scan A asserts the tagged set EQUALS this map — not that it
39
- * is empty. These four exist today (audit T1, T2, T3, T6) and Phase 1 migrates
41
+ * is empty.
42
+ *
43
+ * DERIVED from the capability declarations, not hand-written
44
+ * (openspec/changes/capability-owned-tables task 2.7). It used to be a literal,
45
+ * which made `@owner capability:public_web` a comment checked against another
46
+ * comment. Now the tag is checked against the thing that actually governs the
47
+ * table, so a declared table that nobody tagged and a tagged table nobody
48
+ * declared BOTH fail, and the map cannot drift from the declarations because it
49
+ * no longer exists apart from them. These four exist today (audit T1, T2, T3, T6) and Phase 1 migrates
40
50
  * nothing; an assertion that core holds none would be red the day it landed.
41
51
  *
42
52
  * Tracked by #939. Migrating them keeps the ownership CLAIM in core — dropping
43
53
  * it is the mistake that made `dns_registration_consumers` necessary (#626).
44
54
  */
45
- export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> = {
46
- web_routes: 'public_web',
47
- port_forwards: 'firewall',
48
- trusted_sources: 'firewall',
49
- dns_internal_records: 'dns_internal',
50
- };
55
+ export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> = Object.fromEntries(
56
+ allDeclaredTables().map(({ capability, declaration }) => [declaration.table, capability]),
57
+ );
51
58
 
52
59
  /** One `{file, capability}` pair and how many times core names it. */
53
60
  export interface CapabilityNameRow {
@@ -80,6 +87,42 @@ export interface ProviderLiteralRow {
80
87
  }
81
88
 
82
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
+ },
83
126
  {
84
127
  file: 'apps/celilo/src/capabilities/well-known.ts',
85
128
  capability: 'dhcp_server',
@@ -266,6 +309,24 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
266
309
  count: 1,
267
310
  why: 'X3 — reaches capabilitiesMap.dns_internal by name; should read a declared field (X4 is the model) (#945)',
268
311
  },
312
+ {
313
+ file: 'packages/capabilities/src/declared-tables.ts',
314
+ capability: 'dns_internal',
315
+ count: 1,
316
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
317
+ },
318
+ {
319
+ file: 'packages/capabilities/src/declared-tables.ts',
320
+ capability: 'firewall',
321
+ count: 1,
322
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
323
+ },
324
+ {
325
+ file: 'packages/capabilities/src/declared-tables.ts',
326
+ capability: 'public_web',
327
+ count: 1,
328
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
329
+ },
269
330
  {
270
331
  file: 'packages/capabilities/src/capability-contract.ts',
271
332
  capability: 'control_plane_vpn',
@@ -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
+ }