/** * Initialise the management box's own celilo state, during the celilo-mgmt * deploy. * * ## Why this is here and not in a hook * * It was a hook until celilo#1225. `modules/celilo-mgmt/scripts/on_install.ts` * reached every one of these operations by spawning the `celilo` CLI, which a * jailed hook cannot do: the jail's mount set binds no `/usr/bin`, no * `/usr/local/bin` and no shell, so `execFileSync` dies on the missing binary * and `execSync` dies one step earlier on the missing `/bin/sh`. The deploy ran * Ansible to completion and then failed in its own install hook, on every host * where a jail backend exists. * * The fix is not to let the hook reach the CLI. It is that none of this wanted * to be in a hook. celilo-mgmt is never deployed to a remote box (ruled * 2026-09-02), so the host being configured is always the host celilo runs on, * and every step below is celilo acting on itself: read this box's resolvers, * mint celilo's own fleet key, record celilo's own network, check celilo's own * event bus. A hook was a process boundary with celilo on both sides of it. * * ## What it does NOT do * * It does not decide whether to run. The deploy path does that, from the * control-plane module id, which celilo already keys on in three other places * (`manifest/validate.ts`'s privileged allow-list, `capability-loader.ts`'s * `CONTROL_PLANE_MODULE_ID`, `fleet-checks.ts`'s `CONTROL_PLANE_MODULE`). * * It also does not print. It returns what happened and the caller renders it, * so the same function serves a deploy, a test and any later caller without * carrying a logger's opinions (Rule 10.1). */ import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import { getEventBusPath } from '../config/paths'; import type { DbClient } from '../db/client'; import { capabilities } from '../db/schema'; import { type DnsServers, discoverDns } from './dns-discovery'; import { type FleetFinding, checkDispatcher } from './fleet-checks'; import { type FleetKey, ensureFleetKey } from './fleet-key'; import { type NetworkDiscoveryResult, discoverAndRecordNetwork } from './network-discovery'; import { initializeSystem } from './system-init'; /** * How long to give a dispatcher that is still coming up. * * The Ansible role enables its supervisor unit moments before this runs, so a * first probe legitimately finds nothing. Five one-second attempts is what the * hook this replaced used, and no case has argued for more. */ const DISPATCHER_ATTEMPTS = 5; const DISPATCHER_INTERVAL_MS = 1_000; /** * The marker a resolver provider declares in its capability data to say "the * addresses I advertise are a resolver celilo itself deployed". Core reads * this declaration and names no capability (module-business-barrier Scan B). * Declared today by knot-unbound-internal, knot-unbound-secondary and * technitium (celilo#1239). */ const FLEET_RESOLVER_MARKER = 'fleet_resolver'; /** The declared shape of a resolver capability's data. Values checked before use. */ type ResolverCapabilityData = { fleet_resolver?: unknown; server?: { ip?: unknown; internal_ip?: unknown }; }; /** * Every address a deployed resolver provider advertises for itself. * * A capability row counts when its data declares {@link FLEET_RESOLVER_MARKER}; * the capability NAME is irrelevant to this selection, so a new resolver * provider needs no core change. Discovery hands the addresses to * {@link discoverDns} so a resolv.conf the provider's own aspect wrote is * refused rather than adopted as the fleet's upstream (celilo#1239). Addresses * come from the declared data only (`server.ip`, `server.internal_ip`). Values * may carry a CIDR suffix; discovery normalises both sides before comparing. */ export function fleetResolverAddresses(db: DbClient): string[] { const addresses: string[] = []; const rows = db.select({ data: capabilities.data }).from(capabilities).all(); for (const row of rows) { const data = row.data as ResolverCapabilityData | undefined; if (data?.[FLEET_RESOLVER_MARKER] !== true) continue; for (const value of [data.server?.ip, data.server?.internal_ip]) { if (typeof value === 'string' && value.length > 0) { addresses.push(value); } } } return addresses; } export interface ControlPlaneBootstrapOptions { db: DbClient; /** * Injectable so tests drive the whole sequence without a host or a bus. * Receives the addresses of resolvers celilo itself deployed, which the * discovery must refuse (celilo#1239). */ discoverDnsImpl?: (fleetResolverIps: readonly string[]) => DnsServers; ensureFleetKeyImpl?: () => FleetKey; discoverNetworkImpl?: (db: DbClient) => NetworkDiscoveryResult; /** * One dispatcher reading. Injectable so a test needs no bus at all; the * default opens celilo's own and runs the four-part check against it. */ probeDispatcher?: () => FleetFinding; sleep?: (ms: number) => Promise; dispatcherAttempts?: number; } export interface ControlPlaneBootstrapResult { dns: DnsServers; fleetKey: FleetKey; network: NetworkDiscoveryResult; /** The last dispatcher reading. `fail` means no dispatcher ever answered. */ dispatcher: FleetFinding; } /** * The control plane reads the bus's own health tables and emits nothing, so it * registers no event schemas — the same empty registry `celilo events` opens * with. */ const NO_SCHEMAS = defineEvents({}); /** * Read the dispatcher's state from celilo's own bus. * * `checkDispatcher` is deliberately used in place of the "is the process up?" * read the hook did. Its docblock records why: a naive liveness check reports * green on a dispatcher that is running, unsupervised, on stale code, and * delivering nothing. */ /** * Open celilo's own bus, do one thing with it, close it. * * There is an async twin below and the two are NOT interchangeable: handing an * async function to this one closes the bus when the promise is CREATED, not * when it settles, so the work runs against a closed handle. */ export function withCeliloBus(fn: (bus: Bus) => T): T { const bus: Bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS }); try { return fn(bus); } finally { bus.close(); } } /** The async twin. See the warning on `withCeliloBus`. */ export async function withCeliloBusAsync(fn: (bus: Bus) => Promise): Promise { const bus: Bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS }); try { return await fn(bus); } finally { bus.close(); } } function probeOwnDispatcher(): FleetFinding { return withCeliloBus((bus) => checkDispatcher(bus)); } /** Poll until a dispatcher answers, or the attempts run out. */ async function waitForDispatcher( probe: () => FleetFinding, attempts: number, sleep: (ms: number) => Promise, ): Promise { let finding = probe(); for (let attempt = 1; attempt < attempts && finding.status === 'fail'; attempt++) { await sleep(DISPATCHER_INTERVAL_MS); finding = probe(); } return finding; } /** * Record what celilo needs to know about the box it runs on. * * Ordering is not cosmetic. The fleet key and the DNS servers are written in * ONE `initializeSystem` call, because that function applies defaults and the * init sentinel alongside the keys it is given, and calling it twice makes the * second call re-derive everything the first already settled. The hook this * replaced made two round trips through the CLI for exactly that reason and * had no way not to. * * The network is recorded after, not in the same call: `system apply-config` * REFUSES `network.*` keys outright, so a network is not something you write * through the config surface at all. `discoverAndRecordNetwork` is the only * path, it is idempotent, and it never overwrites an operator's value. */ export async function bootstrapControlPlane( options: ControlPlaneBootstrapOptions, ): Promise { const { db, discoverDnsImpl = (fleetResolverIps: readonly string[]): DnsServers => discoverDns(undefined, { fleetResolverIps }), ensureFleetKeyImpl = ensureFleetKey, discoverNetworkImpl = discoverAndRecordNetwork, probeDispatcher = probeOwnDispatcher, sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)), dispatcherAttempts = DISPATCHER_ATTEMPTS, } = options; const dns = discoverDnsImpl(fleetResolverAddresses(db)); const fleetKey = ensureFleetKeyImpl(); initializeSystem(db, { 'dns.primary': dns.primary, 'dns.fallback': dns.fallback, 'ssh.public_key': fleetKey.publicKey, }); const network = discoverNetworkImpl(db); const dispatcher = await waitForDispatcher(probeDispatcher, dispatcherAttempts, sleep); return { dns, fleetKey, network, dispatcher }; }