@celilo/cli 1.7.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 (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. 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,
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { describe, expect, test } from 'bun:test';
8
+ import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
8
9
  import { validateCapabilityVersions } from './capability-versions';
9
10
 
10
11
  describe('validateCapabilityVersions', () => {
@@ -31,7 +32,11 @@ describe('validateCapabilityVersions', () => {
31
32
  expect(errors).toHaveLength(1);
32
33
  expect(errors[0]).toContain('provides[public_web]');
33
34
  expect(errors[0]).toContain('1.0.0');
34
- expect(errors[0]).toContain('3.1.0');
35
+ // Read from the registry, not pinned to today's number. The assertion is
36
+ // "the message names the RUNTIME version so the author knows what to move
37
+ // to"; a literal here turns a behaviour test into a version tracker that
38
+ // goes red on every legitimate bump, which is what happened at 3.1.0 → 3.2.0.
39
+ expect(errors[0]).toContain(CAPABILITY_CONTRACT_VERSIONS.public_web);
35
40
  });
36
41
 
37
42
  test('error when provides[X].version is newer than runtime', () => {
@@ -6,8 +6,9 @@ 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';
10
- import { buildPortForwardStore, deletePortForwardsForModule } from './port-forwards';
9
+ import { setupTestDatabaseAt } from '../test-utils/database';
10
+ import { deleteClaimedRows } from './capability-table-rows';
11
+ import { buildPortForwardStore } from './port-forwards';
11
12
 
12
13
  const FW = '192.168.0.254';
13
14
  const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' };
@@ -21,7 +22,7 @@ describe('port-forward store', () => {
21
22
  dir = mkdtempSync(join(tmpdir(), 'pf-'));
22
23
  const dbPath = join(dir, 'celilo.db');
23
24
  process.env.CELILO_DB_PATH = dbPath;
24
- db = await setupTestDatabase(dbPath);
25
+ db = await setupTestDatabaseAt(dbPath);
25
26
  store = buildPortForwardStore(db, 'caddy');
26
27
  });
27
28
  afterEach(() => {
@@ -113,7 +114,10 @@ describe('port-forward store', () => {
113
114
  other.replace(FW, { ...CADDY, description: 'also 443' }, [443]);
114
115
  expect(store.list(FW)).toHaveLength(2);
115
116
 
116
- deletePortForwardsForModule(db, 'caddy');
117
+ // Through the GENERIC declaration-driven path, which replaced
118
+ // `deletePortForwardsForModule`. The property is unchanged; what changed is
119
+ // that core no longer names this capability's table to clear it.
120
+ deleteClaimedRows(db, 'caddy');
117
121
 
118
122
  const left = store.list(FW);
119
123
  expect(left).toHaveLength(1);
@@ -99,14 +99,3 @@ export function buildPortForwardStore(db: DbClient, registeredBy: string): PortF
99
99
  },
100
100
  };
101
101
  }
102
-
103
- /**
104
- * Drop every forward a departing module owns, across every firewall.
105
- *
106
- * `registered_by` is plain text rather than a FK, so unlike `web_routes` these
107
- * rows do NOT die with the `modules` row — core deletes them explicitly, AFTER
108
- * the provider has converged without them (D4).
109
- */
110
- export function deletePortForwardsForModule(db: DbClient, moduleId: string): void {
111
- db.delete(portForwards).where(eq(portForwards.registeredBy, moduleId)).run();
112
- }
@@ -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],
@@ -68,11 +68,6 @@ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): Tru
68
68
  };
69
69
  }
70
70
 
71
- /** Drop every trusted source a departing module owns, across every firewall. */
72
- export function deleteTrustedSourcesForModule(db: DbClient, moduleId: string): void {
73
- db.delete(trustedSources).where(eq(trustedSources.registeredBy, moduleId)).run();
74
- }
75
-
76
71
  /** Where a trusted subnet came from — reach into every tier must be attributable. */
77
72
  export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override';
78
73
 
@@ -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
  *
@@ -593,16 +593,81 @@ async function assembleResolutionContext(
593
593
  }
594
594
  }
595
595
  }
