@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -0,0 +1,678 @@
1
+ /**
2
+ * Fleet runtime drift detection — the predicates behind `celilo system
3
+ * doctor`'s fleet section (designs/CELILO_DOCTOR_FLEET_DRIFT.md, ISS-0113).
4
+ *
5
+ * Every facet of post-migration drift that workstream B caught by hand —
6
+ * a dead/stale dispatcher (ISS-0086), an empty `subscribers` table
7
+ * (ISS-0088), a capability chain that never re-derived (ISS-0095 /
8
+ * idp_dmz_ip) — becomes one check here. Each check asserts the *outcome*,
9
+ * not a proxy for it (design D5): "a dispatcher process exists" is not the
10
+ * same as "it's the supervised, current one that's actually emitting timer
11
+ * ticks", and the difference is exactly the bug B hit.
12
+ *
13
+ * Detection is read-only and cheap (design D3). These predicates are also
14
+ * the building blocks the defensive wiring (workstream D) and the celilo
15
+ * MCP (ISS-0112) call — so they take their bus + DB handles as arguments
16
+ * and return structured findings rather than rendering or exiting. The
17
+ * rendering + `--fix` orchestration lives in the doctor command.
18
+ */
19
+
20
+ import type { Bus } from '@celilo/event-bus';
21
+ import { inArray } from 'drizzle-orm';
22
+ import { getModuleStoragePath } from '../config/paths';
23
+ import type { DbClient } from '../db/client';
24
+ import { capabilities as capabilitiesTable, modules } from '../db/schema';
25
+ import { findSchemaDrift } from '../db/schema-introspection';
26
+ import { resolveFirewallNatIp } from '../hooks/capability-loader';
27
+ import type { ModuleManifest } from '../manifest/schema';
28
+ import { getModuleSystems } from './deployed-systems';
29
+ import { listDnsInternalRecords } from './dns-internal-records';
30
+ import { type SupervisorPlatform, readInstalledUnit } from './events-daemon';
31
+ import { resolveSubscription } from './module-subscriptions';
32
+
33
+ /**
34
+ * Zones reachable from the operator's LAN. A celilo placement zone other
35
+ * than `internal` is firewall-segmented — an unmanaged LAN device has no
36
+ * route into it, so an internal-DNS record pointing at a container IP there
37
+ * is unreachable (the bug). The `internal` zone IS the LAN, so a record at
38
+ * one of its systems' IPs is fine. (Never pin literal subnets to zones —
39
+ * compare by the zone role the system carries.)
40
+ */
41
+ const LAN_REACHABLE_ZONE = 'internal';
42
+
43
+ /**
44
+ * Records under the dedicated `.infra.<zone>` label are system-IDENTITY names,
45
+ * registered by modules/technitium/scripts/on-system-event.ts at the system's
46
+ * own container IP ON PURPOSE — the zone-side name for in-zone / VPN access,
47
+ * deliberately kept separate from the natIp records LAN devices use (so a
48
+ * container-IP identity record doesn't clobber the natIp record public_web
49
+ * needs). They are NOT LAN-reachability records, so the natIp rule doesn't
50
+ * apply to them — the service-DNS check skips them.
51
+ */
52
+ const SYSTEM_IDENTITY_HOST = /\.infra\./;
53
+
54
+ export type FleetFindingStatus = 'ok' | 'warn' | 'fail';
55
+
56
+ /**
57
+ * One drift facet's verdict. `autoFixable` marks the checks `--fix` may
58
+ * run unattended (today: subscribers resync only) — everything else is
59
+ * report + a named manual remediation, never a surprise prod redeploy.
60
+ */
61
+ export interface FleetFinding {
62
+ /** Stable id for the check (e.g. 'dispatcher'); not user-facing prose. */
63
+ id: string;
64
+ title: string;
65
+ status: FleetFindingStatus;
66
+ summary: string;
67
+ /** Extra context lines, rendered indented under the summary. */
68
+ detail: string[];
69
+ /** A concrete next step, or null when status is ok. */
70
+ remediation: string | null;
71
+ autoFixable: boolean;
72
+ }
73
+
74
+ /**
75
+ * A timer subscriber should see a fresh tick within its interval plus
76
+ * slack. 15m is the shortest DDNS-refresh cadence (namecheap); a tick
77
+ * older than this means the dispatcher isn't emitting (the workstream-B
78
+ * stale-orphan symptom). One window covers the common case without
79
+ * parsing every interval name.
80
+ */
81
+ const TIMER_TICK_MAX_AGE_MS = 20 * 60 * 1000;
82
+
83
+ const UNRESOLVED_REF = /\$\{?(?:self|capability|infra|infrastructure|system|secret):/;
84
+
85
+ /** Worst of a set of statuses (fail > warn > ok). */
86
+ function worst(statuses: FleetFindingStatus[]): FleetFindingStatus {
87
+ if (statuses.includes('fail')) return 'fail';
88
+ if (statuses.includes('warn')) return 'warn';
89
+ return 'ok';
90
+ }
91
+
92
+ /**
93
+ * The DB schema the running CLI expects must actually exist on this box.
94
+ * celilo only runs drizzle's `migrate()` on a FRESH database; an existing
95
+ * install gets a hand-maintained CREATE/ALTER list in db/client.ts instead
96
+ * (ISS-0100). When that list drifts from the shipped migrations — e.g. a new
97
+ * migration adds a table nobody added to the list — the running code expects
98
+ * a table/column the DB doesn't have, and features fail at runtime.
99
+ *
100
+ * This asserts the outcome directly (track-agnostic): every table + column in
101
+ * the code's drizzle schema is present in the DB. A miss means migrations
102
+ * haven't reached this box. It does NOT count migration rows — an existing DB
103
+ * patched via the hand list legitimately lags `__drizzle_migrations` while
104
+ * its schema is current, so presence is the honest signal.
105
+ */
106
+ export function checkSchemaDrift(db: DbClient): FleetFinding {
107
+ const { missingTables, missingColumns, tableCount } = findSchemaDrift(db.$client);
108
+
109
+ const detail: string[] = [];
110
+ if (missingTables.length > 0) detail.push(`missing table(s): ${missingTables.join(', ')}`);
111
+ if (missingColumns.length > 0) detail.push(`missing column(s): ${missingColumns.join(', ')}`);
112
+ const status: FleetFindingStatus = detail.length > 0 ? 'fail' : 'ok';
113
+
114
+ return {
115
+ id: 'schema',
116
+ title: 'database schema matches the running CLI (migrations applied)',
117
+ status,
118
+ summary:
119
+ status === 'ok'
120
+ ? `all ${tableCount} schema tables present`
121
+ : 'database schema is behind the running CLI — migrations not applied',
122
+ detail,
123
+ remediation:
124
+ status === 'ok'
125
+ ? null
126
+ : 'run `celilo system migrate` to apply pending migrations on this box — see ISS-0100',
127
+ autoFixable: false,
128
+ };
129
+ }
130
+
131
+ interface HeartbeatRow {
132
+ dispatcher_id: string;
133
+ last_heartbeat: number;
134
+ started_at: number;
135
+ pid: number;
136
+ version: string;
137
+ }
138
+
139
+ export interface DispatcherCheckOptions {
140
+ now?: number;
141
+ /**
142
+ * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
143
+ * package.json). A dispatcher whose `started_at` predates this is
144
+ * running stale in-memory code — the exact workstream-B orphan. Omit
145
+ * to skip the staleness aspect (e.g. unit tests, or when the package
146
+ * can't be located).
147
+ */
148
+ installedCodeMtimeMs?: number | null;
149
+ /** Override for readInstalledUnit — tests point this at a temp home. */
150
+ home?: string;
151
+ platform?: SupervisorPlatform;
152
+ }
153
+
154
+ /**
155
+ * The dispatcher check is four-part (design D5): a dispatcher is (1)
156
+ * running, (2) the *supervised* one (survives reboot, not an orphan),
157
+ * (3) running *current* code, and (4) actually emitting timer ticks +
158
+ * draining deliveries. A naive "is a process up?" check reports green
159
+ * while broken — that's the trap this exists to avoid.
160
+ */
161
+ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): FleetFinding {
162
+ const now = opts.now ?? Date.now();
163
+ const health = bus.health();
164
+ const hb = bus.db
165
+ .query<HeartbeatRow, []>(
166
+ 'SELECT dispatcher_id, last_heartbeat, started_at, pid, version FROM dispatcher_heartbeat ORDER BY last_heartbeat DESC LIMIT 1',
167
+ )
168
+ .get();
169
+
170
+ const detail: string[] = [];
171
+ const statuses: FleetFindingStatus[] = [];
172
+ const remediations: string[] = [];
173
+
174
+ // (1) running — a fresh heartbeat. health() already classifies a
175
+ // stale/absent heartbeat as no_dispatcher.
176
+ if (health.status === 'no_dispatcher' || !hb) {
177
+ statuses.push('fail');
178
+ detail.push(
179
+ 'no live dispatcher — heartbeat absent or stale (events are queueing, not delivered)',
180
+ );
181
+ remediations.push(
182
+ 'start the dispatcher: `systemctl --user enable --now celilo-events.service` (or `celilo events install-daemon` then enable it)',
183
+ );
184
+ // Without a heartbeat there's nothing more to assert about it.
185
+ return {
186
+ id: 'dispatcher',
187
+ title: 'event dispatcher running, supervised & current',
188
+ status: 'fail',
189
+ summary: 'no live event dispatcher',
190
+ detail,
191
+ remediation: remediations.join('; '),
192
+ autoFixable: false,
193
+ };
194
+ }
195
+
196
+ const ageMs = health.lastHeartbeatAgeMs ?? now - hb.last_heartbeat;
197
+ detail.push(
198
+ `running (pid ${hb.pid}, heartbeat ${Math.round(ageMs / 1000)}s ago, code v${hb.version})`,
199
+ );
200
+ if (health.status === 'stuck') {
201
+ statuses.push('warn');
202
+ detail.push(
203
+ `${health.stuckRunningCount} delivery(ies) stuck in 'running' — possible crashed handler`,
204
+ );
205
+ remediations.push('`celilo events repair` to sweep stuck deliveries');
206
+ }
207
+
208
+ // (2) supervised — a unit file exists (user or system scope). A
209
+ // running dispatcher with NO unit is the orphan case: works now, gone
210
+ // after reboot.
211
+ const supervised =
212
+ readInstalledUnit({ scope: 'user', home: opts.home, platform: opts.platform }).exists ||
213
+ readInstalledUnit({ scope: 'system', home: opts.home, platform: opts.platform }).exists;
214
+ if (!supervised) {
215
+ statuses.push('warn');
216
+ detail.push('not under a supervisor unit — will not survive a reboot (orphan process)');
217
+ remediations.push('`celilo events install-daemon` then enable the unit so it is supervised');
218
+ }
219
+
220
+ // (3) current — started before the installed code was last written ⇒
221
+ // running stale in-memory code (delivers, but may not emit new event
222
+ // types like timer ticks). The workstream-B orphan, exactly.
223
+ if (opts.installedCodeMtimeMs != null && hb.started_at < opts.installedCodeMtimeMs) {
224
+ statuses.push('warn');
225
+ const startedAgoMin = Math.round((now - hb.started_at) / 60000);
226
+ detail.push(
227
+ `started ${startedAgoMin}min ago — before the last code update; running stale code, restart to pick it up`,
228
+ );
229
+ remediations.push('restart the dispatcher: `systemctl --user restart celilo-events.service`');
230
+ }
231
+
232
+ // (4) emitting + delivering. Only assert ticks if something subscribes
233
+ // to a timer (no subscriber ⇒ no expectation). Assert no piled-up
234
+ // failed deliveries either way.
235
+ const timerSub = bus.db
236
+ .query<{ pattern: string }, []>(
237
+ "SELECT pattern FROM subscribers WHERE pattern LIKE 'timer.tick.%' LIMIT 1",
238
+ )
239
+ .get();
240
+ if (timerSub) {
241
+ const latestTick = bus.recentEvents({ type: timerSub.pattern, limit: 1 })[0];
242
+ if (!latestTick) {
243
+ statuses.push('warn');
244
+ detail.push(
245
+ `a subscriber wants '${timerSub.pattern}' but no such tick has ever been emitted (refresh/DDNS not firing)`,
246
+ );
247
+ remediations.push('restart the dispatcher so it emits timer ticks');
248
+ } else if (now - latestTick.emittedAt > TIMER_TICK_MAX_AGE_MS) {
249
+ statuses.push('warn');
250
+ const ageMin = Math.round((now - latestTick.emittedAt) / 60000);
251
+ detail.push(
252
+ `last '${timerSub.pattern}' was ${ageMin}min ago — dispatcher not emitting on schedule`,
253
+ );
254
+ remediations.push('restart the dispatcher so it resumes emitting timer ticks');
255
+ }
256
+ }
257
+
258
+ const failed = bus.failedDeliveries({ limit: 50 });
259
+ if (failed.length > 0) {
260
+ statuses.push('warn');
261
+ const sample = failed[0]?.lastError ? ` (e.g. ${failed[0].lastError.split('\n')[0]})` : '';
262
+ detail.push(`${failed.length} failed/abandoned delivery(ies)${sample}`);
263
+ remediations.push('inspect failed deliveries and re-emit/repair as needed');
264
+ }
265
+
266
+ const status = worst(statuses);
267
+ return {
268
+ id: 'dispatcher',
269
+ title: 'event dispatcher running, supervised & current',
270
+ status,
271
+ summary:
272
+ status === 'ok'
273
+ ? 'dispatcher healthy, supervised, current, and emitting'
274
+ : 'dispatcher running but degraded',
275
+ detail,
276
+ remediation: remediations.length > 0 ? remediations.join('; ') : null,
277
+ autoFixable: false,
278
+ };
279
+ }
280
+
281
+ /** A deployed module + its parsed manifest (INSTALLED/VERIFIED only). */
282
+ interface DeployedModule {
283
+ id: string;
284
+ manifest: ModuleManifest;
285
+ }
286
+
287
+ function loadDeployedModules(db: DbClient): DeployedModule[] {
288
+ const rows = db
289
+ .select()
290
+ .from(modules)
291
+ .where(inArray(modules.state, ['INSTALLED', 'VERIFIED']))
292
+ .all();
293
+ return rows.map((m) => ({ id: m.id, manifest: m.manifestData as unknown as ModuleManifest }));
294
+ }
295
+
296
+ /**
297
+ * The bus `subscribers` table must reflect what the deployed fleet's
298
+ * manifests declare. A restore/migration starts it EMPTY (ISS-0088), so
299
+ * every reactive subscription silently vanishes until a resync or a
300
+ * redeploy. Missing rows fail; stale rows (a since-removed module) warn.
301
+ */
302
+ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
303
+ const deployed = loadDeployedModules(db);
304
+
305
+ // Expected: every (scoped name → pattern) the deployed manifests declare.
306
+ const expected = new Map<string, string>();
307
+ for (const mod of deployed) {
308
+ const subs = mod.manifest.subscriptions ?? [];
309
+ const modulePath = `${getModuleStoragePath()}/${mod.id}`;
310
+ for (const sub of subs) {
311
+ const resolved = resolveSubscription(sub, mod.id, modulePath);
312
+ expected.set(resolved.name, resolved.pattern);
313
+ }
314
+ }
315
+
316
+ const actualRows = bus.db
317
+ .query<{ name: string; pattern: string }, []>('SELECT name, pattern FROM subscribers')
318
+ .all();
319
+ const actual = new Map(actualRows.map((r) => [r.name, r.pattern]));
320
+
321
+ const missing: string[] = [];
322
+ const mismatched: string[] = [];
323
+ for (const [name, pattern] of expected) {
324
+ const have = actual.get(name);
325
+ if (have === undefined) missing.push(name);
326
+ else if (have !== pattern) mismatched.push(`${name} (manifest: ${pattern}, bus: ${have})`);
327
+ }
328
+ const stale = actualRows.map((r) => r.name).filter((name) => !expected.has(name));
329
+
330
+ const detail: string[] = [];
331
+ const statuses: FleetFindingStatus[] = [];
332
+ if (missing.length > 0) {
333
+ statuses.push('fail');
334
+ detail.push(
335
+ `${missing.length} subscription(s) declared by the fleet but missing from the bus: ${missing.join(', ')}`,
336
+ );
337
+ }
338
+ if (mismatched.length > 0) {
339
+ statuses.push('warn');
340
+ detail.push(
341
+ `${mismatched.length} subscription(s) with a pattern the bus disagrees on: ${mismatched.join('; ')}`,
342
+ );
343
+ }
344
+ if (stale.length > 0) {
345
+ statuses.push('warn');
346
+ detail.push(
347
+ `${stale.length} subscriber(s) on the bus with no deployed module: ${stale.join(', ')}`,
348
+ );
349
+ }
350
+
351
+ const status = worst(statuses);
352
+ return {
353
+ id: 'subscribers',
354
+ title: 'bus subscribers reflect the deployed fleet',
355
+ status,
356
+ summary:
357
+ status === 'ok'
358
+ ? `${expected.size} subscription(s) match the deployed fleet`
359
+ : 'bus subscribers drifted from the deployed fleet',
360
+ detail,
361
+ remediation: status === 'ok' ? null : '`celilo events resync-subscriptions` (safe, idempotent)',
362
+ autoFixable: status !== 'ok',
363
+ };
364
+ }
365
+
366
+ /** A `$capability:<name>.<path>` reference parsed out of a derive_from. */
367
+ interface CapabilityRef {
368
+ variable: string;
369
+ capability: string;
370
+ path: string;
371
+ }
372
+
373
+ export type CapabilityDerivationReason = 'no-provider' | 'empty-value' | 'unresolved-ref';
374
+
375
+ /**
376
+ * A broken `source: capability` derivation found on a consumer module.
377
+ * - `no-provider`: nothing in the capabilities map provides `capability`.
378
+ * - `empty-value`: the field exists but is null/undefined/empty.
379
+ * - `unresolved-ref`: the field is itself an unresolved template ref
380
+ * (e.g. authentik's `idp.dmz_ip = $self:caddy_dmz_ip`) — the chain is
381
+ * broken one+ hops upstream.
382
+ */
383
+ export interface CapabilityDerivationProblem {
384
+ consumerModule: string;
385
+ variable: string;
386
+ capability: string;
387
+ path: string;
388
+ reason: CapabilityDerivationReason;
389
+ /** The offending value, for `unresolved-ref`. */
390
+ value?: string;
391
+ }
392
+
393
+ function parseCapabilityRefs(manifest: ModuleManifest): CapabilityRef[] {
394
+ const refs: CapabilityRef[] = [];
395
+ for (const v of manifest.variables?.owns ?? []) {
396
+ if (v.source !== 'capability' || !v.derive_from) continue;
397
+ const re = /\$\{?capability:([\w-]+)\.([\w.]+)/g;
398
+ let m: RegExpExecArray | null = re.exec(v.derive_from);
399
+ while (m !== null) {
400
+ refs.push({ variable: v.name, capability: m[1], path: m[2] });
401
+ m = re.exec(v.derive_from);
402
+ }
403
+ }
404
+ return refs;
405
+ }
406
+
407
+ /** Walk a dotted path into a JSON object; undefined if any hop is absent. */
408
+ function getNested(data: Record<string, unknown>, path: string): unknown {
409
+ let cur: unknown = data;
410
+ for (const seg of path.split('.')) {
411
+ if (cur == null || typeof cur !== 'object') return undefined;
412
+ cur = (cur as Record<string, unknown>)[seg];
413
+ }
414
+ return cur;
415
+ }
416
+
417
+ /**
418
+ * The shared capability-derivation predicate (design D4: build once, call
419
+ * from the detector AND the preventer). For a consumer manifest and a map
420
+ * of capability-name → data, return every `source: capability` derivation
421
+ * that won't resolve.
422
+ *
423
+ * The `capabilities` map can be either RAW (the doctor reads capability
424
+ * rows straight from the DB, so a still-derived field shows up as
425
+ * `unresolved-ref`) or RESOLVED (deploy preflight / generate pass
426
+ * `ResolutionContext.capabilities`, where `$self:` refs are already
427
+ * substituted against the provider's config — so a broken upstream link
428
+ * shows up as `empty-value` or `unresolved-ref`). Callers decide severity:
429
+ * the doctor treats `unresolved-ref` as "needs the ISS-0114 chain trace"
430
+ * (a note), while preflight/generate treat every reason as a hard error.
431
+ */
432
+ export function findBrokenCapabilityDerivations(
433
+ consumerModule: string,
434
+ manifest: ModuleManifest,
435
+ capabilities: Record<string, Record<string, unknown> | undefined>,
436
+ ): CapabilityDerivationProblem[] {
437
+ const problems: CapabilityDerivationProblem[] = [];
438
+ for (const ref of parseCapabilityRefs(manifest)) {
439
+ const base = {
440
+ consumerModule,
441
+ variable: ref.variable,
442
+ capability: ref.capability,
443
+ path: ref.path,
444
+ };
445
+ const data = capabilities[ref.capability];
446
+ if (!data) {
447
+ problems.push({ ...base, reason: 'no-provider' });
448
+ continue;
449
+ }
450
+ const value = getNested(data, ref.path);
451
+ if (value === undefined || value === null || value === '') {
452
+ problems.push({ ...base, reason: 'empty-value' });
453
+ continue;
454
+ }
455
+ if (typeof value === 'string' && UNRESOLVED_REF.test(value)) {
456
+ problems.push({ ...base, reason: 'unresolved-ref', value });
457
+ }
458
+ }
459
+ return problems;
460
+ }
461
+
462
+ /** One-line human description of a broken derivation, shared by all callers. */
463
+ export function describeCapabilityProblem(p: CapabilityDerivationProblem): string {
464
+ const head = `${p.consumerModule}.${p.variable} derives from $capability:${p.capability}.${p.path}`;
465
+ switch (p.reason) {
466
+ case 'no-provider':
467
+ return `${head}, but no deployed module provides '${p.capability}' — deploy/redeploy its provider first`;
468
+ case 'empty-value':
469
+ return `${head}, but the provider's '${p.capability}' data has no value there — redeploy the provider so it re-registers`;
470
+ case 'unresolved-ref':
471
+ return `${head}, which resolves to an unresolved ref (${p.value}) — its own upstream chain is broken; redeploy the provider chain (provider → consumer)`;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Every `source: capability` variable a deployed module derives must have
477
+ * a live provider whose capability data carries the referenced field
478
+ * (the forgejo `$self:idp_dmz_ip not found` class, ISS-0095/ISS-0115).
479
+ *
480
+ * Reads RAW capability data, so a present-but-still-derived field (e.g.
481
+ * authentik's `idp.dmz_ip = $self:caddy_dmz_ip`) can't be verified here
482
+ * without the backward chain-walker (ISS-0114). Rather than ship a second
483
+ * walker (design D2.1), those `unresolved-ref` cases are flagged as "needs
484
+ * the chain trace" — a note pointing at `celilo capability chain`, not a
485
+ * false-positive fail. (Deploy preflight + generate run the same predicate
486
+ * against the RESOLVED context, where the same break IS a hard error.)
487
+ */
488
+ export function checkCapabilityProviders(db: DbClient): FleetFinding {
489
+ const deployed = loadDeployedModules(db);
490
+ const capRows = db
491
+ .select({ name: capabilitiesTable.capabilityName, data: capabilitiesTable.data })
492
+ .from(capabilitiesTable)
493
+ .all();
494
+ const rawMap: Record<string, Record<string, unknown>> = {};
495
+ for (const r of capRows) rawMap[r.name] = r.data;
496
+
497
+ const breaks: string[] = [];
498
+ const traceNeeded: string[] = [];
499
+ let refCount = 0;
500
+
501
+ for (const mod of deployed) {
502
+ const problems = findBrokenCapabilityDerivations(mod.id, mod.manifest, rawMap);
503
+ refCount += parseCapabilityRefs(mod.manifest).length;
504
+ for (const p of problems) {
505
+ if (p.reason === 'unresolved-ref') {
506
+ traceNeeded.push(
507
+ `${p.consumerModule}.${p.variable} ← ${p.capability}.${p.path} (= ${p.value})`,
508
+ );
509
+ } else {
510
+ breaks.push(describeCapabilityProblem(p));
511
+ }
512
+ }
513
+ }
514
+
515
+ const detail: string[] = [];
516
+ let status: FleetFindingStatus = 'ok';
517
+ if (breaks.length > 0) {
518
+ status = 'fail';
519
+ detail.push(...breaks);
520
+ }
521
+ if (traceNeeded.length > 0) {
522
+ detail.push(
523
+ `${traceNeeded.length} derived value(s) resolve through another capability — verify with \`celilo capability chain <module> <var>\` (ISS-0114): ${traceNeeded.join('; ')}`,
524
+ );
525
+ }
526
+
527
+ return {
528
+ id: 'capability-derived',
529
+ title: 'capability-derived config has live providers',
530
+ status,
531
+ summary:
532
+ status === 'ok'
533
+ ? `${refCount} capability-derived reference(s) have providers`
534
+ : 'capability-derived config is missing a provider',
535
+ detail,
536
+ remediation:
537
+ status === 'ok'
538
+ ? null
539
+ : 'redeploy the provider module(s) so they re-register capability data, then redeploy the consumer (provider → consumer order)',
540
+ autoFixable: false,
541
+ };
542
+ }
543
+
544
+ /**
545
+ * Internal split-horizon DNS records for service hostnames must resolve to
546
+ * the firewall natIp (the LAN-reachable DNAT ingress), not a zone-side
547
+ * container IP a LAN device can't route to (ISS-0094 / ISS-0111). Reads the
548
+ * dns_internal ledger offline — every `registerRecord({type:'A'})` the
549
+ * capability loader saw — and compares each to the natIp:
550
+ * - == natIp → ok
551
+ * - a segmented-zone system's container IP → fail (unroutable from the LAN)
552
+ * - an `internal`-zone system's IP → ok (that zone IS the LAN)
553
+ * - anything else → warn (unknown / possibly stale)
554
+ *
555
+ * Skipped cleanly when no firewall advertises a natIp (a flat network has no
556
+ * segmented zones, so container IPs are reachable).
557
+ */
558
+ export async function checkServiceDns(db: DbClient): Promise<FleetFinding> {
559
+ const base = {
560
+ id: 'service-dns',
561
+ title: 'service DNS points at the firewall natIp',
562
+ autoFixable: false,
563
+ } as const;
564
+
565
+ // The ledger table may be absent on a DB whose schema is behind (ISS-0100).
566
+ // Don't crash the whole doctor — the schema-drift check owns that signal.
567
+ let records: ReturnType<typeof listDnsInternalRecords>;
568
+ try {
569
+ records = listDnsInternalRecords(db);
570
+ } catch {
571
+ return {
572
+ ...base,
573
+ status: 'ok',
574
+ summary: 'internal-DNS ledger not present (schema behind — see the schema check)',
575
+ detail: [],
576
+ remediation: null,
577
+ };
578
+ }
579
+
580
+ if (records.length === 0) {
581
+ return {
582
+ ...base,
583
+ status: 'ok',
584
+ summary: 'no internal DNS records registered',
585
+ detail: [],
586
+ remediation: null,
587
+ };
588
+ }
589
+
590
+ const natIp = await resolveFirewallNatIp(db);
591
+ if (!natIp) {
592
+ return {
593
+ ...base,
594
+ status: 'ok',
595
+ summary: `${records.length} internal DNS record(s); no firewall natIp to check against`,
596
+ detail: ['no firewall advertises a natIp — flat network, container IPs are LAN-reachable'],
597
+ remediation: null,
598
+ };
599
+ }
600
+
601
+ // Map every deployed system's container IP → its zone, so a record can be
602
+ // recognized as pointing at a segmented-zone container (the bug) vs an
603
+ // internal-zone (LAN) system.
604
+ const ipZone = new Map<string, { moduleId: string; zone: string }>();
605
+ for (const mod of loadDeployedModules(db)) {
606
+ for (const sys of getModuleSystems(mod.id, db)) {
607
+ if (sys.ipv4_address) ipZone.set(sys.ipv4_address, { moduleId: mod.id, zone: sys.zone });
608
+ }
609
+ }
610
+
611
+ const atContainer: string[] = [];
612
+ const atOther: string[] = [];
613
+ for (const r of records) {
614
+ // `.infra.<zone>` system-identity records are intentionally container-IP
615
+ // (zone-side names, not LAN-reachability records) — not subject to the
616
+ // natIp rule.
617
+ if (SYSTEM_IDENTITY_HOST.test(r.host)) continue;
618
+ if (r.ip === natIp) continue;
619
+ const owner = ipZone.get(r.ip);
620
+ if (owner && owner.zone !== LAN_REACHABLE_ZONE) {
621
+ atContainer.push(
622
+ `${r.host} → ${r.ip} (${owner.moduleId}'s ${owner.zone}-zone container IP — a LAN device can't route there; should be the natIp ${natIp})`,
623
+ );
624
+ } else if (!owner) {
625
+ atOther.push(`${r.host} → ${r.ip} (neither the natIp ${natIp} nor a known system IP)`);
626
+ }
627
+ }
628
+
629
+ const detail: string[] = [];
630
+ const statuses: FleetFindingStatus[] = [];
631
+ if (atContainer.length > 0) {
632
+ statuses.push('fail');
633
+ detail.push(...atContainer);
634
+ }
635
+ if (atOther.length > 0) {
636
+ statuses.push('warn');
637
+ detail.push(...atOther);
638
+ }
639
+
640
+ const status = worst(statuses);
641
+ return {
642
+ ...base,
643
+ status,
644
+ summary:
645
+ status === 'ok'
646
+ ? `${records.length} internal DNS record(s) resolve to the natIp or a LAN-reachable system`
647
+ : 'internal DNS records point at zone-side IPs unreachable from the LAN',
648
+ detail,
649
+ remediation:
650
+ status === 'fail'
651
+ ? 'redeploy the provider so it registers the record at the firewall natIp (firewall.exposeService result), not the container IP'
652
+ : null,
653
+ };
654
+ }
655
+
656
+ export interface RunFleetChecksOptions {
657
+ now?: number;
658
+ installedCodeMtimeMs?: number | null;
659
+ }
660
+
661
+ /**
662
+ * Run every fleet check against the given handles. The caller owns
663
+ * gating (skip when there's no celilo DB) and rendering; this just
664
+ * returns the findings, in the order they're shown.
665
+ */
666
+ export async function runFleetChecks(
667
+ bus: Bus,
668
+ db: DbClient,
669
+ opts: RunFleetChecksOptions = {},
670
+ ): Promise<FleetFinding[]> {
671
+ return [
672
+ checkSchemaDrift(db),
673
+ checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
674
+ checkSubscribers(bus, db),
675
+ checkCapabilityProviders(db),
676
+ await checkServiceDns(db),
677
+ ];
678
+ }