@celilo/cli 1.8.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +153 -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/define-hook.test.ts +1 -0
- package/src/hooks/executor.test.ts +7 -0
- package/src/hooks/executor.ts +245 -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-state-dir.test.ts +109 -0
- package/src/hooks/hook-timeout.test.ts +104 -0
- package/src/hooks/hook-trespass.test.ts +202 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/mount-set.test.ts +148 -0
- package/src/hooks/mount-set.ts +234 -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/contracts/v1.ts +21 -6
- 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 +105 -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/policy/module-dep-reachability.test.ts +167 -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/module-fixtures.ts +147 -35
- package/src/test-utils/setup-test-db.ts +0 -80
|
@@ -85,4 +85,12 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
|
|
|
85
85
|
version: '1.0.0',
|
|
86
86
|
hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
|
|
87
87
|
},
|
|
88
|
+
web_routes: {
|
|
89
|
+
version: '1.0.0',
|
|
90
|
+
hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
|
|
91
|
+
},
|
|
92
|
+
firewall_registry: {
|
|
93
|
+
version: '2.0.0',
|
|
94
|
+
hash: 'db266155107b9eba88dbff576f979069e012e10a6dfb80de4544f809d1157bfb',
|
|
95
|
+
},
|
|
88
96
|
};
|
|
@@ -65,7 +65,19 @@ export function capabilitySubjects(): Map<string, { typeName: string; file: stri
|
|
|
65
65
|
const name = member.name.getText(source);
|
|
66
66
|
const typeName = member.type.getText(source);
|
|
67
67
|
const file = fileOfType.get(typeName);
|
|
68
|
-
if (file)
|
|
68
|
+
if (!file) {
|
|
69
|
+
// LOUD, not skipped. A member whose type is not a bare imported
|
|
70
|
+
// identifier — an intersection, a generic, a locally-declared type —
|
|
71
|
+
// would otherwise drop out of coverage silently, and the gate would go
|
|
72
|
+
// green having checked one capability fewer. That is the same shape as
|
|
73
|
+
// the bugs this whole change kept finding: an absence that reads as a
|
|
74
|
+
// pass. If a registry member legitimately needs a composite type, teach
|
|
75
|
+
// this resolver about it rather than letting it vanish.
|
|
76
|
+
throw new Error(
|
|
77
|
+
`capability-shape: '${name}' has type '${typeName}', which is not a bare identifier imported into capability-registry.ts, so its shape cannot be hashed. Teach capabilitySubjects() how to resolve it.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
subjects.set(name, { typeName, file });
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
return subjects;
|
|
@@ -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',
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every module's bundled `@celilo/*` range must be able to resolve the version
|
|
3
|
+
* the workspace is about to publish.
|
|
4
|
+
*
|
|
5
|
+
* The failure this exists to catch has no symptom. A module bundles its own
|
|
6
|
+
* copy of `@celilo/capabilities` and runs THAT copy (celilo#173), so when the
|
|
7
|
+
* workspace publishes a new major, a module still pinned to the old caret
|
|
8
|
+
* range keeps installing the old copy. Everything typechecks. Every test
|
|
9
|
+
* passes. The new export is simply unreachable to every module in the fleet,
|
|
10
|
+
* and nothing anywhere says so.
|
|
11
|
+
*
|
|
12
|
+
* That is not hypothetical. `firewall_registry` was registered in
|
|
13
|
+
* `CapabilityRegistry` and npm's published `@celilo/capabilities@2.6.0`
|
|
14
|
+
* contains zero occurrences of it. Every module bundled `^2.6.0`, and a caret
|
|
15
|
+
* range never crosses a major, so the registration reached nothing. It was
|
|
16
|
+
* found by a person reading the diff (celilo#1089), not by a gate.
|
|
17
|
+
*
|
|
18
|
+
* `npm-consumer-smoke` cannot see it: it installs from locally-packed tarballs
|
|
19
|
+
* and never consults a registry, which is exactly why this class drifts unseen.
|
|
20
|
+
* `check:modules` cannot see it either — it installs each module's DECLARED
|
|
21
|
+
* range and typechecks, so a module that does not yet USE the new export is
|
|
22
|
+
* perfectly happy on the old copy. Both gates are green while the thing is
|
|
23
|
+
* broken.
|
|
24
|
+
*
|
|
25
|
+
* ## THIS GATE IS DELIBERATELY NARROW. READ THIS BEFORE TRUSTING IT.
|
|
26
|
+
*
|
|
27
|
+
* Its name invites a stronger reading than it earns. It compares the WORKSPACE
|
|
28
|
+
* version against each module's range. It does NOT consult a registry, so it
|
|
29
|
+
* cannot see a gap between what is PUBLISHED and what a module bundles.
|
|
30
|
+
*
|
|
31
|
+
* Measured, not assumed: run against `00c12ec2^1` — the commit immediately
|
|
32
|
+
* before the Version Packages PR that shipped capabilities 3.0.0 — this gate
|
|
33
|
+
* passes 3/0. At that commit the workspace was 2.6.0 and every module was
|
|
34
|
+
* `^2.6.0`, which satisfies, while npm's published 2.6.0 contained zero
|
|
35
|
+
* occurrences of `firewall_registry`. **So it would not have caught
|
|
36
|
+
* celilo#1089, the bug it was written for.** Nor `hello-trespass` sitting at
|
|
37
|
+
* `^2.3.0`, because `satisfies('2.6.0', '^2.3.0')` is true: a caret crosses
|
|
38
|
+
* minors freely and stops only at a major.
|
|
39
|
+
*
|
|
40
|
+
* If you are here because a published export turned out to be unreachable and
|
|
41
|
+
* you are wondering why this was green: it never looked. That check needs a
|
|
42
|
+
* registry query, which is network-bound and therefore does not belong beside
|
|
43
|
+
* hermetic checks in `ci/validate` — a flaky gate gets disabled. Its home is
|
|
44
|
+
* the release pipeline or a scheduled job.
|
|
45
|
+
*
|
|
46
|
+
* And one limitation no in-repo gate can ever close, however it is written:
|
|
47
|
+
* out-of-repo modules. `lunacycle` lives outside this checkout and no sweep
|
|
48
|
+
* here can see its pin at all (lunacycle#64).
|
|
49
|
+
*
|
|
50
|
+
* What this DOES catch is a module whose pin is out of lockstep with the
|
|
51
|
+
* workspace, which happens when a module is added or edited outside the
|
|
52
|
+
* release sweep's window: the Version Packages PR is open with everything at
|
|
53
|
+
* `^3.0.0`, a new module merges to main at `^2.6.0`, the version PR merges,
|
|
54
|
+
* and nothing re-runs the sweep until the next release.
|
|
55
|
+
*
|
|
56
|
+
* `scripts/sync-consumer-pins.ts` is what normally keeps these aligned, and it
|
|
57
|
+
* runs inside the version-PR phase of `release.yml`. This gate is the check
|
|
58
|
+
* that it RAN and covered everything — a module added between releases carries
|
|
59
|
+
* whatever range its author typed until the next release sweeps it.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { describe, expect, test } from 'bun:test';
|
|
63
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
64
|
+
import { join } from 'node:path';
|
|
65
|
+
import { repoRoot } from './capability-shape';
|
|
66
|
+
|
|
67
|
+
interface PackageJson {
|
|
68
|
+
name?: string;
|
|
69
|
+
version?: string;
|
|
70
|
+
dependencies?: Record<string, string>;
|
|
71
|
+
devDependencies?: Record<string, string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readJson(path: string): PackageJson {
|
|
75
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as PackageJson;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `@celilo/*` package name → the version this checkout would publish. */
|
|
79
|
+
function workspaceVersions(root: string): Map<string, string> {
|
|
80
|
+
const versions = new Map<string, string>();
|
|
81
|
+
for (const dir of ['packages', 'apps']) {
|
|
82
|
+
const base = join(root, dir);
|
|
83
|
+
if (!existsSync(base)) continue;
|
|
84
|
+
for (const entry of readdirSync(base)) {
|
|
85
|
+
const manifest = join(base, entry, 'package.json');
|
|
86
|
+
if (!existsSync(manifest)) continue;
|
|
87
|
+
const pkg = readJson(manifest);
|
|
88
|
+
if (pkg.name?.startsWith('@celilo/') && pkg.version) versions.set(pkg.name, pkg.version);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return versions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ModuleDep {
|
|
95
|
+
module: string;
|
|
96
|
+
dep: string;
|
|
97
|
+
range: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every `@celilo/*` dependency declared by any module's `scripts/` package. */
|
|
101
|
+
function moduleDeps(root: string): ModuleDep[] {
|
|
102
|
+
const out: ModuleDep[] = [];
|
|
103
|
+
const modulesDir = join(root, 'modules');
|
|
104
|
+
for (const module of readdirSync(modulesDir)) {
|
|
105
|
+
const manifest = join(modulesDir, module, 'scripts', 'package.json');
|
|
106
|
+
if (!existsSync(manifest)) continue;
|
|
107
|
+
const pkg = readJson(manifest);
|
|
108
|
+
for (const [dep, range] of Object.entries({
|
|
109
|
+
...(pkg.dependencies ?? {}),
|
|
110
|
+
...(pkg.devDependencies ?? {}),
|
|
111
|
+
})) {
|
|
112
|
+
if (dep.startsWith('@celilo/')) out.push({ module, dep, range });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('a module can reach what the workspace publishes', () => {
|
|
119
|
+
const root = repoRoot();
|
|
120
|
+
const versions = workspaceVersions(root);
|
|
121
|
+
const deps = moduleDeps(root);
|
|
122
|
+
|
|
123
|
+
test('the scan found the workspace packages and the module deps', () => {
|
|
124
|
+
// An empty scan passes every assertion below while checking nothing, which
|
|
125
|
+
// is the failure mode that makes a green gate worse than no gate.
|
|
126
|
+
expect(versions.size).toBeGreaterThan(0);
|
|
127
|
+
expect(deps.length).toBeGreaterThan(0);
|
|
128
|
+
expect(versions.has('@celilo/capabilities')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('every module range resolves the version this checkout would publish', () => {
|
|
132
|
+
const unreachable = deps
|
|
133
|
+
.filter(({ dep, range }) => {
|
|
134
|
+
const version = versions.get(dep);
|
|
135
|
+
// A dep on a package this checkout does not build is out of scope: it
|
|
136
|
+
// resolves from the registry like any third-party dependency.
|
|
137
|
+
if (!version) return false;
|
|
138
|
+
return !Bun.semver.satisfies(version, range);
|
|
139
|
+
})
|
|
140
|
+
.map(
|
|
141
|
+
({ module, dep, range }) =>
|
|
142
|
+
`${module} pins ${dep}@${range}, which cannot resolve the workspace's ${versions.get(dep)}`,
|
|
143
|
+
)
|
|
144
|
+
.sort();
|
|
145
|
+
|
|
146
|
+
const guidance = [
|
|
147
|
+
'A module bundles its own copy and runs THAT copy (celilo#173). A range that',
|
|
148
|
+
'cannot reach the version about to be published means every export added',
|
|
149
|
+
'since is unreachable to that module, silently.',
|
|
150
|
+
'',
|
|
151
|
+
'Fix: bun scripts/sync-consumer-pins.ts (then commit the rewritten pins)',
|
|
152
|
+
'',
|
|
153
|
+
...unreachable,
|
|
154
|
+
].join('\n');
|
|
155
|
+
|
|
156
|
+
expect(unreachable, guidance).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('PROVE IT FAILS: a caret range does not cross a major', () => {
|
|
160
|
+
// The exact shape of celilo#1089: modules on ^2.6.0, workspace at 3.0.0.
|
|
161
|
+
expect(Bun.semver.satisfies('3.0.0', '^2.6.0')).toBe(false);
|
|
162
|
+
// And the near-miss that makes it easy to believe you are covered: a caret
|
|
163
|
+
// DOES cross a minor, so a lagging pin looks harmless right up until the
|
|
164
|
+
// major.
|
|
165
|
+
expect(Bun.semver.satisfies('2.6.0', '^2.3.0')).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -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
|
+
}
|