@celilo/cli 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CELILO_CORE_MODULES.md +2 -0
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/drizzle/0028_capability_bindings.sql +26 -0
  4. package/drizzle/0029_module_instances.sql +58 -0
  5. package/drizzle/meta/_journal.json +14 -0
  6. package/package.json +2 -2
  7. package/src/cli/commands/module-show.ts +1 -0
  8. package/src/db/foreign-keys.test.ts +101 -0
  9. package/src/db/schema.ts +161 -5
  10. package/src/hooks/broker.test.ts +152 -0
  11. package/src/hooks/broker.ts +307 -0
  12. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  13. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  14. package/src/hooks/capability-loader.test.ts +10 -2
  15. package/src/hooks/capability-loader.ts +59 -2
  16. package/src/hooks/executor.ts +234 -111
  17. package/src/hooks/hook-protocol.test.ts +192 -0
  18. package/src/hooks/hook-protocol.ts +275 -0
  19. package/src/hooks/hook-runner.ts +231 -0
  20. package/src/hooks/hook-timeout.test.ts +103 -0
  21. package/src/hooks/hook-trespass.test.ts +201 -0
  22. package/src/hooks/injected-capabilities.test.ts +75 -0
  23. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  24. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  25. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  26. package/src/manifest/validate-provider-views.test.ts +61 -0
  27. package/src/manifest/validate.ts +21 -14
  28. package/src/module/packaging/module-state-directory.test.ts +99 -0
  29. package/src/module/packaging/package-rules.ts +10 -2
  30. package/src/policy/capability-shape-baseline.ts +8 -0
  31. package/src/policy/capability-shape.ts +13 -1
  32. package/src/policy/module-business-baseline.ts +36 -0
  33. package/src/services/alerting/ack.test.ts +2 -2
  34. package/src/services/alerting/deferral.test.ts +2 -2
  35. package/src/services/alerting/delivery-loop.test.ts +2 -2
  36. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  37. package/src/services/alerting/inbound-poller.test.ts +2 -2
  38. package/src/services/alerting/inbound.test.ts +2 -2
  39. package/src/services/alerting/notification-responder.test.ts +2 -2
  40. package/src/services/alerting/run-monitor.test.ts +2 -2
  41. package/src/services/alerting/store.test.ts +2 -2
  42. package/src/services/alerting/sweep-runner.test.ts +2 -2
  43. package/src/services/alerting/tokens.test.ts +2 -2
  44. package/src/services/capability-bindings.test.ts +104 -0
  45. package/src/services/capability-bindings.ts +107 -0
  46. package/src/services/capability-table-rows.test.ts +2 -2
  47. package/src/services/consumer-cleanup.test.ts +40 -3
  48. package/src/services/dns-internal-records.test.ts +3 -3
  49. package/src/services/fleet-checks.test.ts +4 -4
  50. package/src/services/module-instances.test.ts +198 -0
  51. package/src/services/module-instances.ts +96 -0
  52. package/src/services/module-journal.test.ts +2 -2
  53. package/src/services/module-subscriptions.test.ts +1 -1
  54. package/src/services/port-forwards.test.ts +2 -2
  55. package/src/services/trusted-sources.test.ts +3 -3
  56. package/src/templates/ingress-ip.test.ts +31 -0
  57. package/src/test-utils/database.ts +31 -1
  58. package/src/test-utils/setup-test-db.ts +0 -80
@@ -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 { setupTestDatabase } from '../test-utils/setup-test-db';
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 setupTestDatabase(join(dir, 'celilo.db'));
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 { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
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 { setupTestDatabase } from '../test-utils/setup-test-db';
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 setupTestDatabase(dbPath);
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 { setupTestDatabase } from '../test-utils/setup-test-db';
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 setupTestDatabase(dbPath);
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 setupTestDatabase(dbPath);
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
- }