@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
- package/drizzle/0021_dns_registration_consumers.sql +63 -0
- package/drizzle/0022_dns_registrations_companion.sql +15 -0
- package/drizzle/0023_public_dns_evidence.sql +19 -0
- package/drizzle/meta/_journal.json +29 -1
- package/package.json +2 -2
- package/schemas/system_config.json +22 -11
- package/src/cli/commands/dns.ts +8 -4
- package/src/cli/commands/events.ts +4 -1
- package/src/cli/commands/system-audit.ts +15 -0
- package/src/cli/commands/system-migrate.test.ts +25 -4
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/dns-registrations-migration.test.ts +205 -0
- package/src/db/schema.ts +77 -8
- package/src/hooks/define-hook.test.ts +3 -3
- package/src/hooks/executor.test.ts +58 -0
- package/src/hooks/executor.ts +67 -7
- package/src/hooks/run-named-hook.ts +7 -1
- package/src/hooks/test-fixtures/silent-hook.ts +20 -0
- package/src/module/packaging/build.ts +14 -0
- package/src/services/alerting/builtin-monitors.ts +3 -0
- package/src/services/alerting/builtin-source.ts +23 -0
- package/src/services/audit/index.test.ts +2 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/public-dns-source.ts +55 -0
- package/src/services/audit/public-dns.test.ts +209 -0
- package/src/services/audit/public-dns.ts +286 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/dns-registrations.test.ts +78 -16
- package/src/services/dns-registrations.ts +107 -19
- package/src/services/fleet-checks.test.ts +47 -1
- package/src/services/fleet-checks.ts +36 -4
- package/src/services/module-subscriptions.test.ts +9 -0
- package/src/services/public-dns-probe.test.ts +81 -0
- package/src/services/public-dns-probe.ts +156 -0
- 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
|
|
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
|
|
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
|
-
|
|
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: '
|
|
88
|
-
|
|
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'
|
|
110
|
-
await wrapped.registerHost({ fqdn: 'fail.example.net'
|
|
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
|
|
115
|
-
|
|
154
|
+
const fqdns = listDnsRegistrations(db)
|
|
155
|
+
.map((r) => r.fqdn)
|
|
156
|
+
.sort();
|
|
116
157
|
expect(fqdns).toEqual(['auto.example.net', 'www.example.net']);
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
companion: registration.companion ?? false,
|
|
62
112
|
registeredAt: new Date(),
|
|
63
113
|
})
|
|
64
114
|
.onConflictDoUpdate({
|
|
65
115
|
target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 }, []>(
|
|
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
|
-
|
|
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:
|
|
450
|
-
autoFixable:
|
|
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
|
+
});
|