@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.
Files changed (66) 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 +153 -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/define-hook.test.ts +1 -0
  17. package/src/hooks/executor.test.ts +7 -0
  18. package/src/hooks/executor.ts +245 -111
  19. package/src/hooks/hook-protocol.test.ts +192 -0
  20. package/src/hooks/hook-protocol.ts +275 -0
  21. package/src/hooks/hook-runner.ts +231 -0
  22. package/src/hooks/hook-state-dir.test.ts +109 -0
  23. package/src/hooks/hook-timeout.test.ts +104 -0
  24. package/src/hooks/hook-trespass.test.ts +202 -0
  25. package/src/hooks/injected-capabilities.test.ts +75 -0
  26. package/src/hooks/mount-set.test.ts +148 -0
  27. package/src/hooks/mount-set.ts +234 -0
  28. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  29. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  30. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  31. package/src/manifest/contracts/v1.ts +21 -6
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/packaging/module-state-directory.test.ts +105 -0
  35. package/src/module/packaging/package-rules.ts +10 -2
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/capability-shape.ts +13 -1
  38. package/src/policy/module-business-baseline.ts +36 -0
  39. package/src/policy/module-dep-reachability.test.ts +167 -0
  40. package/src/services/alerting/ack.test.ts +2 -2
  41. package/src/services/alerting/deferral.test.ts +2 -2
  42. package/src/services/alerting/delivery-loop.test.ts +2 -2
  43. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  44. package/src/services/alerting/inbound-poller.test.ts +2 -2
  45. package/src/services/alerting/inbound.test.ts +2 -2
  46. package/src/services/alerting/notification-responder.test.ts +2 -2
  47. package/src/services/alerting/run-monitor.test.ts +2 -2
  48. package/src/services/alerting/store.test.ts +2 -2
  49. package/src/services/alerting/sweep-runner.test.ts +2 -2
  50. package/src/services/alerting/tokens.test.ts +2 -2
  51. package/src/services/capability-bindings.test.ts +104 -0
  52. package/src/services/capability-bindings.ts +107 -0
  53. package/src/services/capability-table-rows.test.ts +2 -2
  54. package/src/services/consumer-cleanup.test.ts +40 -3
  55. package/src/services/dns-internal-records.test.ts +3 -3
  56. package/src/services/fleet-checks.test.ts +4 -4
  57. package/src/services/module-instances.test.ts +198 -0
  58. package/src/services/module-instances.ts +96 -0
  59. package/src/services/module-journal.test.ts +2 -2
  60. package/src/services/module-subscriptions.test.ts +1 -1
  61. package/src/services/port-forwards.test.ts +2 -2
  62. package/src/services/trusted-sources.test.ts +3 -3
  63. package/src/templates/ingress-ip.test.ts +31 -0
  64. package/src/test-utils/database.ts +31 -1
  65. package/src/test-utils/module-fixtures.ts +147 -35
  66. package/src/test-utils/setup-test-db.ts +0 -80
@@ -19,7 +19,7 @@ import { join } from 'node:path';
19
19
  import { allDeclaredTables } from '@celilo/capabilities';
20
20
  import type { DbClient } from '../db/client';
21
21
  import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema';
22
- import { setupTestDatabase } from '../test-utils/setup-test-db';
22
+ import { setupTestDatabaseAt } from '../test-utils/database';
23
23
  import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
24
24
 
25
25
  const FW = '192.168.0.254';
