@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,191 @@
1
+ /**
2
+ * The ONE consumer-removal rule, driven by the declarations
3
+ * (openspec/changes/capability-owned-tables task 2.4).
4
+ *
5
+ * Before this, "the row dies with its consumer" had five implementations for one
6
+ * rule, and two of them were functions core imported BY NAME from
7
+ * `consumer-cleanup.ts` — the file whose whole purpose is deleting exactly that
8
+ * kind of per-capability special-casing.
9
+ *
10
+ * The property worth protecting is not "port forwards get deleted". It is that
11
+ * core clears EVERY declared table without naming any of them, so declaring a
12
+ * new one is sufficient to have it cleaned up.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { allDeclaredTables } from '@celilo/capabilities';
20
+ import type { DbClient } from '../db/client';
21
+ import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema';
22
+ import { setupTestDatabaseAt } from '../test-utils/database';
23
+ import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
24
+
25
+ const FW = '192.168.0.254';
26
+
27
+ describe('planClaimedRowDeletion', () => {
28
+ /**
29
+ * The anti-regression that matters. If someone declares a table and this plan
30
+ * does not grow, their rows outlive the consumer silently — which is the whole
31
+ * failure the declaration exists to make impossible.
32
+ */
33
+ it('covers every declared table, so declaring one is enough to have it cleared', () => {
34
+ const planned = new Set(planClaimedRowDeletion().map((t) => t.table));
35
+ const declared = allDeclaredTables().map((d) => d.declaration.table);
36
+
37
+ expect(declared.length).toBeGreaterThan(0);
38
+ for (const table of declared) expect(planned.has(table)).toBe(true);
39
+ });
40
+
41
+ it("carries each table's OWN claim column, which is spelled three ways", () => {
42
+ const byTable = new Map(planClaimedRowDeletion().map((t) => [t.table, t.claimColumn]));
43
+
44
+ expect(byTable.get('web_routes')).toBe('module_id');
45
+ expect(byTable.get('port_forwards')).toBe('registered_by');
46
+ expect(byTable.get('trusted_sources')).toBe('registered_by');
47
+ expect(byTable.get('dns_internal_records')).toBe('consumer_module_id');
48
+ });
49
+ });
50
+
51
+ describe('deleteClaimedRows', () => {
52
+ let dir: string;
53
+ let db: DbClient;
54
+
55
+ beforeEach(async () => {
56
+ dir = mkdtempSync(join(tmpdir(), 'claimed-'));
57
+ const dbPath = join(dir, 'celilo.db');
58
+ process.env.CELILO_DB_PATH = dbPath;
59
+ db = await setupTestDatabaseAt(dbPath);
60
+
61
+ for (const id of ['caddy', 'forgejo', 'technitium']) {
62
+ db.insert(modules)
63
+ .values({
64
+ id,
65
+ name: id,
66
+ version: '1.0.0',
67
+ state: 'VERIFIED',
68
+ sourcePath: `/tmp/${id}`,
69
+ manifestData: {},
70
+ })
71
+ .run();
72
+ }
73
+ });
74
+
75
+ afterEach(() => {
76
+ db.$client.close();
77
+ process.env.CELILO_DB_PATH = undefined;
78
+ rmSync(dir, { recursive: true, force: true });
79
+ });
80
+
81
+ function seed(consumer: string, hostname: string, port: number, subnet: string) {
82
+ db.insert(webRoutes)
83
+ .values({
84
+ slug: `${consumer}-slug`,
85
+ moduleId: consumer,
86
+ type: 'reverse_proxy',
87
+ path: `/${consumer}`,
88
+ hostname,
89
+ })
90
+ .run();
91
+ db.insert(portForwards)
92
+ .values({
93
+ firewallIp: FW,
94
+ internalIp: '10.0.20.5',
95
+ port,
96
+ protocol: 'TCP',
97
+ registeredBy: consumer,
98
+ })
99
+ .run();
100
+ db.insert(trustedSources).values({ firewallIp: FW, subnet, registeredBy: consumer }).run();
101
+ db.insert(dnsInternalRecords)
102
+ .values({
103
+ providerModuleId: 'technitium',
104
+ consumerModuleId: consumer,
105
+ host: hostname,
106
+ ip: '10.0.20.5',
107
+ })
108
+ .run();
109
+ }
110
+
111
+ it('clears the departing consumer from all four declared tables at once', () => {
112
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
113
+
114
+ const cleared = deleteClaimedRows(db, 'caddy');
115
+
116
+ expect(cleared.map((c) => c.table).sort()).toEqual([
117
+ 'dns_internal_records',
118
+ 'port_forwards',
119
+ 'trusted_sources',
120
+ 'web_routes',
121
+ ]);
122
+ expect(db.select().from(webRoutes).all()).toHaveLength(0);
123
+ expect(db.select().from(portForwards).all()).toHaveLength(0);
124
+ expect(db.select().from(trustedSources).all()).toHaveLength(0);
125
+ expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
126
+ });
127
+
128
+ /**
129
+ * The refcount case, the bug most likely to ship silently. Two consumers each
130
+ * hold their own row, and one leaving must not withdraw what the other still
131
+ * needs.
132
+ */
133
+ it("leaves another consumer's rows untouched", () => {
134
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
135
+ seed('forgejo', 'b.example.org', 2222, '10.2.0.0/24');
136
+
137
+ deleteClaimedRows(db, 'caddy');
138
+
139
+ expect(
140
+ db
141
+ .select()
142
+ .from(webRoutes)
143
+ .all()
144
+ .map((r) => r.moduleId),
145
+ ).toEqual(['forgejo']);
146
+ expect(
147
+ db
148
+ .select()
149
+ .from(portForwards)
150
+ .all()
151
+ .map((r) => r.registeredBy),
152
+ ).toEqual(['forgejo']);
153
+ expect(
154
+ db
155
+ .select()
156
+ .from(trustedSources)
157
+ .all()
158
+ .map((r) => r.registeredBy),
159
+ ).toEqual(['forgejo']);
160
+ expect(
161
+ db
162
+ .select()
163
+ .from(dnsInternalRecords)
164
+ .all()
165
+ .map((r) => r.consumerModuleId),
166
+ ).toEqual(['forgejo']);
167
+ });
168
+
169
+ /**
170
+ * `dns_internal_records` is claimed by its CONSUMER, never its provider. The
171
+ * declaration cannot express anything else, which is what makes celilo#1010
172
+ * unrepresentable — but the runtime rule has to agree, or the declaration is
173
+ * decoration.
174
+ */
175
+ it('does not treat the PROVIDER as the claimant of a dns_internal record', () => {
176
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
177
+
178
+ deleteClaimedRows(db, 'technitium');
179
+
180
+ expect(db.select().from(dnsInternalRecords).all()).toHaveLength(1);
181
+ });
182
+
183
+ it('is a no-op for a module that claimed nothing', () => {
184
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
185
+
186
+ deleteClaimedRows(db, 'forgejo');
187
+
188
+ expect(db.select().from(webRoutes).all()).toHaveLength(1);
189
+ expect(db.select().from(portForwards).all()).toHaveLength(1);
190
+ });
191
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Acting on every capability-declared table without naming any of them
3
+ * (openspec/changes/capability-owned-tables, design D3).
4
+ *
5
+ * "This row dies with its consumer" had five implementations for one rule: an FK
6
+ * cascade for `web_routes`, two functions core imported BY NAME from
7
+ * `consumer-cleanup.ts` for `port_forwards` and `trusted_sources`, a second
8
+ * table pruned on read for `dns_registrations`, a cascade on both provider and
9
+ * consumer for `dns_internal_records`, and a hook rewriting a JSON blob for the
10
+ * rest. `consumer-cleanup.ts` exists specifically to delete that kind of
11
+ * per-capability special-casing. It succeeded for the hook dispatch and then
12
+ * grew two hardcoded imports for the rows, because the rows had no generic rule.
13
+ *
14
+ * This is the generic rule. The declaration names the claim column, so core no
15
+ * longer has to know that three tables spell it `module_id`, `registered_by` and
16
+ * `consumer_module_id`.
17
+ */
18
+
19
+ import { CAPABILITY_DECLARED_TABLES, allDeclaredTables } from '@celilo/capabilities';
20
+ import { type Column, eq, is } from 'drizzle-orm';
21
+ import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
22
+ import type { DbClient } from '../db/client';
23
+ import * as dbSchema from '../db/schema';
24
+
25
+ export interface ClaimedRowTarget {
26
+ capability: string;
27
+ /** Physical table name. */
28
+ table: string;
29
+ /** The column holding the consumer's module id. */
30
+ claimColumn: string;
31
+ }
32
+
33
+ /**
34
+ * Physical table name → the drizzle table object, for the tables celilo can
35
+ * actually act on. Built by walking the schema rather than by a hand-written
36
+ * map, so a table cannot be declared and then silently unreachable.
37
+ */
38
+ function schemaTables(): Map<string, SQLiteTable> {
39
+ const out = new Map<string, SQLiteTable>();
40
+ for (const value of Object.values(dbSchema)) {
41
+ if (!is(value, SQLiteTable)) continue;
42
+ out.set(getTableConfig(value).name, value);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * What a consumer's removal must clear. Pure — the list is a property of the
49
+ * declarations, so it is worth being able to assert without a database.
50
+ *
51
+ * A declaration naming a table that does not exist is skipped here rather than
52
+ * throwing. `policy/capability-shape-drift.test.ts` Scan A is what fails on
53
+ * that, loudly and at build time; a removal is the wrong moment to discover it,
54
+ * and refusing to clear the OTHER tables because one declaration is wrong would
55
+ * strand real rows.
56
+ */
57
+ export function planClaimedRowDeletion(): ClaimedRowTarget[] {
58
+ const tables = schemaTables();
59
+ return allDeclaredTables()
60
+ .filter(({ declaration }) => tables.has(declaration.table))
61
+ .map(({ capability, declaration }) => ({
62
+ capability,
63
+ table: declaration.table,
64
+ claimColumn: declaration.claim.column,
65
+ }))
66
+ .sort((a, b) => a.table.localeCompare(b.table));
67
+ }
68
+
69
+ /**
70
+ * Delete every declared row this consumer claimed.
71
+ *
72
+ * Called AFTER every provider has been told the consumer is going (its D4): a
73
+ * provider's `on_consumer_removed` is a full converge, and converging from a set
74
+ * the rows had already been removed from would withdraw more than the departing
75
+ * consumer's share.
76
+ *
77
+ * `web_routes` and `dns_internal_records` also carry an FK cascade, so their
78
+ * rows would go anyway once the `modules` row is deleted. Doing it here as well
79
+ * is deliberate and not merely harmless: it makes the ORDER explicit rather than
80
+ * a consequence of when some other statement happens to run, and it is the same
81
+ * point in the sequence at which `port_forwards` and `trusted_sources` were
82
+ * already being cleared by hand.
83
+ */
84
+ export function deleteClaimedRows(db: DbClient, consumer: string): ClaimedRowTarget[] {
85
+ const tables = schemaTables();
86
+ const cleared: ClaimedRowTarget[] = [];
87
+ for (const target of planClaimedRowDeletion()) {
88
+ const table = tables.get(target.table);
89
+ if (!table) continue;
90
+ const column = getTableConfig(table).columns.find((c) => c.name === target.claimColumn);
91
+ if (!column) continue;
92
+ db.delete(table)
93
+ .where(eq(column as Column, consumer))
94
+ .run();
95
+ cleared.push(target);
96
+ }
97
+ return cleared;
98
+ }
99
+
100
+ /** Capability names that declare at least one table — for logs and reports. */
101
+ export function capabilitiesWithDeclaredTables(): string[] {
102
+ return Object.keys(CAPABILITY_DECLARED_TABLES).sort();
103
+ }
@@ -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();
@@ -23,8 +23,7 @@ import { capabilities, modules } from '../db/schema';
23
23
  import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook';
24
24
  import type { HookLogger } from '../hooks/types';
25
25
  import type { ModuleManifest } from '../manifest/schema';
26
- import { deletePortForwardsForModule } from './port-forwards';
27
- import { deleteTrustedSourcesForModule } from './trusted-sources';
26
+ import { deleteClaimedRows } from './capability-table-rows';
28
27
 
29
28
  /**
30
29
  * States in which a module has never resolved a capability and therefore holds
@@ -213,11 +212,18 @@ export async function runConsumerCleanup(
213
212
  );
214
213
  }
215
214
 
216
- // The rows, once every provider has converged without them (D4). `web_routes`
217
- // dies with the `modules` row via its FK cascade; these two carry a plain
218
- // `registered_by` text column and do not.
219
- deletePortForwardsForModule(db, consumer);
220
- deleteTrustedSourcesForModule(db, consumer);
215
+ // The rows, once every provider has converged without them (D4).
216
+ //
217
+ // Driven by the capability declarations rather than by name. This used to be
218
+ // two hardcoded imports, `deletePortForwardsForModule` and
219
+ // `deleteTrustedSourcesForModule`, which is the shape of the whole problem
220
+ // openspec/changes/capability-owned-tables addresses: this file exists to
221
+ // delete per-capability special-casing, it succeeded for the hook dispatch,
222
+ // and then grew two capability-named calls for the rows because the rows had
223
+ // no generic rule. Now they have one, and core no longer has to know that the
224
+ // claim column is spelled `module_id` on one table, `registered_by` on two and
225
+ // `consumer_module_id` on a fourth.
226
+ deleteClaimedRows(db, consumer);
221
227
 
222
228
  return result;
223
229
  }
@@ -4,9 +4,10 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DnsRecordRequest } from '@celilo/capabilities';
6
6
  import type { ViewOverride } from '@celilo/capabilities';
7
+ import { eq } from 'drizzle-orm';
7
8
  import type { DbClient } from '../db/client';
8
- import { modules } from '../db/schema';
9
- import { setupTestDatabase } from '../test-utils/setup-test-db';
9
+ import { dnsInternalRecords, modules } from '../db/schema';
10
+ import { setupTestDatabaseAt } from '../test-utils/database';
10
11
  import {
11
12
  listDnsInternalRecords,
12
13
  listViewOverrides,
@@ -24,7 +25,7 @@ describe('dns-internal-records ledger', () => {
24
25
  dir = mkdtempSync(join(tmpdir(), 'dns-int-'));
25
26
  dbPath = join(dir, 'celilo.db');
26
27
  process.env.CELILO_DB_PATH = dbPath;
27
- db = await setupTestDatabase(dbPath);
28
+ db = await setupTestDatabaseAt(dbPath);
28
29
  // FK targets: provider + consumer modules.
29
30
  for (const id of ['technitium', 'forgejo']) {
30
31
  db.insert(modules)
@@ -197,3 +198,73 @@ describe('dns-internal-records ledger', () => {
197
198
  });
198
199
  });
199
200
  });
201
+
202
+ /**
203
+ * celilo#1010 — swapping the internal DNS provider must not delete the ledger.
204
+ *
205
+ * `dns_internal_records` used to cascade on `provider_module_id` as well as on
206
+ * the consumer, so removing `technitium` to install `knot-unbound-internal` took
207
+ * the fleet's entire internal DNS ledger with it, `zone_routable_ip` view
208
+ * overrides included — the durable desired state the resolver's split-horizon
209
+ * config is reconciled from. `web_routes` cascades on its consumer only, and the
210
+ * two tables' docblocks claimed to be siblings, so the divergence read as intent
211
+ * and was not.
212
+ *
213
+ * The claim on a capability-owned table is the CONSUMER
214
+ * (openspec/changes/capability-owned-tables D3/D8). Migration 0027 makes the
215
+ * table agree with the declaration, which cannot express anything else.
216
+ */
217
+ describe('provider cascade (celilo#1010)', () => {
218
+ let dir: string;
219
+ let db: DbClient;
220
+
221
+ beforeEach(async () => {
222
+ dir = mkdtempSync(join(tmpdir(), 'dns-cascade-'));
223
+ const dbPath = join(dir, 'celilo.db');
224
+ process.env.CELILO_DB_PATH = dbPath;
225
+ db = await setupTestDatabaseAt(dbPath);
226
+ for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) {
227
+ db.insert(modules)
228
+ .values({
229
+ id,
230
+ name: id,
231
+ version: '1.0.0',
232
+ state: 'VERIFIED',
233
+ sourcePath: `/tmp/${id}`,
234
+ manifestData: {},
235
+ })
236
+ .run();
237
+ }
238
+ db.insert(dnsInternalRecords)
239
+ .values({
240
+ providerModuleId: 'technitium',
241
+ consumerModuleId: 'caddy',
242
+ host: 'auth.example.org',
243
+ ip: '192.168.0.253',
244
+ zoneRoutableIp: '10.0.10.14',
245
+ })
246
+ .run();
247
+ });
248
+
249
+ afterEach(() => {
250
+ db.$client.close();
251
+ process.env.CELILO_DB_PATH = undefined;
252
+ rmSync(dir, { recursive: true, force: true });
253
+ });
254
+
255
+ it('keeps the ledger when the PROVIDER is removed, view overrides included', () => {
256
+ db.delete(modules).where(eq(modules.id, 'technitium')).run();
257
+
258
+ const left = db.select().from(dnsInternalRecords).all();
259
+ expect(left).toHaveLength(1);
260
+ // The override is the part whose loss is silent: the resolver keeps
261
+ // answering, just with the wrong address for in-zone clients.
262
+ expect(left[0]?.zoneRoutableIp).toBe('10.0.10.14');
263
+ });
264
+
265
+ it('still dies with its CONSUMER, which is the rule that did not change', () => {
266
+ db.delete(modules).where(eq(modules.id, 'caddy')).run();
267
+
268
+ expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
269
+ });
270
+ });
@@ -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