@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.6
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/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/cli/command-registry.ts +13 -2
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +2 -2
- package/src/cli/commands/service-add-proxmox.ts +9 -0
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +7 -2
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +24 -15
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/schema.ts +7 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +2 -2
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +10 -10
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/templates/generator.test.ts +62 -12
- package/src/templates/generator.ts +69 -50
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
6
|
+
import type { DbClient } from '../db/client';
|
|
7
|
+
import {
|
|
8
|
+
capabilities as capabilitiesTable,
|
|
9
|
+
dnsInternalRecords,
|
|
10
|
+
moduleConfigs,
|
|
11
|
+
moduleSystems,
|
|
12
|
+
modules,
|
|
13
|
+
} from '../db/schema';
|
|
14
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
15
|
+
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
16
|
+
import { getDaemonUnitPath } from './events-daemon';
|
|
17
|
+
import {
|
|
18
|
+
checkCapabilityProviders,
|
|
19
|
+
checkDispatcher,
|
|
20
|
+
checkSchemaDrift,
|
|
21
|
+
checkServiceDns,
|
|
22
|
+
checkSubscribers,
|
|
23
|
+
describeCapabilityProblem,
|
|
24
|
+
findBrokenCapabilityDerivations,
|
|
25
|
+
} from './fleet-checks';
|
|
26
|
+
|
|
27
|
+
const MINUTE = 60_000;
|
|
28
|
+
|
|
29
|
+
/** Seed a dispatcher heartbeat row directly (bypasses the running loop). */
|
|
30
|
+
function seedHeartbeat(
|
|
31
|
+
bus: Bus,
|
|
32
|
+
opts: { startedAt: number; lastHeartbeat: number; pid?: number; version?: string },
|
|
33
|
+
): void {
|
|
34
|
+
bus.db.run(
|
|
35
|
+
'INSERT INTO dispatcher_heartbeat (dispatcher_id, last_heartbeat, started_at, pid, version) VALUES (?, ?, ?, ?, ?)',
|
|
36
|
+
['d1', opts.lastHeartbeat, opts.startedAt, opts.pid ?? 4242, opts.version ?? '0.1.0'],
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Write a supervisor unit file so readInstalledUnit('user') sees it. */
|
|
41
|
+
function installFakeUnit(home: string): void {
|
|
42
|
+
const path = getDaemonUnitPath('linux', home, 'user');
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
writeFileSync(path, '[Unit]\nDescription=fake\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const baseManifest = (overrides: Partial<ModuleManifest> = {}): ModuleManifest => ({
|
|
48
|
+
celilo_contract: '1.0',
|
|
49
|
+
id: 'mod',
|
|
50
|
+
name: 'Mod',
|
|
51
|
+
version: '1.0.0',
|
|
52
|
+
requires: { capabilities: [] },
|
|
53
|
+
provides: { capabilities: [] },
|
|
54
|
+
variables: { owns: [], imports: [] },
|
|
55
|
+
...overrides,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('checkDispatcher', () => {
|
|
59
|
+
let dir: string;
|
|
60
|
+
let dbPath: string;
|
|
61
|
+
let home: string;
|
|
62
|
+
let bus: Bus;
|
|
63
|
+
// Use real wall-clock: bus.health() reads Date.now() internally, so a
|
|
64
|
+
// synthetic past `now` would make every seeded heartbeat look stale.
|
|
65
|
+
let now: number;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
dir = mkdtempSync(join(tmpdir(), 'fleet-disp-'));
|
|
69
|
+
dbPath = join(dir, 'events.db');
|
|
70
|
+
home = join(dir, 'home');
|
|
71
|
+
now = Date.now();
|
|
72
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
73
|
+
bus = openBus({ dbPath, events: defineEvents({}) });
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
bus.close();
|
|
77
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
78
|
+
try {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('fails when no dispatcher has ever heartbeated', () => {
|
|
86
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
87
|
+
expect(f.status).toBe('fail');
|
|
88
|
+
expect(f.summary).toContain('no live event dispatcher');
|
|
89
|
+
expect(f.remediation).toContain('enable --now celilo-events.service');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('passes when running, supervised, current, and not behind on timer ticks', () => {
|
|
93
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
94
|
+
installFakeUnit(home);
|
|
95
|
+
bus.subscribe({ name: 'namecheap.ddns', pattern: 'timer.tick.15m', handler: 'echo' });
|
|
96
|
+
bus.emitRaw('timer.tick.15m', { interval: '15m' });
|
|
97
|
+
|
|
98
|
+
const f = checkDispatcher(bus, {
|
|
99
|
+
now: now,
|
|
100
|
+
home,
|
|
101
|
+
platform: 'linux',
|
|
102
|
+
installedCodeMtimeMs: now - 2 * MINUTE, // code installed BEFORE the dispatcher started
|
|
103
|
+
});
|
|
104
|
+
expect(f.status).toBe('ok');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('warns when the dispatcher is an orphan (no supervisor unit)', () => {
|
|
108
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
109
|
+
// no unit file written
|
|
110
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
111
|
+
expect(f.status).toBe('warn');
|
|
112
|
+
expect(f.detail.join(' ')).toContain('not under a supervisor');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('warns when the dispatcher started before the installed code (stale)', () => {
|
|
116
|
+
seedHeartbeat(bus, { startedAt: now - 10 * MINUTE, lastHeartbeat: now - 1000 });
|
|
117
|
+
installFakeUnit(home);
|
|
118
|
+
const f = checkDispatcher(bus, {
|
|
119
|
+
now: now,
|
|
120
|
+
home,
|
|
121
|
+
platform: 'linux',
|
|
122
|
+
installedCodeMtimeMs: now - 5 * MINUTE, // code newer than the running dispatcher
|
|
123
|
+
});
|
|
124
|
+
expect(f.status).toBe('warn');
|
|
125
|
+
expect(f.detail.join(' ')).toContain('stale code');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('warns when a timer subscriber exists but no tick was ever emitted', () => {
|
|
129
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
130
|
+
installFakeUnit(home);
|
|
131
|
+
bus.subscribe({ name: 'namecheap.ddns', pattern: 'timer.tick.15m', handler: 'echo' });
|
|
132
|
+
// no tick emitted
|
|
133
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
134
|
+
expect(f.status).toBe('warn');
|
|
135
|
+
expect(f.detail.join(' ')).toContain('no such tick has ever been emitted');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('warns when the newest timer tick is older than the window', () => {
|
|
139
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
140
|
+
installFakeUnit(home);
|
|
141
|
+
bus.subscribe({ name: 'namecheap.ddns', pattern: 'timer.tick.15m', handler: 'echo' });
|
|
142
|
+
bus.emitRaw('timer.tick.15m', { interval: '15m' });
|
|
143
|
+
// tick exists but is ancient relative to now (emitRaw stamps Date.now()).
|
|
144
|
+
bus.db.run("UPDATE events SET emitted_at = ? WHERE type = 'timer.tick.15m'", [
|
|
145
|
+
now - 30 * MINUTE,
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
149
|
+
expect(f.status).toBe('warn');
|
|
150
|
+
expect(f.detail.join(' ')).toContain('not emitting on schedule');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
155
|
+
let dir: string;
|
|
156
|
+
let dbPath: string;
|
|
157
|
+
let busPath: string;
|
|
158
|
+
let db: DbClient;
|
|
159
|
+
let bus: Bus;
|
|
160
|
+
|
|
161
|
+
beforeEach(async () => {
|
|
162
|
+
dir = mkdtempSync(join(tmpdir(), 'fleet-subs-'));
|
|
163
|
+
dbPath = join(dir, 'celilo.db');
|
|
164
|
+
busPath = join(dir, 'events.db');
|
|
165
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
166
|
+
process.env.EVENT_BUS_DB = busPath;
|
|
167
|
+
db = await setupTestDatabase(dbPath);
|
|
168
|
+
bus = openBus({ dbPath: busPath, events: defineEvents({}) });
|
|
169
|
+
});
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
bus.close();
|
|
172
|
+
db.$client.close();
|
|
173
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
174
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
175
|
+
try {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
function insertModule(id: string, manifest: ModuleManifest, state = 'VERIFIED' as const): void {
|
|
183
|
+
db.insert(modules)
|
|
184
|
+
.values({
|
|
185
|
+
id,
|
|
186
|
+
name: manifest.name,
|
|
187
|
+
version: manifest.version,
|
|
188
|
+
state,
|
|
189
|
+
manifestData: manifest as unknown as Record<string, unknown>,
|
|
190
|
+
sourcePath: `/src/${id}`,
|
|
191
|
+
})
|
|
192
|
+
.run();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
describe('checkSubscribers', () => {
|
|
196
|
+
it('fails when a deployed module declares a subscription the bus is missing', () => {
|
|
197
|
+
insertModule(
|
|
198
|
+
'lunacycle',
|
|
199
|
+
baseManifest({
|
|
200
|
+
id: 'lunacycle',
|
|
201
|
+
subscriptions: [{ name: 'smoke', pattern: 'deploy.completed.$self', handler: 'echo' }],
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
const f = checkSubscribers(bus, db);
|
|
205
|
+
expect(f.status).toBe('fail');
|
|
206
|
+
expect(f.autoFixable).toBe(true);
|
|
207
|
+
expect(f.detail.join(' ')).toContain('lunacycle.smoke');
|
|
208
|
+
expect(f.remediation).toContain('resync-subscriptions');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('passes once the subscription is registered on the bus', () => {
|
|
212
|
+
insertModule(
|
|
213
|
+
'lunacycle',
|
|
214
|
+
baseManifest({
|
|
215
|
+
id: 'lunacycle',
|
|
216
|
+
subscriptions: [{ name: 'smoke', pattern: 'deploy.completed.$self', handler: 'echo' }],
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
bus.subscribe({
|
|
220
|
+
name: 'lunacycle.smoke',
|
|
221
|
+
pattern: 'deploy.completed.lunacycle',
|
|
222
|
+
handler: 'echo',
|
|
223
|
+
});
|
|
224
|
+
const f = checkSubscribers(bus, db);
|
|
225
|
+
expect(f.status).toBe('ok');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('warns about a stale subscriber with no deployed module', () => {
|
|
229
|
+
// No modules deployed, but a leftover subscriber lingers.
|
|
230
|
+
bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo' });
|
|
231
|
+
const f = checkSubscribers(bus, db);
|
|
232
|
+
expect(f.status).toBe('warn');
|
|
233
|
+
expect(f.detail.join(' ')).toContain('ghost.sub');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('checkCapabilityProviders', () => {
|
|
238
|
+
const consumer = (deriveFrom: string) =>
|
|
239
|
+
baseManifest({
|
|
240
|
+
id: 'forgejo',
|
|
241
|
+
name: 'Forgejo',
|
|
242
|
+
variables: {
|
|
243
|
+
owns: [
|
|
244
|
+
{
|
|
245
|
+
name: 'idp_dmz_ip',
|
|
246
|
+
type: 'string',
|
|
247
|
+
required: true,
|
|
248
|
+
source: 'capability',
|
|
249
|
+
derive_from: deriveFrom,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
imports: [],
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('fails when the consumed capability has no deployed provider', () => {
|
|
257
|
+
insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
|
|
258
|
+
const f = checkCapabilityProviders(db);
|
|
259
|
+
expect(f.status).toBe('fail');
|
|
260
|
+
expect(f.detail.join(' ')).toContain("no deployed module provides 'idp'");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('fails when the provider lacks the referenced field', () => {
|
|
264
|
+
insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
|
|
265
|
+
insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
|
|
266
|
+
db.insert(capabilitiesTable)
|
|
267
|
+
.values({
|
|
268
|
+
moduleId: 'authentik',
|
|
269
|
+
capabilityName: 'idp',
|
|
270
|
+
version: '1.0.0',
|
|
271
|
+
data: { auth_url: 'x' },
|
|
272
|
+
})
|
|
273
|
+
.run();
|
|
274
|
+
const f = checkCapabilityProviders(db);
|
|
275
|
+
expect(f.status).toBe('fail');
|
|
276
|
+
expect(f.detail.join(' ')).toContain('has no value there');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('passes when the provider carries a concrete value', () => {
|
|
280
|
+
insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
|
|
281
|
+
insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
|
|
282
|
+
db.insert(capabilitiesTable)
|
|
283
|
+
.values({
|
|
284
|
+
moduleId: 'authentik',
|
|
285
|
+
capabilityName: 'idp',
|
|
286
|
+
version: '1.0.0',
|
|
287
|
+
data: { dmz_ip: '10.0.10.10' },
|
|
288
|
+
})
|
|
289
|
+
.run();
|
|
290
|
+
const f = checkCapabilityProviders(db);
|
|
291
|
+
expect(f.status).toBe('ok');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('passes but flags a derived ref for the chain trace (ISS-0114)', () => {
|
|
295
|
+
// The workstream-B case: idp.dmz_ip is present but is itself a ref
|
|
296
|
+
// ($self:caddy_dmz_ip) — we can't verify it resolves without the walker.
|
|
297
|
+
insertModule('forgejo', consumer('$capability:idp.dmz_ip'));
|
|
298
|
+
insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
|
|
299
|
+
db.insert(capabilitiesTable)
|
|
300
|
+
.values({
|
|
301
|
+
moduleId: 'authentik',
|
|
302
|
+
capabilityName: 'idp',
|
|
303
|
+
version: '1.0.0',
|
|
304
|
+
data: { dmz_ip: '$self:caddy_dmz_ip' },
|
|
305
|
+
})
|
|
306
|
+
.run();
|
|
307
|
+
const f = checkCapabilityProviders(db);
|
|
308
|
+
expect(f.status).toBe('ok');
|
|
309
|
+
expect(f.detail.join(' ')).toContain('celilo capability chain');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('checkServiceDns', () => {
|
|
314
|
+
const NAT_IP = '192.168.0.253';
|
|
315
|
+
|
|
316
|
+
function seedFirewall(moduleId: string, natIp: string): void {
|
|
317
|
+
insertModule(moduleId, baseManifest({ id: moduleId, name: moduleId }));
|
|
318
|
+
db.insert(capabilitiesTable)
|
|
319
|
+
.values({ moduleId, capabilityName: 'firewall', version: '1.0.0', data: {} })
|
|
320
|
+
.run();
|
|
321
|
+
db.insert(moduleConfigs)
|
|
322
|
+
.values({ moduleId, key: 'nat_ip', value: natIp, valueJson: JSON.stringify(natIp) })
|
|
323
|
+
.run();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function seedSystem(moduleId: string, ip: string, zone: 'dmz' | 'internal'): void {
|
|
327
|
+
db.insert(moduleSystems)
|
|
328
|
+
.values({
|
|
329
|
+
moduleId,
|
|
330
|
+
name: 'main',
|
|
331
|
+
hostname: moduleId,
|
|
332
|
+
ipv4Address: ip,
|
|
333
|
+
zone,
|
|
334
|
+
infraType: 'container_service',
|
|
335
|
+
})
|
|
336
|
+
.run();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function seedRecord(provider: string, consumer: string, host: string, ip: string): void {
|
|
340
|
+
db.insert(dnsInternalRecords)
|
|
341
|
+
.values({ providerModuleId: provider, consumerModuleId: consumer, host, ip })
|
|
342
|
+
.run();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
it('is ok when no internal DNS records are registered', async () => {
|
|
346
|
+
const f = await checkServiceDns(db);
|
|
347
|
+
expect(f.status).toBe('ok');
|
|
348
|
+
expect(f.summary).toContain('no internal DNS records');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('skips (ok) when records exist but no firewall natIp is configured', async () => {
|
|
352
|
+
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
353
|
+
insertModule('caddy', baseManifest({ id: 'caddy', name: 'Caddy' }));
|
|
354
|
+
seedRecord('technitium', 'caddy', 'git.celilo.computer', '10.0.10.10');
|
|
355
|
+
const f = await checkServiceDns(db);
|
|
356
|
+
expect(f.status).toBe('ok');
|
|
357
|
+
expect(f.detail.join(' ')).toContain('no firewall');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('is ok when a service record points at the natIp', async () => {
|
|
361
|
+
seedFirewall('iptables', NAT_IP);
|
|
362
|
+
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
363
|
+
insertModule('caddy', baseManifest({ id: 'caddy', name: 'Caddy' }));
|
|
364
|
+
seedRecord('technitium', 'caddy', 'git.celilo.computer', NAT_IP);
|
|
365
|
+
const f = await checkServiceDns(db);
|
|
366
|
+
expect(f.status).toBe('ok');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('fails when a record points at a segmented-zone container IP', async () => {
|
|
370
|
+
seedFirewall('iptables', NAT_IP);
|
|
371
|
+
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
372
|
+
insertModule('forgejo', baseManifest({ id: 'forgejo', name: 'Forgejo' }));
|
|
373
|
+
seedSystem('forgejo', '10.0.20.14', 'dmz');
|
|
374
|
+
seedRecord('technitium', 'forgejo', 'git-ssh.git.celilo.computer', '10.0.20.14');
|
|
375
|
+
const f = await checkServiceDns(db);
|
|
376
|
+
expect(f.status).toBe('fail');
|
|
377
|
+
expect(f.detail.join(' ')).toContain('dmz-zone container IP');
|
|
378
|
+
expect(f.remediation).toContain('natIp');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('is ok when a record points at an internal-zone (LAN) system IP', async () => {
|
|
382
|
+
seedFirewall('iptables', NAT_IP);
|
|
383
|
+
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
384
|
+
insertModule('homebridge', baseManifest({ id: 'homebridge', name: 'Homebridge' }));
|
|
385
|
+
seedSystem('homebridge', '192.168.0.50', 'internal');
|
|
386
|
+
seedRecord('technitium', 'homebridge', 'hb.celilo.computer', '192.168.0.50');
|
|
387
|
+
const f = await checkServiceDns(db);
|
|
388
|
+
expect(f.status).toBe('ok');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('warns when a record points at neither the natIp nor a known system IP', async () => {
|
|
392
|
+
seedFirewall('iptables', NAT_IP);
|
|
393
|
+
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
394
|
+
insertModule('caddy', baseManifest({ id: 'caddy', name: 'Caddy' }));
|
|
395
|
+
seedRecord('technitium', 'caddy', 'stale.celilo.computer', '203.0.113.9');
|
|
396
|
+
const f = await checkServiceDns(db);
|
|
397
|
+
expect(f.status).toBe('warn');
|
|
398
|
+
expect(f.detail.join(' ')).toContain('known system IP');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('does not crash when the ledger table is missing (defers to schema check)', async () => {
|
|
402
|
+
db.$client.run('DROP TABLE dns_internal_records');
|
|
403
|
+
const f = await checkServiceDns(db);
|
|
404
|
+
expect(f.status).toBe('ok');
|
|
405
|
+
expect(f.summary).toContain('ledger not present');
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe('checkSchemaDrift', () => {
|
|
410
|
+
it('is ok when every schema table is present (fresh migrated DB)', () => {
|
|
411
|
+
const f = checkSchemaDrift(db);
|
|
412
|
+
expect(f.status).toBe('ok');
|
|
413
|
+
expect(f.summary).toContain('schema tables present');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('fails and names a table the running CLI expects but the DB lacks', () => {
|
|
417
|
+
db.$client.run('DROP TABLE dns_internal_records');
|
|
418
|
+
const f = checkSchemaDrift(db);
|
|
419
|
+
expect(f.status).toBe('fail');
|
|
420
|
+
expect(f.detail.join(' ')).toContain('dns_internal_records');
|
|
421
|
+
expect(f.remediation).toContain('migrations');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('findBrokenCapabilityDerivations (shared predicate)', () => {
|
|
427
|
+
const consumer = baseManifest({
|
|
428
|
+
id: 'forgejo',
|
|
429
|
+
variables: {
|
|
430
|
+
owns: [
|
|
431
|
+
{
|
|
432
|
+
name: 'idp_dmz_ip',
|
|
433
|
+
type: 'string',
|
|
434
|
+
required: true,
|
|
435
|
+
source: 'capability',
|
|
436
|
+
derive_from: '$capability:idp.dmz_ip',
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
imports: [],
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('flags no-provider when the capability is absent from the map', () => {
|
|
444
|
+
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {});
|
|
445
|
+
expect(problems).toHaveLength(1);
|
|
446
|
+
expect(problems[0].reason).toBe('no-provider');
|
|
447
|
+
expect(describeCapabilityProblem(problems[0])).toContain("no deployed module provides 'idp'");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('flags empty-value when the field is present but empty', () => {
|
|
451
|
+
const problems = findBrokenCapabilityDerivations('forgejo', consumer, { idp: { dmz_ip: '' } });
|
|
452
|
+
expect(problems[0].reason).toBe('empty-value');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('flags unresolved-ref when the resolved value is still a template', () => {
|
|
456
|
+
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
|
|
457
|
+
idp: { dmz_ip: '$self:caddy_dmz_ip' },
|
|
458
|
+
});
|
|
459
|
+
expect(problems[0].reason).toBe('unresolved-ref');
|
|
460
|
+
expect(problems[0].value).toBe('$self:caddy_dmz_ip');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('returns nothing when the field resolves to a concrete value', () => {
|
|
464
|
+
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
|
|
465
|
+
idp: { dmz_ip: '10.0.10.10' },
|
|
466
|
+
});
|
|
467
|
+
expect(problems).toHaveLength(0);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('walks dotted paths', () => {
|
|
471
|
+
const m = baseManifest({
|
|
472
|
+
id: 'consumer',
|
|
473
|
+
variables: {
|
|
474
|
+
owns: [
|
|
475
|
+
{
|
|
476
|
+
name: 'primary',
|
|
477
|
+
type: 'string',
|
|
478
|
+
required: true,
|
|
479
|
+
source: 'capability',
|
|
480
|
+
derive_from: '$capability:dns_external.server.ip.primary',
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
imports: [],
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
const ok = findBrokenCapabilityDerivations('consumer', m, {
|
|
487
|
+
dns_external: { server: { ip: { primary: '1.2.3.4' } } },
|
|
488
|
+
});
|
|
489
|
+
expect(ok).toHaveLength(0);
|
|
490
|
+
const broken = findBrokenCapabilityDerivations('consumer', m, {
|
|
491
|
+
dns_external: { server: { ip: {} } },
|
|
492
|
+
});
|
|
493
|
+
expect(broken[0].reason).toBe('empty-value');
|
|
494
|
+
});
|
|
495
|
+
});
|