@@ -56,7 +56,7 @@ describe('deleteClaimedRows', () => {
56
56
  dir = mkdtempSync(join(tmpdir(), 'claimed-'));
57
57
  const dbPath = join(dir, 'celilo.db');
58
58
  process.env.CELILO_DB_PATH = dbPath;
59
- db = await setupTestDatabase(dbPath);
59
+ db = await setupTestDatabaseAt(dbPath);
60
60
 
61
61
  for (const id of ['caddy', 'forgejo', 'technitium']) {
62
62
  db.insert(modules)
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DbClient } from '../db/client';
6
6
  import type { ModuleManifest } from '../manifest/schema';
7
- import { setupTestDatabase } from '../test-utils/setup-test-db';
7
+ import { setupTestDatabaseAt } from '../test-utils/database';
8
8
  import {
9
9
  type CleanupTarget,
10
10
  type ProviderRow,
@@ -167,6 +167,43 @@ describe('planConsumerCleanup', () => {
167
167
  });
168
168
  });
169
169
 
170
+ describe('planConsumerCleanup stays permissive after celilo#1072', () => {
171
+ it('notifies a provider the consumer has no recorded binding to', () => {
172
+ // celilo#1072 added `capability_bindings`, which records the provider a
173
+ // consumer actually CALLED. That set is precise and it is deliberately not
174
+ // this one. Cleanup must reach every provider that MIGHT hold minted state,
175
+ // including one a hook resolved, minted against, and never called again —
176
+ // and including one whose binding row was never written because the state
177
+ // was minted by an older celilo. Narrowing this to the recorded set would
178
+ // leave a site block in caddy or a DNAT rule in a ruleset with nothing left
179
+ // in the registry to reconcile it away, which is the exact failure
180
+ // `consumer-cleanup.ts` exists to prevent.
181
+ //
182
+ // The inputs here are manifest + provider rows and NOTHING ELSE. That is
183
+ // the assertion: no binding table is consulted, so no binding table can
184
+ // shrink the result.
185
+ const plan = planConsumerCleanup(
186
+ 'tango-nexus',
187
+ manifest({ optional: ['external_web', 'public_web', 'source_forge', 'registry_publish'] }),
188
+ provides(
189
+ ['cpanel-host', 'external_web'],
190
+ ['caddy', 'public_web'],
191
+ ['forgejo', 'source_forge'],
192
+ ['celilo-registry', 'registry_publish'],
193
+ ),
194
+ deployed('cpanel-host', 'caddy', 'forgejo', 'celilo-registry'),
195
+ );
196
+
197
+ expect(plan.map((t) => t.providerId)).toEqual([
198
+ 'caddy',
199
+ 'celilo-registry',
200
+ 'cpanel-host',
201
+ 'forgejo',
202
+ ]);
203
+ expect(plan.every((t) => t.skip === undefined)).toBe(true);
204
+ });
205
+ });
206
+
170
207
  describe('runConsumerCleanup — the paused skip is reported, not silent', () => {
171
208
  let dir: string;
172
209
  let db: DbClient;
@@ -175,7 +212,7 @@ describe('runConsumerCleanup — the paused skip is reported, not silent', () =>
175
212
  dir = mkdtempSync(join(tmpdir(), 'cc-'));
176
213
  const dbPath = join(dir, 'celilo.db');
177
214
  process.env.CELILO_DB_PATH = dbPath;
178
- db = await setupTestDatabase(dbPath);
215
+ db = await setupTestDatabaseAt(dbPath);
179
216
  });
180
217
  afterEach(() => {
181
218
  db.$client.close();
@@ -244,7 +281,7 @@ describe('runConsumerCleanup — dispatch', () => {
244
281
  dir = mkdtempSync(join(tmpdir(), 'ccd-'));
245
282
  const dbPath = join(dir, 'celilo.db');
246
283
  process.env.CELILO_DB_PATH = dbPath;
247
- db = await setupTestDatabase(dbPath);
284
+ db = await setupTestDatabaseAt(dbPath);
248
285
  });
249
286
  afterEach(() => {
250
287
  db.$client.close();
@@ -7,7 +7,7 @@ import type { ViewOverride } from '@celilo/capabilities';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import type { DbClient } from '../db/client';
9
9
  import { dnsInternalRecords, modules } from '../db/schema';
10
- import { setupTestDatabase } from '../test-utils/setup-test-db';
10
+ import { setupTestDatabaseAt } from '../test-utils/database';
11
11
  import {
12
12
  listDnsInternalRecords,
13
13
  listViewOverrides,
@@ -25,7 +25,7 @@ describe('dns-internal-records ledger', () => {
25
25
  dir = mkdtempSync(join(tmpdir(), 'dns-int-'));
26
26
  dbPath = join(dir, 'celilo.db');
27
27
  process.env.CELILO_DB_PATH = dbPath;
28
- db = await setupTestDatabase(dbPath);
28
+ db = await setupTestDatabaseAt(dbPath);
29
29
  // FK targets: provider + consumer modules.
30
30
  for (const id of ['technitium', 'forgejo']) {
31
31
  db.insert(modules)
@@ -222,7 +222,7 @@ describe('provider cascade (celilo#1010)', () => {
222
222
  dir = mkdtempSync(join(tmpdir(), 'dns-cascade-'));
223
223
  const dbPath = join(dir, 'celilo.db');
224
224
  process.env.CELILO_DB_PATH = dbPath;
225
- db = await setupTestDatabase(dbPath);
225
+ db = await setupTestDatabaseAt(dbPath);
226
226
  for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) {
227
227
  db.insert(modules)
228
228
  .values({
@@ -13,7 +13,7 @@ import {
13
13
  systemConfig,
14
14
  } from '../db/schema';
15
15
  import type { ModuleManifest } from '../manifest/schema';
16
- import { setupTestDatabase } from '../test-utils/setup-test-db';
16
+ import { setupTestDatabaseAt } from '../test-utils/database';
17
17
  import { ensureInboundSubscriber, ensureSweepSubscriber } from './alerting/monitors';
18
18
  import { ensureBackupSweepSubscriber } from './backup-sweep';
19
19
  import { getDaemonUnitPath } from './events-daemon';
@@ -263,7 +263,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
263
263
  busPath = join(dir, 'events.db');
264
264
  process.env.CELILO_DB_PATH = dbPath;
265
265
  process.env.EVENT_BUS_DB = busPath;
266
- db = await setupTestDatabase(dbPath);
266
+ db = await setupTestDatabaseAt(dbPath);
267
267
  bus = openBus({ dbPath: busPath, events: defineEvents({}) });
268
268
  });
269
269
  afterEach(() => {
@@ -737,7 +737,7 @@ describe('checkControlPlaneNetwork', () => {
737
737
 
738
738
  beforeEach(async () => {
739
739
  cpDir = mkdtempSync(join(tmpdir(), 'fleet-cpn-'));
740
- db = await setupTestDatabase(join(cpDir, 'celilo.db'));
740
+ db = await setupTestDatabaseAt(join(cpDir, 'celilo.db'));
741
741
  });
742
742
 
743
743
  afterEach(() => {
@@ -971,7 +971,7 @@ describe('runFleetChecks includes host liveness (#728)', () => {
971
971
  dir = mkdtempSync(join(tmpdir(), 'fleet-liveness-'));
972
972
  process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
973
973
  process.env.EVENT_BUS_DB = join(dir, 'events.db');
974
- db = await setupTestDatabase(join(dir, 'celilo.db'));
974
+ db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
975
975
  bus = openBus({ dbPath: join(dir, 'events.db'), events: defineEvents({}) });
976
976
  });
977
977
 
@@ -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
  *