@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
6
|
+
import { deriveInstanceModuleId, ownedSystemModuleIds } from './module-instances';
|
|
7
|
+
|
|
8
|
+
describe('deriveInstanceModuleId', () => {
|
|
9
|
+
test('is deterministic, so an instantiate is safe to retry', () => {
|
|
10
|
+
const a = deriveInstanceModuleId('byoi', 'lab', 'sub-abc123');
|
|
11
|
+
const b = deriveInstanceModuleId('byoi', 'lab', 'sub-abc123');
|
|
12
|
+
expect(a).toBe(b);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('produces a valid kebab-case module id from an opaque key', () => {
|
|
16
|
+
// celilo never interprets the key, so it may be anything at all.
|
|
17
|
+
const id = deriveInstanceModuleId('byoi', 'lab', 'Sally Smith <sally@example.com> 🎉');
|
|
18
|
+
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
19
|
+
expect(id.startsWith('byoi-lab-')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('two parents using the same key get different ids', () => {
|
|
23
|
+
expect(deriveInstanceModuleId('byoi', 'lab', 'shared-key')).not.toBe(
|
|
24
|
+
deriveInstanceModuleId('forgejo', 'lab', 'shared-key'),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('one parent reusing a key across two submodules gets different ids', () => {
|
|
29
|
+
expect(deriveInstanceModuleId('forgejo', 'runner', 'proj-1')).not.toBe(
|
|
30
|
+
deriveInstanceModuleId('forgejo', 'builder', 'proj-1'),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Without a separator in the hash input, ("a", "b-c") and ("a-b", "c") hash
|
|
35
|
+
// the same string. The kind of ambiguity that never shows up until it does.
|
|
36
|
+
test('a hyphen moving between parent and submodule changes the id', () => {
|
|
37
|
+
expect(deriveInstanceModuleId('a', 'b-c', 'k')).not.toBe(
|
|
38
|
+
deriveInstanceModuleId('a-b', 'c', 'k'),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a different key changes the id', () => {
|
|
43
|
+
expect(deriveInstanceModuleId('byoi', 'lab', 'one')).not.toBe(
|
|
44
|
+
deriveInstanceModuleId('byoi', 'lab', 'two'),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('module_instances storage', () => {
|
|
50
|
+
let root: string;
|
|
51
|
+
let db: DbClient;
|
|
52
|
+
|
|
53
|
+
const addModule = (id: string) =>
|
|
54
|
+
db.$client.run(
|
|
55
|
+
"INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}','/tmp/'||?)",
|
|
56
|
+
[id, id, id],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const addInstance = (parent: string, submodule: string, key: string) => {
|
|
60
|
+
const id = deriveInstanceModuleId(parent, submodule, key);
|
|
61
|
+
addModule(id);
|
|
62
|
+
db.$client.run(
|
|
63
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
64
|
+
[id, parent, submodule, key],
|
|
65
|
+
);
|
|
66
|
+
return id;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const instanceCount = () =>
|
|
70
|
+
(db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n;
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-instances-'));
|
|
74
|
+
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
75
|
+
for (const id of ['byoi', 'forgejo']) addModule(id);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
db.$client.close();
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('identity is the triple, not the key alone', () => {
|
|
84
|
+
const columns = db.$client
|
|
85
|
+
.query("PRAGMA index_info('module_instances_identity_idx')")
|
|
86
|
+
.all() as Array<{ name: string }>;
|
|
87
|
+
expect(columns.map((c) => c.name)).toEqual(['parent_id', 'submodule', 'instance_key']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('two parents may use the same instance key', () => {
|
|
91
|
+
addInstance('byoi', 'lab', 'shared');
|
|
92
|
+
expect(() => addInstance('forgejo', 'lab', 'shared')).not.toThrow();
|
|
93
|
+
expect(instanceCount()).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('one parent cannot reuse a key for the same submodule', () => {
|
|
97
|
+
addInstance('byoi', 'lab', 'dup');
|
|
98
|
+
expect(() =>
|
|
99
|
+
db.$client.run(
|
|
100
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
101
|
+
['byoi-lab-other', 'byoi', 'lab', 'dup'],
|
|
102
|
+
),
|
|
103
|
+
).toThrow(/UNIQUE constraint failed/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// A row must never outlive the parent it names.
|
|
107
|
+
test('removing a parent removes its instance rows and leaves other parents alone', () => {
|
|
108
|
+
addInstance('byoi', 'lab', 'a');
|
|
109
|
+
addInstance('forgejo', 'runner', 'b');
|
|
110
|
+
expect(instanceCount()).toBe(2);
|
|
111
|
+
|
|
112
|
+
db.$client.run("DELETE FROM modules WHERE id='byoi'");
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
db.$client.query('SELECT parent_id FROM module_instances').all() as Array<{
|
|
116
|
+
parent_id: string;
|
|
117
|
+
}>,
|
|
118
|
+
).toEqual([{ parent_id: 'forgejo' }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('an instance defaults to pending with no failure recorded', () => {
|
|
122
|
+
addInstance('byoi', 'lab', 'fresh');
|
|
123
|
+
const row = db.$client
|
|
124
|
+
.query('SELECT state, failure_reason, retryable FROM module_instances')
|
|
125
|
+
.get() as { state: string; failure_reason: string | null; retryable: number | null };
|
|
126
|
+
expect(row.state).toBe('pending');
|
|
127
|
+
expect(row.failure_reason).toBeNull();
|
|
128
|
+
expect(row.retryable).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('ownedSystemModuleIds', () => {
|
|
133
|
+
let root: string;
|
|
134
|
+
let db: DbClient;
|
|
135
|
+
|
|
136
|
+
const addModule = (id: string) =>
|
|
137
|
+
db.$client.run(
|
|
138
|
+
"INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES (?,?,'1.0.0','{}','/tmp/'||?)",
|
|
139
|
+
[id, id, id],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const addInstance = (parent: string, submodule: string, key: string) => {
|
|
143
|
+
const id = deriveInstanceModuleId(parent, submodule, key);
|
|
144
|
+
addModule(id);
|
|
145
|
+
db.$client.run(
|
|
146
|
+
'INSERT INTO module_instances (module_id,parent_id,submodule,instance_key) VALUES (?,?,?,?)',
|
|
147
|
+
[id, parent, submodule, key],
|
|
148
|
+
);
|
|
149
|
+
return id;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-owned-'));
|
|
154
|
+
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
155
|
+
for (const id of ['forgejo', 'caddy']) addModule(id);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterEach(async () => {
|
|
159
|
+
db.$client.close();
|
|
160
|
+
await rm(root, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The state of every fleet until submodules ship, and the reason this is
|
|
164
|
+
// correct on its own rather than merely inert: an empty table gives every
|
|
165
|
+
// module itself, so a caller reaches only what it provisioned.
|
|
166
|
+
test('with no instances in existence, a module owns exactly itself', () => {
|
|
167
|
+
expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
|
|
168
|
+
expect(ownedSystemModuleIds('forgejo', db)).toEqual(['forgejo']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a parent gets itself and every instance it owns', () => {
|
|
172
|
+
const a = addInstance('forgejo', 'runner', 'proj-1');
|
|
173
|
+
const b = addInstance('forgejo', 'runner', 'proj-2');
|
|
174
|
+
|
|
175
|
+
const owned = ownedSystemModuleIds('forgejo', db);
|
|
176
|
+
|
|
177
|
+
expect(owned).toContain('forgejo');
|
|
178
|
+
expect(owned).toContain(a);
|
|
179
|
+
expect(owned).toContain(b);
|
|
180
|
+
expect(owned).toHaveLength(3);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a parent does not reach another parent's instances", () => {
|
|
184
|
+
addInstance('forgejo', 'runner', 'proj-1');
|
|
185
|
+
expect(ownedSystemModuleIds('caddy', db)).toEqual(['caddy']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Ownership is one level deep by construction, so the answer terminates
|
|
189
|
+
// without anyone reasoning about depth.
|
|
190
|
+
test('an instance owns nobody, so the answer is one level and terminates', () => {
|
|
191
|
+
const instance = addInstance('forgejo', 'runner', 'proj-1');
|
|
192
|
+
expect(ownedSystemModuleIds(instance, db)).toEqual([instance]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a module that does not exist owns only the name it was asked about', () => {
|
|
196
|
+
expect(ownedSystemModuleIds('never-installed', db)).toEqual(['never-installed']);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance identity (openspec/changes/submodules, D2 and D4).
|
|
3
|
+
*
|
|
4
|
+
* A parent module addresses the instances it owns by an opaque key it chose.
|
|
5
|
+
* celilo addresses them by a `modules.id`, because an instance IS a module row,
|
|
6
|
+
* and that is what lets every reader keyed on `moduleId` keep working. This
|
|
7
|
+
* file is the single place those two names meet, so the mapping cannot drift.
|
|
8
|
+
*
|
|
9
|
+
* The rest of the submodules mechanism (the symlink farm an instance's install
|
|
10
|
+
* is, the deploy-worker capability, the lifecycle) is not here. This is the
|
|
11
|
+
* identity and the ownership question, landed ahead of it because
|
|
12
|
+
* `hook-process-boundary` stage 3 needs `ownedSystemModuleIds` and nothing else
|
|
13
|
+
* from that change.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { eq } from 'drizzle-orm';
|
|
18
|
+
import type { DbClient } from '../db/client';
|
|
19
|
+
import { moduleInstances } from '../db/schema';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bits of hash in a derived instance id, as hex characters.
|
|
23
|
+
*
|
|
24
|
+
* 12 hex characters is 48 bits, so a birthday collision needs on the order of
|
|
25
|
+
* 16 million instances of ONE submodule under ONE parent. A fleet runs tens.
|
|
26
|
+
* Short enough to read in `celilo module list`, long enough that nobody has to
|
|
27
|
+
* think about it.
|
|
28
|
+
*/
|
|
29
|
+
const INSTANCE_ID_HASH_CHARS = 12;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `modules.id` an instance runs as.
|
|
33
|
+
*
|
|
34
|
+
* Derived rather than supplied, for two reasons. The parent's key is opaque and
|
|
35
|
+
* may be anything (D2 forbids celilo interpreting it), while `modules.id` must
|
|
36
|
+
* be kebab-case. And identity is the TRIPLE of parent, submodule and key, so
|
|
37
|
+
* the first two have to be inside the hash, or two parents using the same key
|
|
38
|
+
* would collide.
|
|
39
|
+
*
|
|
40
|
+
* Deterministic: the same triple always yields the same id, which is what makes
|
|
41
|
+
* an instantiate safe to retry.
|
|
42
|
+
*/
|
|
43
|
+
export function deriveInstanceModuleId(
|
|
44
|
+
parentId: string,
|
|
45
|
+
submodule: string,
|
|
46
|
+
instanceKey: string,
|
|
47
|
+
): string {
|
|
48
|
+
// The separator matters. Hashing the concatenation alone would let
|
|
49
|
+
// ("a", "b-c") and ("a-b", "c") collide, which is the kind of ambiguity that
|
|
50
|
+
// never shows up until it does.
|
|
51
|
+
const digest = createHash('sha256')
|
|
52
|
+
.update(`${parentId} ${submodule} ${instanceKey}`)
|
|
53
|
+
.digest('hex')
|
|
54
|
+
.slice(0, INSTANCE_ID_HASH_CHARS);
|
|
55
|
+
|
|
56
|
+
return `${parentId}-${submodule}-${digest}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every module id whose systems `moduleId` transitively owns: itself, plus each
|
|
61
|
+
* of its instances.
|
|
62
|
+
*
|
|
63
|
+
* This is the allow-list behind D12 of `openspec/changes/hook-process-boundary`
|
|
64
|
+
* ("a module may reach the systems it provisioned, or that its submodules
|
|
65
|
+
* provisioned"). Feed it to `getModuleSystems` per id, or use it directly as an
|
|
66
|
+
* `IN (...)` set.
|
|
67
|
+
*
|
|
68
|
+
* NOT RECURSIVE, and that is a property of the model rather than a shortcut.
|
|
69
|
+
* Nesting is refused when a parent is imported: a submodule may not declare
|
|
70
|
+
* submodules of its own, so ownership is exactly one level deep by
|
|
71
|
+
* construction. A parent owns instances, and an instance owns nothing. So this
|
|
72
|
+
* is one indexed lookup on `module_instances_parent_idx` rather than a walk,
|
|
73
|
+
* which is what makes it cheap enough to run at hook-invocation time.
|
|
74
|
+
*
|
|
75
|
+
* A recursive walk that happens to terminate and one that cannot recurse are
|
|
76
|
+
* different things, and only the second is safe to call per invocation without
|
|
77
|
+
* reasoning about depth.
|
|
78
|
+
*
|
|
79
|
+
* An instance asking gets only itself, which is correct: an instance provisions
|
|
80
|
+
* its own systems and owns nobody else's.
|
|
81
|
+
*
|
|
82
|
+
* Correct with no instances in existence, which is the state of every fleet
|
|
83
|
+
* until submodules ship: the table is empty, so every module gets `[itself]`,
|
|
84
|
+
* and a caller reaches only the systems it provisioned. That is the answer the
|
|
85
|
+
* allow-list wants today, and it picks up submodule behaviour later with no
|
|
86
|
+
* second edit.
|
|
87
|
+
*/
|
|
88
|
+
export function ownedSystemModuleIds(moduleId: string, db: DbClient): string[] {
|
|
89
|
+
const instances = db
|
|
90
|
+
.select({ moduleId: moduleInstances.moduleId })
|
|
91
|
+
.from(moduleInstances)
|
|
92
|
+
.where(eq(moduleInstances.parentId, moduleId))
|
|
93
|
+
.all();
|
|
94
|
+
|
|
95
|
+
return [moduleId, ...instances.map((row) => row.moduleId)];
|
|
96
|
+
}
|
|
@@ -20,7 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { type RunResult, type Runner, createMockRunner } from '@celilo/capabilities';
|
|
21
21
|
import type { DbClient } from '../db/client';
|
|
22
22
|
import { moduleSystems, modules } from '../db/schema';
|
|
23
|
-
import {
|
|
23
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
24
24
|
import { planJournalRead, readModuleJournal } from './module-journal';
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -64,7 +64,7 @@ let db: DbClient;
|
|
|
64
64
|
|
|
65
65
|
beforeEach(async () => {
|
|
66
66
|
dir = mkdtempSync(join(tmpdir(), 'module-journal-'));
|
|
67
|
-
db = await
|
|
67
|
+
db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
afterEach(() => {
|
|
@@ -9,7 +9,7 @@ import { closeDb, getDb } from '../db/client';
|
|
|
9
9
|
import { ModuleManifestSchema } from '../manifest/schema';
|
|
10
10
|
import { ModuleSubscriptionSchema } from '../manifest/schema';
|
|
11
11
|
import type { ModuleManifest } from '../manifest/schema';
|
|
12
|
-
import {
|
|
12
|
+
import { setupTestDatabaseAt as migrateDbFile } from '../test-utils/database';
|
|
13
13
|
import {
|
|
14
14
|
registerModuleSubscriptions,
|
|
15
15
|
resolveSubscription,
|
|
@@ -6,7 +6,7 @@ import type { PortForwardStore } from '@celilo/capabilities';
|
|
|
6
6
|
import { eq } from 'drizzle-orm';
|
|
7
7
|
import type { DbClient } from '../db/client';
|
|
8
8
|
import { portForwards } from '../db/schema';
|
|
9
|
-
import {
|
|
9
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
10
10
|
import { deleteClaimedRows } from './capability-table-rows';
|
|
11
11
|
import { buildPortForwardStore } from './port-forwards';
|
|
12
12
|
|
|
@@ -22,7 +22,7 @@ describe('port-forward store', () => {
|
|
|
22
22
|
dir = mkdtempSync(join(tmpdir(), 'pf-'));
|
|
23
23
|
const dbPath = join(dir, 'celilo.db');
|
|
24
24
|
process.env.CELILO_DB_PATH = dbPath;
|
|
25
|
-
db = await
|
|
25
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
26
26
|
store = buildPortForwardStore(db, 'caddy');
|
|
27
27
|
});
|
|
28
28
|
afterEach(() => {
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import type { DbClient } from '../db/client';
|
|
6
6
|
import { systemConfig } from '../db/schema';
|
|
7
7
|
import { loadTrustedSubnets } from '../hooks/capability-loader';
|
|
8
|
-
import {
|
|
8
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
9
9
|
import {
|
|
10
10
|
buildTrustedSourceStore,
|
|
11
11
|
composeTrustedSubnets,
|
|
@@ -26,7 +26,7 @@ describe('trusted-source store', () => {
|
|
|
26
26
|
dir = mkdtempSync(join(tmpdir(), 'ts-'));
|
|
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
|
afterEach(() => {
|
|
32
32
|
db.$client.close();
|
|
@@ -138,7 +138,7 @@ describe('the render input excludes registrations; the reporting view includes t
|
|
|
138
138
|
dir = mkdtempSync(join(tmpdir(), 'ts-render-'));
|
|
139
139
|
const dbPath = join(dir, 'celilo.db');
|
|
140
140
|
process.env.CELILO_DB_PATH = dbPath;
|
|
141
|
-
db = await
|
|
141
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
142
142
|
db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
|
|
143
143
|
buildTrustedSourceStore(db, 'wireguard').replace(FW, {
|
|
144
144
|
subnets: [VPN],
|
|
@@ -58,8 +58,30 @@ const removeModule = async (moduleId: string): Promise<void> => {
|
|
|
58
58
|
db.$client.prepare('DELETE FROM module_configs WHERE module_id = ?').run(moduleId);
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every module these tests name, as a real row. `module_configs` carries a
|
|
63
|
+
* foreign key onto `modules`, so without the parent the config write
|
|
64
|
+
* `ensureIngressIps` performs is rejected and the function reports failure —
|
|
65
|
+
* which is what happens in production too, and never happened here while the
|
|
66
|
+
* test helper ran with foreign keys off (celilo#1074).
|
|
67
|
+
*
|
|
68
|
+
* Worth knowing WHICH tests that would have broken. Only some: "two modules get
|
|
69
|
+
* two different addresses" fails loudly, but "REUSES the same address on a
|
|
70
|
+
* second generate" compares one unwritten value to another and passes. So a
|
|
71
|
+
* repair that chased the red would have left this file's central guard asserting
|
|
72
|
+
* `undefined === undefined`.
|
|
73
|
+
*/
|
|
74
|
+
const NAMED_MODULES = ['technitium', 'knot-unbound-internal', 'caddy', 'caddy-internal'];
|
|
75
|
+
|
|
61
76
|
beforeEach(async () => {
|
|
62
77
|
db = await setupTestDatabase();
|
|
78
|
+
for (const id of NAMED_MODULES) {
|
|
79
|
+
db.$client
|
|
80
|
+
.prepare(
|
|
81
|
+
'INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, ?, ?, ?)',
|
|
82
|
+
)
|
|
83
|
+
.run(id, id, '1.0.0', `/test/${id}`, '{}');
|
|
84
|
+
}
|
|
63
85
|
db.$client
|
|
64
86
|
.prepare('INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)')
|
|
65
87
|
.run('network.internal.subnet', '10.226.1.0/24');
|
|
@@ -78,6 +100,13 @@ describe('ensureIngressIps', () => {
|
|
|
78
100
|
// every time the module is regenerated, and nothing reports a problem.
|
|
79
101
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
80
102
|
const first = storedIp('technitium');
|
|
103
|
+
// Assert the read SUCCEEDED before comparing two of them. Without this the
|
|
104
|
+
// test passes whenever BOTH reads fail the same way, which is exactly what
|
|
105
|
+
// happened when foreign keys were switched on and the config write this
|
|
106
|
+
// depends on started being rejected (celilo#1074): `undefined` equals
|
|
107
|
+
// `undefined`, and the file's central guard stayed green while proving
|
|
108
|
+
// nothing.
|
|
109
|
+
expect(first).toBeDefined();
|
|
81
110
|
|
|
82
111
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
83
112
|
const second = storedIp('technitium');
|
|
@@ -88,6 +117,7 @@ describe('ensureIngressIps', () => {
|
|
|
88
117
|
test('stays stable across many generates, not just two', async () => {
|
|
89
118
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
90
119
|
const first = storedIp('technitium');
|
|
120
|
+
expect(first).toBeDefined();
|
|
91
121
|
|
|
92
122
|
for (let i = 0; i < 5; i++) {
|
|
93
123
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
@@ -179,6 +209,7 @@ describe('releasing ingress IPs on module removal', () => {
|
|
|
179
209
|
// one address per install/remove cycle until it runs out.
|
|
180
210
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
181
211
|
const first = storedIp('technitium');
|
|
212
|
+
expect(first).toBeDefined();
|
|
182
213
|
|
|
183
214
|
await removeModule('technitium');
|
|
184
215
|
await ensureIngressIps('technitium', wantsIngress, db);
|
|
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
13
13
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
14
|
-
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
14
|
+
import { type DbClient, createDbClient, findMigrationsFolder } from '../db/client';
|
|
15
15
|
import * as schema from '../db/schema';
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -31,6 +31,10 @@ import * as schema from '../db/schema';
|
|
|
31
31
|
*/
|
|
32
32
|
export async function setupTestDatabase(): Promise<DbClient> {
|
|
33
33
|
const sqlite = new Database(':memory:');
|
|
34
|
+
// Matches `createDbClient` (db/client.ts). SQLite defaults this OFF per
|
|
35
|
+
// connection, so without it every `onDelete: 'cascade'` in the schema is
|
|
36
|
+
// enforced in production and inert in the suite (celilo#1074).
|
|
37
|
+
sqlite.run('PRAGMA foreign_keys = ON');
|
|
34
38
|
const db = drizzle(sqlite, { schema });
|
|
35
39
|
const migrationsFolder = findMigrationsFolder();
|
|
36
40
|
await migrate(db, { migrationsFolder });
|
|
@@ -60,6 +64,8 @@ export async function setupTestDatabaseFile(): Promise<{
|
|
|
60
64
|
const tempDir = await mkdtemp(join(tmpdir(), 'celilo-test-'));
|
|
61
65
|
const dbPath = join(tempDir, 'test.db');
|
|
62
66
|
const sqlite = new Database(dbPath);
|
|
67
|
+
// See setupTestDatabase — celilo#1074.
|
|
68
|
+
sqlite.run('PRAGMA foreign_keys = ON');
|
|
63
69
|
const db = drizzle(sqlite, { schema });
|
|
64
70
|
const migrationsFolder = findMigrationsFolder();
|
|
65
71
|
await migrate(db, { migrationsFolder });
|
|
@@ -72,6 +78,30 @@ export async function setupTestDatabaseFile(): Promise<{
|
|
|
72
78
|
return { db, path: dbPath, cleanup };
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Create a test database at a path the CALLER chooses.
|
|
83
|
+
*
|
|
84
|
+
* The other two helpers pick the location — memory, or a temp directory they
|
|
85
|
+
* own. This one exists for tests that must hand the same path to something
|
|
86
|
+
* else, typically a spawned CLI through `CELILO_DB_PATH`.
|
|
87
|
+
*
|
|
88
|
+
* It was a second module, `test-utils/setup-test-db.ts`, exporting a function
|
|
89
|
+
* ALSO called `setupTestDatabase` and another also called
|
|
90
|
+
* `cleanupTestDatabase`, with different arities and — until celilo#1074 —
|
|
91
|
+
* opposite foreign-key semantics. Which contract a test was under depended
|
|
92
|
+
* entirely on which file its import line named, and nothing at the call site
|
|
93
|
+
* showed it. One caller had already aliased the import to `migrateDbFile` to
|
|
94
|
+
* make it readable. The two colliding names are gone rather than renamed: there
|
|
95
|
+
* is no longer a wrong one to pick.
|
|
96
|
+
*
|
|
97
|
+
* @param testDbPath - Where to create the database file
|
|
98
|
+
*/
|
|
99
|
+
export async function setupTestDatabaseAt(testDbPath: string): Promise<DbClient> {
|
|
100
|
+
const db = createDbClient({ path: testDbPath });
|
|
101
|
+
await migrate(db, { migrationsFolder: findMigrationsFolder() });
|
|
102
|
+
return db;
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
/**
|
|
76
106
|
* Clean up test database
|
|
77
107
|
*
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { unlink } from 'node:fs/promises';
|
|
3
|
-
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
4
|
-
import { type DbClient, createDbClient, findMigrationsFolder } from '../db/client';
|
|
5
|
-
|
|
6
|
-
// `findMigrationsFolder` is imported from db/client rather than redefined here.
|
|
7
|
-
// This file used to carry its own copy, and the copy had drifted: it listed
|
|
8
|
-
// only CWD-RELATIVE candidates (`./drizzle`, `process.cwd()/drizzle`, …) and was
|
|
9
|
-
// missing the `join(currentDir, '../../drizzle')` fallback that resolves
|
|
10
|
-
// relative to the source file.
|
|
11
|
-
//
|
|
12
|
-
// The effect was invisible from `apps/celilo` and total from the repo root —
|
|
13
|
-
// which is where CLAUDE.md says to run the gate. Every suite using
|
|
14
|
-
// setupTestDatabase threw here, left `db` unassigned, and then failed again in
|
|
15
|
-
// its own afterEach on `db.$client`, so one missing path candidate presented as
|
|
16
|
-
// hundreds of unrelated-looking assertion failures (celilo: 668 across the
|
|
17
|
-
// repo, 341 in apps/celilo/src alone).
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Setup test database with real migrations
|
|
21
|
-
*
|
|
22
|
-
* This helper ensures tests use the same schema as production by running
|
|
23
|
-
* actual migrations instead of manual CREATE TABLE statements.
|
|
24
|
-
*
|
|
25
|
-
* Benefits:
|
|
26
|
-
* - Single source of truth for schema
|
|
27
|
-
* - Tests automatically use latest schema
|
|
28
|
-
* - Validates migration correctness
|
|
29
|
-
*
|
|
30
|
-
* @param testDbPath - Path to test database file
|
|
31
|
-
* @returns Database client instance
|
|
32
|
-
*/
|
|
33
|
-
export async function setupTestDatabase(testDbPath: string) {
|
|
34
|
-
// Create database client
|
|
35
|
-
const db = createDbClient({ path: testDbPath });
|
|
36
|
-
|
|
37
|
-
// Find and run migrations
|
|
38
|
-
const migrationsFolder = findMigrationsFolder();
|
|
39
|
-
await migrate(db, { migrationsFolder });
|
|
40
|
-
|
|
41
|
-
return db;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Cleanup test database
|
|
46
|
-
*
|
|
47
|
-
* @param db - Database client to close
|
|
48
|
-
* @param testDbPath - Path to test database file to delete
|
|
49
|
-
*/
|
|
50
|
-
export async function cleanupTestDatabase(db: DbClient, testDbPath: string) {
|
|
51
|
-
// Close database connection
|
|
52
|
-
db.$client.close();
|
|
53
|
-
|
|
54
|
-
// Delete test database file
|
|
55
|
-
if (existsSync(testDbPath)) {
|
|
56
|
-
await unlink(testDbPath);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Also delete WAL and SHM files if they exist
|
|
60
|
-
const walPath = `${testDbPath}-wal`;
|
|
61
|
-
const shmPath = `${testDbPath}-shm`;
|
|
62
|
-
|
|
63
|
-
if (existsSync(walPath)) {
|
|
64
|
-
await unlink(walPath);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (existsSync(shmPath)) {
|
|
68
|
-
await unlink(shmPath);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Generate unique test database path
|
|
74
|
-
*
|
|
75
|
-
* @param prefix - Optional prefix for the database file (defaults to 'test-celilo')
|
|
76
|
-
* @returns Unique database file path
|
|
77
|
-
*/
|
|
78
|
-
export function generateTestDbPath(prefix = 'test-celilo'): string {
|
|
79
|
-
return `./${prefix}-${Date.now()}-${Math.random()}.db`;
|
|
80
|
-
}
|