@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.
- package/CELILO_CORE_MODULES.md +2 -0
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +161 -5
- package/src/hooks/broker.test.ts +152 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/executor.ts +234 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +99 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/capability-shape.ts +13 -1
- package/src/policy/module-business-baseline.ts +36 -0
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +2 -2
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/dns-internal-records.test.ts +3 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/port-forwards.test.ts +2 -2
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- 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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
974
|
+
db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
|
|
975
975
|
bus = openBus({ dbPath: join(dir, 'events.db'), events: defineEvents({}) });
|
|
976
976
|
});
|
|
977
977
|
|