596
- const dnsInternal = capabilitiesMap.dns_internal as { server?: { ip?: unknown } } | undefined;
597
- // The dns_internal capability advertises the provider's address, which may
598
- // carry a CIDR suffix (technitium's server.ip resolves from target_ip,
599
- // e.g. "192.168.0.151/24"). A nameserver must be a bare IP — strip it.
600
- const rawInternalIp =
601
- typeof dnsInternal?.server?.ip === 'string' ? dnsInternal.server.ip : undefined;
602
- const internalIp = rawInternalIp ? rawInternalIp.split('/')[0] : undefined;
603
- const nameservers = internalIp ? [internalIp, ...publicDns] : publicDns;
604
- if (nameservers.length > 0) {
605
- selfConfig.lxc_nameserver = nameservers.join(' ');
596
+ type ResolverEndpoint = {
597
+ server?: { ip?: unknown; internal_ip?: unknown };
598
+ /** Zones the provider's base-module aspect owns ongoing DNS for (D5d). */
599
+ aspect?: { covered_zones?: unknown };
600
+ };
601
+ const dnsInternal = capabilitiesMap.dns_internal as ResolverEndpoint | undefined;
602
+ const dnsSecondary = capabilitiesMap.dns_internal_secondary as ResolverEndpoint | undefined;
603
+
604
+ // A nameserver must be a bare IP. The advertised address may carry a CIDR
605
+ // suffix (technitium's server.ip resolves from target_ip, e.g.
606
+ // "192.168.0.151/24"), so strip it.
607
+ const bareIp = (value: unknown): string | undefined =>
608
+ typeof value === 'string' && value.length > 0 ? value.split('/')[0] : undefined;
609
+
610
+ const targetZone = module?.manifestData
611
+ ? getSingularSystemSpec(module.manifestData as ModuleManifest)?.zone
612
+ : undefined;
613
+
614
+ // `internal` cannot route into the resolvers' own (dmz) subnet, so it uses
615
+ // the ingress addresses the firewall DNATs. Every other zone reaches them
616
+ // directly.
617
+ const endpointFor = (resolver: ResolverEndpoint | undefined): string | undefined =>
618
+ targetZone === 'internal'
619
+ ? (bareIp(resolver?.server?.internal_ip) ?? bareIp(resolver?.server?.ip))
620
+ : bareIp(resolver?.server?.ip);
621
+
622
+ const primaryIp = endpointFor(dnsInternal);
623
+ const secondaryIp = endpointFor(dnsSecondary);
624
+
625
+ // Whether the DEPLOYED provider's base-module aspect covers this system's
626
+ // zone, which is what decides if the public entries may go (design D5d).
627
+ //
628
+ // The predicate is aspect COVERAGE, not routing. Routing is why a zone goes
629
+ // uncovered — `external` and the planned `quarantine` hold systems that
630
+ // cannot reach a resolver inside the perimeter. Coverage is why the birth
631
+ // list is PERMANENT: terraform injects
632
+ // `lifecycle { ignore_changes = [nameserver] }`, so it cannot correct the
633
+ // value afterwards even in principle, and a zone no aspect covers has no
634
+ // owner for ongoing DNS at all. Keying on coverage therefore handles a
635
+ // future excluded zone with no change here, and fails safe on an accidental
636
+ // omission — where keeping the public entries is the lesser harm until the
637
+ // array is fixed.
638
+ // The provider DECLARES its coverage in the capability data. Core could
639
+ // instead find the provider's module row and read
640
+ // `base_module_aspect.applicable_zones` off its manifest, which would be
641
+ // one source of truth rather than two — but that means core naming a
642
+ // capability in order to find its provider, which is the pattern the
643
+ // module-business gate exists to stop, and its advice is exactly this: let
644
+ // the provider declare the behaviour. A manifest test holds the declared
645
+ // list and the aspect's own `applicable_zones` together so they cannot
646
+ // drift.
647
+ const aspectZones = dnsInternal?.aspect?.covered_zones;
648
+ const zoneIsAspectCovered =
649
+ targetZone !== undefined && Array.isArray(aspectZones) && aspectZones.includes(targetZone);
650
+
651
+ // Both halves are required. A pair with no aspect covering this zone is the
652
+ // `external` case: two addresses it cannot route to and nothing else, which
653
+ // does not tighten anything, it takes DNS away. A primary with no secondary
654
+ // is D5a: removing the fallback and shipping a secondary are ONE decision,
655
+ // because otherwise every resolver redeploy blanks fleet DNS.
656
+ const dropPublicResolvers =
657
+ primaryIp !== undefined && secondaryIp !== undefined && zoneIsAspectCovered;
658
+
659
+ // The uncovered branch is main's composition unchanged: the primary, then
660
+ // the public resolvers. The secondary is deliberately NOT added to it — in
661
+ // a zone that cannot route to the resolvers, a second unreachable address
662
+ // buys nothing but another timeout before the public entries answer.
663
+ const nameservers = dropPublicResolvers
664
+ ? [primaryIp, secondaryIp]
665
+ : primaryIp
666
+ ? [primaryIp, ...publicDns]
667
+ : publicDns;
668
+ const unique = [...new Set(nameservers)];
669
+ if (unique.length > 0) {
670
+ selfConfig.lxc_nameserver = unique.join(' ');
606
671
  }
607
672
  }
608
673