/** * Deploy-time DNS backfill for a freshly-deployed `dns_internal` provider. * * Event deliveries bind at emit time, so a provider that deploys late never * receives the `system.created` events of systems that came up before it. This * backfill closes that gap: when a `dns_internal` provider deploys, it registers * every currently-deployed system in its zones by invoking its OWN * `on_system_event` hook once per host. The ongoing case (a system deploying * AFTER the provider) is handled by the provider's `system.created.*` * subscription. See openspec/specs/event-driven-hook-subscriptions/spec.md. * * All DNS mechanics (which zones, register vs delete) live in the module's * hook; this file only decides WHICH hosts and invokes the hook. That is the * D5 division of labour: celilo owns host inventory, the module owns DNS. * * WHY THIS SURVIVED THE GENERIC PROVIDER-ARRIVAL BACKFILL * (openspec/changes/capability-owned-tables task 1.7). `provider-arrival.ts` * replaced `public-web-republish.ts` and covers `firewall` for the first time. * It does NOT cover either function here, and deleting this file over it would * silently take internal DNS with it. Two independent reasons, both checked * against the manifests rather than assumed: * * 1. `backfillProviderDns` replays celilo's HOST INVENTORY — one record per * deployed system, across every module. Those systems are hosts, not * consumers. There is no `requires`/`optional` edge to fan out along * because none of them asked `dns_internal` for anything. It is not a * capability registration set, so a mechanism defined over consumer * declarations cannot reach it. * * 2. `backfillWebRouteDns` covers the FQDNs modules publish through * `public_web`. Those modules declare `public_web` — `authentik`, * `forgejo`, `celilo-registry`, `celilo-apt-repo`, `celilo-website`, * `npm-cache-node` and the two hello fixtures. NOT ONE declares * `dns_internal`. The only module that does is `caddy-internal`, and it * declares it `optional`. So a `dns_internal` provider arriving has no * edge to the modules whose records it needs to learn. * * Point 2 is still second-implementation debt, and it is already ledgered: * `module-business-baseline.ts` carries this file under S3 (#945). Retiring it * needs a `public_web` fan-out triggered by a `dns_internal` arrival — a * CROSS-capability edge the pull-shaped design does not define — because * `register_route` (packages/capabilities/src/public-web.ts) already writes * exactly the record `backfillWebRouteDns` reconstructs by hand, with the same * value and the same `zoneRoutableValue`. */ import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities'; import { and, eq, inArray, or } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema'; import { loadCapabilityFunctions, resolveCaddyZoneIp, resolveFirewallNatIp, } from '../hooks/capability-loader'; import { runNamedHook } from '../hooks/run-named-hook'; import type { HookName } from '../hooks/types'; import { getModuleSystems } from './deployed-systems'; /** True when `moduleId` provides the `dns_internal` capability. */ export function isDnsInternalProvider(moduleId: string, db: DbClient): boolean { const row = db .select() .from(capabilitiesTable) .where( and( eq(capabilitiesTable.capabilityName, 'dns_internal'), eq(capabilitiesTable.moduleId, moduleId), ), ) .get(); return Boolean(row); } /** * Register every currently-deployed system in the just-deployed provider's * zones, via its `on_system_event` hook (`op: register`). Includes the provider * itself, so the provider's own record exists even in daemonless contexts where * no dispatcher delivers its `system.created` event. * * Attempts every host, then throws an aggregated error if any failed (no * surprises; v2/issues/ISS-0004) — one bad host doesn't abort the rest. */ export async function backfillProviderDns( moduleId: string, db: DbClient, logger: HookLogger, ): Promise { logger.info(`dns_internal provider '${moduleId}' deployed — backfilling DNS for all systems`); // ISS-0009: register only DEPLOYED systems, never IMPORTED-but-undeployed // modules (those have a hostname/target_ip configured but nothing serving at // that address — registering an A record for them is a phantom record). A // deployed system is INSTALLED or VERIFIED. The provider itself is already // INSTALLED by the time backfill runs (module-deploy transitions state before // calling this), but we union its id explicitly so the provider's own // self-record is registered even if that transition order ever changes — // critical in daemonless contexts where no dispatcher delivers system.created. const deployed = db .select() .from(modules) .where(or(inArray(modules.state, ['INSTALLED', 'VERIFIED']), eq(modules.id, moduleId))) .all(); const failures: string[] = []; // One register per deployed system across all modules — a module with N // hosts backfills N records (openspec/specs/module-systems-addressing/spec.md). for (const mod of deployed) { for (const sys of getModuleSystems(mod.id, db)) { const result = await runNamedHook(moduleId, 'on_system_event' as HookName, db, logger, { inputs: { hostname: sys.hostname, target_ip: sys.ipv4_address, op: 'register' }, }); if (!result.success && !result.notDefined) { failures.push(`${sys.hostname} (${mod.id}): ${result.error ?? 'unknown error'}`); } } } if (failures.length > 0) { throw new Error(`DNS backfill failed for ${failures.length} host(s): ${failures.join('; ')}`); } } /** * Backfill split-horizon records for every PUBLISHED web-route hostname * (ISS-0029). The per-system backfill above covers each deployed host by its * bare hostname; this covers the FQDNs modules publish via public_web (e.g. * `apt.celilo.computer`), which a late-deploying provider would otherwise never * learn. Each hostname is registered at the firewall NAT IP — the same value * public_web uses for the live registration — so backfill and live agree. * * Unlike the per-system path, this does NOT go through `on_system_event` (which * concatenates `.` and would corrupt an already-FQDN host). * It calls the provider's `registerRecord` directly with the FQDN; the * provider creates the split-horizon zone on demand (Phase 1). Attempts every * hostname, then throws an aggregate error if any failed. */ export async function backfillWebRouteDns( moduleId: string, db: DbClient, logger: HookLogger, // Injectable so unit tests don't dynamically import a real provider module. loadCaps: typeof loadCapabilityFunctions = loadCapabilityFunctions, ): Promise { const hostnames = [ ...new Set( db .select({ hostname: webRoutes.hostname }) .from(webRoutes) .all() .map((r) => r.hostname), ), ]; if (hostnames.length === 0) return; const natIp = await resolveFirewallNatIp(db); if (!natIp) { // No firewall NAT IP to point at — the live public_web path falls back to // Caddy's IP per route; we don't replicate that here. Records land when the // route is (re)published. Surface it rather than silently doing nothing. logger.warn( `web-route DNS backfill for '${moduleId}' skipped: no firewall nat_ip available (${hostnames.length} hostname(s) deferred to live registration)`, ); return; } const caps = await loadCaps(moduleId, db, logger); const dnsInternal = caps.dns_internal as DnsInternalCapability | undefined; if (!dnsInternal) { logger.warn(`web-route DNS backfill: dns_internal capability not loadable for '${moduleId}'`); return; } // caddy's zone-routable IP — the in-zone split-horizon answer (ISS-0156). When // it differs from the natIp, each fronted hostname carries it as // `zoneRoutableValue`; the provider's reconcileViews (driven from the ledger by // the registration wrapper) materializes the per-zone view overrides. const caddyZoneIp = await resolveCaddyZoneIp(db); const zoneRoutableValue = caddyZoneIp && caddyZoneIp !== natIp ? caddyZoneIp : undefined; const viewNote = zoneRoutableValue ? ` (in-zone view → ${zoneRoutableValue})` : ''; logger.info( `Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}${viewNote}`, ); const failures: string[] = []; for (const host of hostnames) { try { await dnsInternal.registerRecord({ host, type: 'A', value: natIp, zoneRoutableValue }); } catch (err) { failures.push(`${host}: ${err instanceof Error ? err.message : String(err)}`); } } if (failures.length > 0) { throw new Error( `web-route DNS backfill failed for ${failures.length} host(s): ${failures.join('; ')}`, ); } }