@celilo/cli 0.20.0 → 0.21.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 (39) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
  4. package/drizzle/0021_dns_registration_consumers.sql +63 -0
  5. package/drizzle/0022_dns_registrations_companion.sql +15 -0
  6. package/drizzle/0023_public_dns_evidence.sql +19 -0
  7. package/drizzle/meta/_journal.json +29 -1
  8. package/package.json +2 -2
  9. package/schemas/system_config.json +22 -11
  10. package/src/cli/commands/dns.ts +8 -4
  11. package/src/cli/commands/events.ts +4 -1
  12. package/src/cli/commands/system-audit.ts +15 -0
  13. package/src/cli/commands/system-migrate.test.ts +25 -4
  14. package/src/cli/commands/system-update.ts +5 -0
  15. package/src/cli/tui/audit-state.ts +2 -0
  16. package/src/db/dns-registrations-migration.test.ts +205 -0
  17. package/src/db/schema.ts +77 -8
  18. package/src/hooks/define-hook.test.ts +3 -3
  19. package/src/hooks/executor.test.ts +58 -0
  20. package/src/hooks/executor.ts +67 -7
  21. package/src/hooks/run-named-hook.ts +7 -1
  22. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  23. package/src/module/packaging/build.ts +14 -0
  24. package/src/services/alerting/builtin-monitors.ts +3 -0
  25. package/src/services/alerting/builtin-source.ts +23 -0
  26. package/src/services/audit/index.test.ts +2 -0
  27. package/src/services/audit/index.ts +3 -0
  28. package/src/services/audit/public-dns-source.ts +55 -0
  29. package/src/services/audit/public-dns.test.ts +209 -0
  30. package/src/services/audit/public-dns.ts +286 -0
  31. package/src/services/audit/types.ts +1 -0
  32. package/src/services/dns-registrations.test.ts +78 -16
  33. package/src/services/dns-registrations.ts +107 -19
  34. package/src/services/fleet-checks.test.ts +47 -1
  35. package/src/services/fleet-checks.ts +36 -4
  36. package/src/services/module-subscriptions.test.ts +9 -0
  37. package/src/services/public-dns-probe.test.ts +81 -0
  38. package/src/services/public-dns-probe.ts +156 -0
  39. package/src/services/update/orchestrator.test.ts +2 -0
@@ -2,7 +2,8 @@
2
2
  * Unit tests for the DNS registration ledger
3
3
  * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2): upsert semantics,
4
4
  * the registerHost recording wrapper (success-only), refresh stamping,
5
- * and FK-cascade cleanup when a module is removed.
5
+ * companion rows, and the consumer-set lifecycle (design.md D5) a row
6
+ * survives every consumer but the last.
6
7
  */
7
8
 
