@celilo/cli 0.5.0-alpha.3 → 0.5.0-alpha.5

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Internal split-horizon DNS A-record ledger (designs/CELILO_DOCTOR_FLEET_DRIFT.md
3
+ * Phase 4, ISS-0094 / ISS-0111).
4
+ *
5
+ * The dns_internal capability (technitium/knot) registers records straight
6
+ * into the resolver's own DB, leaving celilo no offline record of what it
7
+ * asked to be served. This ledger — the internal-DNS sibling of
8
+ * `dns_registrations` — records every successful `registerRecord({type:'A'})`
9
+ * so `celilo system doctor` can assert, without a live resolver probe, that
10
+ * service hostnames resolve to the firewall natIp (LAN-reachable) rather than
11
+ * a zone-side container IP a LAN device can't route to.
12
+ *
13
+ * Row lifecycle is FK cascade — records die with their provider or consumer.
14
+ */
15
+
16
+ import type { DnsInternalCapability, DnsRecordRequest } from '@celilo/capabilities';
17
+ import { and, eq } from 'drizzle-orm';
18
+ import type { DbClient } from '../db/client';
19
+ import { dnsInternalRecords } from '../db/schema';
20
+
21
+ export interface DnsInternalRecordRow {
22
+ host: string;
23
+ ip: string;
24
+ providerModuleId: string;
25
+ consumerModuleId: string;
26
+ registeredAt: Date;
27
+ }
28
+
29
+ export function listDnsInternalRecords(
30
+ db: DbClient,
31
+ options: { providerModuleId?: string } = {},
32
+ ): DnsInternalRecordRow[] {
33
+ const query = db
34
+ .select({
35
+ host: dnsInternalRecords.host,
36
+ ip: dnsInternalRecords.ip,
37
+ providerModuleId: dnsInternalRecords.providerModuleId,
38
+ consumerModuleId: dnsInternalRecords.consumerModuleId,
39
+ registeredAt: dnsInternalRecords.registeredAt,
40
+ })
41
+ .from(dnsInternalRecords);
42
+ return options.providerModuleId
43
+ ? query.where(eq(dnsInternalRecords.providerModuleId, options.providerModuleId)).all()
44
+ : query.all();
45
+ }
46
+
47
+ export function recordDnsInternalRecord(
48
+ db: DbClient,
49
+ record: { providerModuleId: string; consumerModuleId: string; host: string; ip: string },
50
+ ): void {
51
+ db.insert(dnsInternalRecords)
52
+ .values({
53
+ providerModuleId: record.providerModuleId,
54
+ consumerModuleId: record.consumerModuleId,
55
+ host: record.host,
56
+ ip: record.ip,
57
+ registeredAt: new Date(),
58
+ })
59
+ .onConflictDoUpdate({
60
+ target: [dnsInternalRecords.providerModuleId, dnsInternalRecords.host],
61
+ set: {
62
+ consumerModuleId: record.consumerModuleId,
63
+ ip: record.ip,
64
+ registeredAt: new Date(),
65
+ },
66
+ })
67
+ .run();
68
+ }
69
+
70
+ export function removeDnsInternalRecord(
71
+ db: DbClient,
72
+ record: { providerModuleId: string; host: string },
73
+ ): void {
74
+ db.delete(dnsInternalRecords)
75
+ .where(
76
+ and(
77
+ eq(dnsInternalRecords.providerModuleId, record.providerModuleId),
78
+ eq(dnsInternalRecords.host, record.host),
79
+ ),
80
+ )
81
+ .run();
82
+ }
83
+
84
+ /**
85
+ * Wrap a dns_internal interface so A-record register/delete calls are
86
+ * mirrored into the ledger. Only A records are tracked — they're the
87
+ * host→IP mapping the natIp drift check reasons about; CNAMEs/others pass
88
+ * through unrecorded. A throw from the underlying call propagates before
89
+ * any ledger write, so only a confirmed register/delete earns a row change.
90
+ */
91
+ export function withDnsInternalLedger(
92
+ iface: DnsInternalCapability,
93
+ ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
94
+ ): DnsInternalCapability {
95
+ const isA = (request: DnsRecordRequest) => request.type.toUpperCase() === 'A';
96
+ return {
97
+ ...iface,
98
+ async registerRecord(request: DnsRecordRequest): Promise<void> {
99
+ await iface.registerRecord(request);
100
+ if (isA(request)) {
101
+ recordDnsInternalRecord(ctx.db, {
102
+ providerModuleId: ctx.providerModuleId,
103
+ consumerModuleId: ctx.consumerModuleId,
104
+ host: request.host,
105
+ ip: request.value,
106
+ });
107
+ }
108
+ },
109
+ async deleteRecord(request: DnsRecordRequest): Promise<void> {
110
+ await iface.deleteRecord(request);
111
+ if (isA(request)) {
112
+ removeDnsInternalRecord(ctx.db, {
113
+ providerModuleId: ctx.providerModuleId,
114
+ host: request.host,
115
+ });
116
+ }
117
+ },
118
+ };
119
+ }
@@ -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
+ });