8
9
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
@@ -28,7 +29,7 @@ describe('dns_registrations ledger', () => {
28
29
  tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsreg-'));
29
30
  process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
30
31
  db = getDb();
31
- for (const id of ['namecheap', 'caddy']) {
32
+ for (const id of ['namecheap', 'caddy', 'authentik']) {
32
33
  db.insert(modules)
33
34
  .values({
34
35
  id,
@@ -46,25 +47,23 @@ describe('dns_registrations ledger', () => {
46
47
  process.env.CELILO_DB_PATH = undefined;
47
48
  });
48
49
 
49
- test('record + list roundtrip; upsert replaces ip and consumer', () => {
50
+ test('record + list roundtrip; a re-assert is an upsert, not a second row', () => {
50
51
  recordDnsRegistration(db, {
51
52
  providerModuleId: 'namecheap',
52
53
  consumerModuleId: 'caddy',
53
54
  fqdn: 'www.example.net',
54
- ip: '198.51.100.7',
55
55
  });
56
56
  recordDnsRegistration(db, {
57
57
  providerModuleId: 'namecheap',
58
58
  consumerModuleId: 'caddy',
59
59
  fqdn: 'www.example.net',
60
- ip: '198.51.100.8',
61
60
  });
62
61
 
63
62
  const rows = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
64
63
  expect(rows.length).toBe(1);
65
64
  expect(rows[0].fqdn).toBe('www.example.net');
66
- expect(rows[0].ip).toBe('198.51.100.8');
67
65
  expect(rows[0].consumerModuleId).toBe('caddy');
66
+ expect(rows[0].companion).toBe(false);
68
67
  expect(rows[0].refreshedAt).toBeNull();
69
68
  });
70
69
 
@@ -73,21 +72,62 @@ describe('dns_registrations ledger', () => {
73
72
  providerModuleId: 'namecheap',
74
73
  consumerModuleId: 'caddy',
75
74
  fqdn: 'git.example.net',
76
- ip: null,
77
75
  });
78
76
  stampDnsRegistrationsRefreshed(db, 'namecheap');
79
77
  const [row] = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
80
78
  expect(row.refreshedAt).not.toBeNull();
81
79
  });
82
80
 
83
- test('rows die with the consumer module (FK cascade)', () => {
81
+ // ── design.md D5: attribution is a set, and it is load-bearing ─────────────
82
+ test('a blanket re-assert ADDS a consumer instead of overwriting the introducer', () => {
83
+ recordDnsRegistration(db, {
84
+ providerModuleId: 'namecheap',
85
+ consumerModuleId: 'authentik',
86
+ fqdn: 'auth.example.net',
87
+ });
88
+ // What `run-hook caddy on_install` does to every served name.
84
89
  recordDnsRegistration(db, {
85
90
  providerModuleId: 'namecheap',
86
91
  consumerModuleId: 'caddy',
87
- fqdn: 'www.example.net',
88
- ip: '198.51.100.7',
92
+ fqdn: 'auth.example.net',
93
+ });
94
+
95
+ const [row] = listDnsRegistrations(db);
96
+ expect(row.consumerModuleIds).toEqual(['authentik', 'caddy']);
97
+ // The module that introduced the name is still identifiable.
98
+ expect(row.consumerModuleId).toBe('authentik');
99
+ });
100
+
101
+ test('the row survives a consumer removal and dies with the last one', () => {
102
+ recordDnsRegistration(db, {
103
+ providerModuleId: 'namecheap',
104
+ consumerModuleId: 'authentik',
105
+ fqdn: 'auth.example.net',
89
106
  });
107
+ recordDnsRegistration(db, {
108
+ providerModuleId: 'namecheap',
109
+ consumerModuleId: 'caddy',
110
+ fqdn: 'auth.example.net',
111
+ });
112
+
113
+ // Removing caddy on the live fleet would, under the single-value column,
114
+ // have cascade-deleted a name authentik still serves.
90
115
  db.delete(modules).where(eq(modules.id, 'caddy')).run();
116
+ const [row] = listDnsRegistrations(db);
117
+ expect(row.fqdn).toBe('auth.example.net');
118
+ expect(row.consumerModuleIds).toEqual(['authentik']);
119
+
120
+ db.delete(modules).where(eq(modules.id, 'authentik')).run();
121
+ expect(listDnsRegistrations(db).length).toBe(0);
122
+ });
123
+
124
+ test('rows die with the provider module (FK cascade)', () => {
125
+ recordDnsRegistration(db, {
126
+ providerModuleId: 'namecheap',
127
+ consumerModuleId: 'caddy',
128
+ fqdn: 'www.example.net',
129
+ });
130
+ db.delete(modules).where(eq(modules.id, 'namecheap')).run();
91
131
  expect(listDnsRegistrations(db).length).toBe(0);
92
132
  });
93
133
 
@@ -106,15 +146,37 @@ describe('dns_registrations ledger', () => {
106
146
  consumerModuleId: 'caddy',
107
147
  });
108
148
 
109
- await wrapped.registerHost({ fqdn: 'www.example.net', ip: '198.51.100.7' });
110
- await wrapped.registerHost({ fqdn: 'fail.example.net', ip: '198.51.100.7' });
149
+ await wrapped.registerHost({ fqdn: 'www.example.net' });
150
+ await wrapped.registerHost({ fqdn: 'fail.example.net' });
111
151
  await wrapped.registerHost({ fqdn: 'auto.example.net' });
112
152
 
113
153
  expect(calls.length).toBe(3);
114
- const rows = listDnsRegistrations(db);
115
- const fqdns = rows.map((r) => r.fqdn).sort();
154
+ const fqdns = listDnsRegistrations(db)
155
+ .map((r) => r.fqdn)
156
+ .sort();
116
157
  expect(fqdns).toEqual(['auto.example.net', 'www.example.net']);
117
- const auto = rows.find((r) => r.fqdn === 'auto.example.net');
118
- expect(auto?.ip).toBeNull();
158
+ });
159
+
160
+ test('a companion the provider attempted gets its own watched row', async () => {
161
+ const fake: DnsRegistrarCapability = {
162
+ async registerHost(request) {
163
+ return {
164
+ success: true,
165
+ outputs: { companion_fqdn: `www.${request.fqdn}` },
166
+ duration: 1,
167
+ };
168
+ },
169
+ };
170
+ const wrapped = withDnsRegistrationLedger(fake, {
171
+ db,
172
+ providerModuleId: 'namecheap',
173
+ consumerModuleId: 'caddy',
174
+ });
175
+
176
+ await wrapped.registerHost({ fqdn: 'example.net' });
177
+
178
+ const rows = listDnsRegistrations(db).sort((a, b) => a.fqdn.localeCompare(b.fqdn));
179
+ expect(rows.map((r) => r.fqdn)).toEqual(['example.net', 'www.example.net']);
180
+ expect(rows.map((r) => r.companion)).toEqual([false, true]);
119
181
  });
120
182
  });
@@ -4,36 +4,63 @@
4
4
  *
5
5
  * The capability loader records every successful
6
6
  * dns_registrar.registerHost here; the run-hook path reads the ledger
7
- * back to feed a provider's `refresh_registrations` hook, and `celilo
8
- * dns registrations` lists it for the operator. Row lifecycle is FK
9
- * cascade — registrations die with their provider or consumer module.
7
+ * back to feed a provider's `refresh_registrations` hook, the
8
+ * `public_dns` check resolves every row from off-fleet, and `celilo dns
9
+ * registrations` lists it for the operator.
10
+ *
11
+ * The ledger records WHICH MODULE ASKED FOR WHICH NAME. It stores no
12
+ * address — see the schema comment and design.md D1.
13
+ *
14
+ * Lifecycle: a row dies with its provider by FK cascade, and with its
15
+ * LAST consumer via the `dns_registration_consumers` set (design.md D5).
10
16
  */
11
17
 
12
18
  import type { DnsRegistrarCapability, HookResult } from '@celilo/capabilities';
13
- import { eq } from 'drizzle-orm';
19
+ import { and, eq, sql } from 'drizzle-orm';
14
20
  import type { DbClient } from '../db/client';
15
- import { dnsRegistrations } from '../db/schema';
21
+ import { dnsRegistrationConsumers, dnsRegistrations } from '../db/schema';
16
22
 
17
23
  export interface DnsRegistrationRow {
18
24
  fqdn: string;
19
- /** null = the provider auto-detected the request's source IP. */
20
- ip: string | null;
21
25
  providerModuleId: string;
26
+ /** The module that introduced the name — the earliest consumer. */
22
27
  consumerModuleId: string;
28
+ /** Every module currently depending on the name, introducer first. */
29
+ consumerModuleIds: string[];
30
+ /** Claimed by celilo as the companion of a declared name, not asked for. */
31
+ companion: boolean;
23
32
  registeredAt: Date;
24
33
  refreshedAt: Date | null;
25
34
  }
26
35
 
36
+ /**
37
+ * Drop registrations whose last consumer module is gone.
38
+ *
39
+ * ponytail: pruned on read rather than by trigger — SQLite fires delete
40
+ * triggers for FK-cascaded deletes only when `recursive_triggers` is on,
41
+ * and reads are the only thing that consumes the ledger. Move it into a
42
+ * trigger if something ever reads these rows without going through here.
43
+ */
44
+ function pruneOrphanedRegistrations(db: DbClient): void {
45
+ db.run(
46
+ sql`DELETE FROM dns_registrations WHERE NOT EXISTS (
47
+ SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
48
+ )`,
49
+ );
50
+ }
51
+
27
52
  export function listDnsRegistrations(
28
53
  db: DbClient,
29
54
  options: { providerModuleId?: string } = {},
30
55
  ): DnsRegistrationRow[] {
56
+ pruneOrphanedRegistrations(db);
57
+
31
58
  const query = db
32
59
  .select({
60
+ id: dnsRegistrations.id,
33
61
  fqdn: dnsRegistrations.fqdn,
34
- ip: dnsRegistrations.ip,
35
62
  providerModuleId: dnsRegistrations.providerModuleId,
36
- consumerModuleId: dnsRegistrations.consumerModuleId,
63
+ companion: dnsRegistrations.companion,
37
64
  registeredAt: dnsRegistrations.registeredAt,
38
65
  refreshedAt: dnsRegistrations.refreshedAt,
39
66
  })
@@ -41,7 +68,31 @@ export function listDnsRegistrations(
41
68
  const rows = options.providerModuleId
42
69
  ? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
43
70
  : query.all();
44
- return rows;
71
+
72
+ const consumers = db
73
+ .select({
74
+ registrationId: dnsRegistrationConsumers.registrationId,
75
+ moduleId: dnsRegistrationConsumers.moduleId,
76
+ })
77
+ .from(dnsRegistrationConsumers)
78
+ .orderBy(dnsRegistrationConsumers.id)
79
+ .all();
80
+
81
+ const byRegistration = new Map<number, string[]>();
82
+ for (const c of consumers) {
83
+ const list = byRegistration.get(c.registrationId);
84
+ if (list) list.push(c.moduleId);
85
+ else byRegistration.set(c.registrationId, [c.moduleId]);
86
+ }
87
+
88
+ return rows.map(({ id, ...row }) => {
89
+ const consumerModuleIds = byRegistration.get(id) ?? [];
90
+ return {
91
+ ...row,
92
+ consumerModuleIds,
93
+ consumerModuleId: consumerModuleIds[0] ?? '',
94
+ };
95
+ });
45
96
  }
46
97
 
47
98
  export function recordDnsRegistration(
@@ -50,26 +101,46 @@ export function recordDnsRegistration(
50
101
  providerModuleId: string;
51
102
  consumerModuleId: string;
52
103
  fqdn: string;
53
- ip: string | null;
104
+ companion?: boolean;
54
105
  },
55
106
  ): void {
56
107
  db.insert(dnsRegistrations)
57
108
  .values({
58
109
  providerModuleId: registration.providerModuleId,
59
- consumerModuleId: registration.consumerModuleId,
60
110
  fqdn: registration.fqdn,
61
- ip: registration.ip,
111
+ companion: registration.companion ?? false,
62
112
  registeredAt: new Date(),
63
113
  })
64
114
  .onConflictDoUpdate({
65
115
  target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
66
- set: {
67
- consumerModuleId: registration.consumerModuleId,
68
- ip: registration.ip,
69
- registeredAt: new Date(),
70
- },
116
+ // `companion` is not re-asserted: once a module declares a name
117
+ // outright it stops being something celilo claimed on its behalf.
118
+ set: { registeredAt: new Date() },
71
119
  })
72
120
  .run();
121
+
122
+ const row = db
123
+ .select({ id: dnsRegistrations.id })
124
+ .from(dnsRegistrations)
125
+ .where(
126
+ and(
127
+ eq(dnsRegistrations.providerModuleId, registration.providerModuleId),
128
+ eq(dnsRegistrations.fqdn, registration.fqdn),
129
+ ),
130
+ )
131
+ .get();
132
+ if (!row) return;
133
+
134
+ // A re-assert ADDS the asserting module rather than replacing whoever
135
+ // was there — the row must outlive any single one of them (D5).
136
+ db.insert(dnsRegistrationConsumers)
137
+ .values({
138
+ registrationId: row.id,
139
+ moduleId: registration.consumerModuleId,
140
+ firstSeenAt: new Date(),
141
+ })
142
+ .onConflictDoNothing()
143
+ .run();
73
144
  }
74
145
 
75
146
  /** Stamp every row of a provider as refreshed (successful refresh hook). */
@@ -85,6 +156,15 @@ export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: s
85
156
  * recorded in the ledger. Failures and MissingProviderInputError
86
157
  * interview throws pass through untouched — only a confirmed
87
158
  * registration earns a row.
159
+ *
160
+ * A provider that ATTEMPTED a companion name (`www.<domain>` ↔
161
+ * `<domain>`) reports it as `outputs.companion_fqdn`, and it gets its own
162
+ * row whether or not the attempt reported success. The row is not a claim
163
+ * that the name is published — it is what puts the name under the
164
+ * `public_dns` check, which is the only thing that can tell. Namecheap
165
+ * returns `ErrCount 0` for a `www` update it silently does not apply, so
166
+ * a companion recorded only on "success" would be a name celilo believes
167
+ * it owns and never looks at again (design.md D3).
88
168
  */
89
169
  export function withDnsRegistrationLedger(
90
170
  registrar: DnsRegistrarCapability,
@@ -99,8 +179,16 @@ export function withDnsRegistrationLedger(
99
179
  providerModuleId: ctx.providerModuleId,
100
180
  consumerModuleId: ctx.consumerModuleId,
101
181
  fqdn: request.fqdn,
102
- ip: request.ip ?? null,
103
182
  });
183
+ const companion = result.outputs?.companion_fqdn;
184
+ if (typeof companion === 'string' && companion.length > 0) {
185
+ recordDnsRegistration(ctx.db, {
186
+ providerModuleId: ctx.providerModuleId,
187
+ consumerModuleId: ctx.consumerModuleId,
188
+ fqdn: companion,
189
+ companion: true,
190
+ });
191
+ }
104
192
  }
105
193
  return result;
106
194
  },
@@ -14,6 +14,8 @@ import {
14
14
  } from '../db/schema';
15
15
  import type { ModuleManifest } from '../manifest/schema';
16
16
  import { setupTestDatabase } from '../test-utils/setup-test-db';
17
+ import { ensureInboundSubscriber, ensureSweepSubscriber } from './alerting/monitors';
18
+ import { ensureBackupSweepSubscriber } from './backup-sweep';
17
19
  import { getDaemonUnitPath } from './events-daemon';
18
20
  import {
19
21
  checkCapabilityProviders,
@@ -25,6 +27,7 @@ import {
25
27
  describeCapabilityProblem,
26
28
  findBrokenCapabilityDerivations,
27
29
  } from './fleet-checks';
30
+ import { ensureOperationsSweepSubscriber } from './module-operations';
28
31
 
29
32
  const MINUTE = 60_000;
30
33
 
@@ -274,10 +277,53 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
274
277
 
275
278
  it('warns about a stale subscriber with no deployed module', () => {
276
279
  // No modules deployed, but a leftover subscriber lingers.
277
- bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo' });
280
+ bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo', registeredBy: 'ghost' });
278
281
  const f = checkSubscribers(bus, db);
279
282
  expect(f.status).toBe('warn');
280
283
  expect(f.detail.join(' ')).toContain('ghost.sub');
284
+ // `resync-subscriptions` never deletes, so it cannot clear this (#624).
285
+ expect(f.autoFixable).toBe(false);
286
+ expect(f.remediation).toContain('subscribers remove');
287
+ });
288
+
289
+ it('ignores core-registered subscribers that no manifest declares (#624)', () => {
290
+ ensureSweepSubscriber(bus);
291
+ ensureInboundSubscriber(bus);
292
+ ensureBackupSweepSubscriber(bus);
293
+ ensureOperationsSweepSubscriber(bus);
294
+ const f = checkSubscribers(bus, db);
295
+ expect(f.status).toBe('ok');
296
+ expect(f.detail.join(' ')).not.toContain('celilo-');
297
+ });
298
+
299
+ // The guard for the invariant `checkSubscribers` classifies by, asserted
300
+ // at the sites that ESTABLISH it rather than the one that consumes it —
301
+ // so it keeps holding if the predicate is ever rewritten. Goes red the
302
+ // moment a core registrar names its row under its own registrar id
303
+ // (`celilo-alerting` registering `celilo-alerting.digest`), which would
304
+ // read as a module row, find no module of that name, and be reported
305
+ // stale — #624 again, and baffling to whoever hit it.
306
+ //
307
+ // ponytail: enumerates the registrars by hand because celilo has no
308
+ // registry of them. A fifth one is not covered until it is added here;
309
+ // if that ever bites, the upgrade is a shared `registerCoreSubscriber`
310
+ // helper that every core site goes through, tested once.
311
+ it('no core registrar produces a module-shaped subscriber name', () => {
312
+ ensureSweepSubscriber(bus);
313
+ ensureInboundSubscriber(bus);
314
+ ensureBackupSweepSubscriber(bus);
315
+ ensureOperationsSweepSubscriber(bus);
316
+
317
+ const rows = bus.db
318
+ .query<{ name: string; registered_by: string | null }, []>(
319
+ 'SELECT name, registered_by FROM subscribers',
320
+ )
321
+ .all();
322
+ expect(rows.length).toBeGreaterThan(0);
323
+ for (const row of rows) {
324
+ expect(row.registered_by).not.toBeNull();
325
+ expect(row.name.startsWith(`${row.registered_by}.`)).toBe(false);
326
+ }
281
327
  });
282
328
  });
283
329
 
@@ -386,6 +386,9 @@ function loadDeployedModules(db: DbClient): DeployedModule[] {
386
386
  * manifests declare. A restore/migration starts it EMPTY (ISS-0088), so
387
387
  * every reactive subscription silently vanishes until a resync or a
388
388
  * redeploy. Missing rows fail; stale rows (a since-removed module) warn.
389
+ *
390
+ * Scoped to MODULE-owned rows — core's own subscribers share this bus and are
391
+ * declared by no manifest.
389
392
  */
390
393
  export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
391
394
  const deployed = loadDeployedModules(db);
@@ -402,7 +405,9 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
402
405
  }
403
406
 
404
407
  const actualRows = bus.db
405
- .query<{ name: string; pattern: string }, []>('SELECT name, pattern FROM subscribers')
408
+ .query<{ name: string; pattern: string; registered_by: string | null }, []>(
409
+ 'SELECT name, pattern, registered_by FROM subscribers',
410
+ )
406
411
  .all();
407
412
  const actual = new Map(actualRows.map((r) => [r.name, r.pattern]));
408
413
 
@@ -413,7 +418,25 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
413
418
  if (have === undefined) missing.push(name);
414
419
  else if (have !== pattern) mismatched.push(`${name} (manifest: ${pattern}, bus: ${have})`);
415
420
  }
416
- const stale = actualRows.map((r) => r.name).filter((name) => !expected.has(name));
421
+ // Only module-owned rows can be stale. celilo core registers subscribers on
422
+ // the same bus (the alerting/backup/operations sweeps) that no manifest
423
+ // declares, so judging every row against the manifest-derived map reported
424
+ // them as drift on every install, forever (#624).
425
+ //
426
+ // INVARIANT this relies on: a module row is named `<registered_by>.<sub>` —
427
+ // `resolveSubscription` (services/module-subscriptions.ts) derives both the
428
+ // scoped name and registered_by from the same module id. A core row is NOT
429
+ // scoped under its registrar's id (`celilo-alerting` registers
430
+ // `celilo-alerting-sweep`, not `celilo-alerting.sweep`). That's what lets
431
+ // provenance separate them with no allowlist to keep in sync.
432
+ //
433
+ // To break it: have a core registrar name a row under its own id — a future
434
+ // `celilo-alerting.digest` would read as module-owned, find no deployed
435
+ // module named `celilo-alerting`, and be reported stale. Which is #624 again.
436
+ const stale = actualRows
437
+ .filter((r) => r.registered_by !== null && r.name.startsWith(`${r.registered_by}.`))
438
+ .map((r) => r.name)
439
+ .filter((name) => !expected.has(name));
417
440
 
418
441
  const detail: string[] = [];
419
442
  const statuses: FleetFindingStatus[] = [];
@@ -436,6 +459,15 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
436
459
  );
437
460
  }
438
461
 
462
+ // `resync-subscriptions` only upserts from manifests — it never deletes, so
463
+ // it cannot clear a stale row. Advertising that as the remedy (and as
464
+ // auto-fixable) made `--fix` run a guaranteed no-op and call it a fix.
465
+ const resyncable = missing.length > 0 || mismatched.length > 0;
466
+ const remediations: string[] = [];
467
+ if (resyncable) remediations.push('`celilo events resync-subscriptions` (safe, idempotent)');
468
+ if (stale.length > 0)
469
+ remediations.push(`\`celilo subscribers remove <name>\` for: ${stale.join(', ')}`);
470
+
439
471
  const status = worst(statuses);
440
472
  return {
441
473
  id: 'subscribers',
@@ -446,8 +478,8 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
446
478
  ? `${expected.size} subscription(s) match the deployed fleet`
447
479
  : 'bus subscribers drifted from the deployed fleet',
448
480
  detail,
449
- remediation: status === 'ok' ? null : '`celilo events resync-subscriptions` (safe, idempotent)',
450
- autoFixable: status !== 'ok',
481
+ remediation: remediations.length > 0 ? remediations.join('; ') : null,
482
+ autoFixable: resyncable,
451
483
  };
452
484
  }
453
485
 
@@ -46,6 +46,15 @@ describe('resolveSubscription', () => {
46
46
  expect(resolved.registeredBy).toBe('lunacycle');
47
47
  });
48
48
 
49
+ // The other half of the invariant `checkSubscribers` classifies by (#624):
50
+ // core rows are not scoped under their registrar id, module rows always are.
51
+ // Stop scoping the name here and every module row reads as core-owned — the
52
+ // stale check would go silently blind rather than noisily wrong.
53
+ it('always scopes the name under the id it stamps in registeredBy', () => {
54
+ const resolved = resolveSubscription({ name: 'a', pattern: 'x', handler: 'echo' }, 'foo', '/p');
55
+ expect(resolved.name.startsWith(`${resolved.registeredBy}.`)).toBe(true);
56
+ });
57
+
49
58
  it('only substitutes $self when followed by . or end-of-string', () => {
50
59
  // `$selfish` would not be a real pattern but we want to ensure the
51
60
  // substitution doesn't accidentally rewrite identifier-like names.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The gate for the original bug, one layer up.
3
+ *
4
+ * A `public_dns` check that quietly resolved through the fleet's own resolver
5
+ * would pass forever whatever the internet sees — exactly like caddy's `dig`
6
+ * check does today, and exactly the blindness that let celilo#626 run for nine
7
+ * days. So "the resolver is off-fleet" is asserted rather than left to a
8
+ * comment, and the assertion lives where the probe is built.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import {
13
+ DEFAULT_PUBLIC_RESOLVER,
14
+ FleetResolverAsPublicVantageError,
15
+ assertOffFleetResolver,
16
+ parseFleetResolvers,
17
+ } from './public-dns-probe';
18
+
19
+ const FLEET = [
20
+ { role: 'dns.primary', ip: '10.0.20.5' },
21
+ { role: 'dns.fallback', ip: '8.8.8.8' },
22
+ ];
23
+
24
+ describe('assertOffFleetResolver', () => {
25
+ test("refuses the fleet's own resolver", () => {
26
+ expect(() => assertOffFleetResolver('10.0.20.5', FLEET)).toThrow(
27
+ FleetResolverAsPublicVantageError,
28
+ );
29
+ });
30
+
31
+ test('refuses it even when the fleet resolver is a public address', () => {
32
+ // The failure is "the fleet asks this resolver too", not "the address is
33
+ // private": a fleet configured to forward to 8.8.8.8 gets the same
34
+ // split-horizon answers back through it.
35
+ expect(() => assertOffFleetResolver('8.8.8.8', FLEET)).toThrow(
36
+ FleetResolverAsPublicVantageError,
37
+ );
38
+ });
39
+
40
+ test('accepts a resolver the fleet does not use', () => {
41
+ expect(() => assertOffFleetResolver(DEFAULT_PUBLIC_RESOLVER, FLEET)).not.toThrow();
42
+ });
43
+
44
+ test('the refusal says how to fix it', () => {
45
+ try {
46
+ assertOffFleetResolver('10.0.20.5', FLEET);
47
+ throw new Error('expected a refusal');
48
+ } catch (error) {
49
+ expect((error as Error).message).toContain('celilo system config set public_dns.resolver');
50
+ }
51
+ });
52
+ });
53
+
54
+ describe('parseFleetResolvers', () => {
55
+ test('a comma-separated dns.fallback yields one entry per address', () => {
56
+ // `system init` writes `dns.fallback=1.0.0.1,8.8.8.8`. Compared whole, the
57
+ // guard matched neither address and would have accepted 8.8.8.8 as the
58
+ // "off-fleet" vantage point of a fleet that forwards to 8.8.8.8.
59
+ expect(
60
+ parseFleetResolvers([
61
+ { role: 'dns.primary', value: '10.0.20.5' },
62
+ { role: 'dns.fallback', value: '1.0.0.1, 8.8.8.8' },
63
+ ]),
64
+ ).toEqual([
65
+ { role: 'dns.primary', ip: '10.0.20.5' },
66
+ { role: 'dns.fallback', ip: '1.0.0.1' },
67
+ { role: 'dns.fallback', ip: '8.8.8.8' },
68
+ ]);
69
+ });
70
+
71
+ test('an unset key contributes nothing', () => {
72
+ expect(parseFleetResolvers([{ role: 'dns.primary', value: undefined }])).toEqual([]);
73
+ });
74
+
75
+ test('a multi-entry fallback is caught by the guard', () => {
76
+ const fleet = parseFleetResolvers([{ role: 'dns.fallback', value: '1.0.0.1,8.8.8.8' }]);
77
+ expect(() => assertOffFleetResolver('8.8.8.8', fleet)).toThrow(
78
+ FleetResolverAsPublicVantageError,
79
+ );
80
+ });
81
+ });