/** * Ensure every network a module REQUIRES is defined before the deploy proceeds * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * * celilo owns the network namespace. A module names the networks it depends on * under `requires.networks`; it never carries their values. When one of those * networks has no `network..subnet` in system config, this asks for the * range over the event bus and writes it — celilo asking, celilo writing. * * Two properties matter and both come from WHERE this runs rather than from * anything clever it does: * * - It runs in the deploy's interview phase, before generation and before any * hook. So by the time a hook executes, the network is defined — whether the * consumer reading declared networks captured them eagerly or reads at point * of use. That is what retires the live-reader mitigation the firewall * capability carries today (celilo#759). * - It asks through the generic bus interview, so it is answerable by whatever * responder is attached — a terminal, `celilo events reply`, an automated * policy — and behaves identically interactive or headless. * * ── What is asked, and what is merely observed ── * * Not every attribute of a network is a question. The rule is whether celilo can * already SEE the answer: * * - `subnet` is ASKED. It is an addressing-plan decision that predates every * module, and nothing in the fleet can be consulted for it. A well-known name * is offered a suggested range so an operator new to networking is not made to * invent one — an offer in a question, never a seeded row. * - `gateway` is OBSERVED, never asked. It is the address a router answers on * inside that subnet, which celilo already holds: `machine add` catalogues * every interface of every machine. Asking for it would be asking the operator * to retype something celilo can look up, which is the same failure this change * exists to remove, aimed at a different key. * - `vlan` is ASKED, optional, blank meaning untagged. It is NOT observable — * a catalogued interface carries `{name, ipAddress, zone}` and no tag — and it * IS load-bearing: every container-provisioning template reads * `$system:network..vlan` as the Proxmox NIC tag. Leaving it uncollected * would provision containers untagged onto the wrong VLAN, silently. * * Which attributes a network HAS at all is celilo's answer too, taken from * `schemas/system_config.json`: it declares `gateway`/`vlan` for the routed * segments and omits both for the control-plane VPN, which has neither. So a * module never states which attributes it reads, and asking a nonsense question * is impossible by construction. */ import { subnetContains } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { machines, moduleConfigs, systemConfig } from '../db/schema'; import { type ModuleManifest, getRequiredNetworkNames } from '../manifest/schema'; import { askText } from './bus-interview'; import { loadSchema } from './system-init'; export interface NetworkEnsureResult { success: boolean; error?: string; /** What this call defined, as ` = ` lines for the deploy log. */ applied: string[]; } /** The one piece of I/O here, injectable so the rules are testable without a bus. */ export type NetworkAsker = (question: { scope: string; key: string; message: string; description: string; defaultValue?: string; placeholder?: string; required: boolean; pattern?: string; }) => Promise; /** * The module's own config, flat, for resolving a `from:` requirement. * * Reads `value_json` in preference to `value`: an array config value is stored * as JSON, and reading the scalar column would yield the string form, which * `getRequiredNetworkNames` would then have to guess at. */ function loadModuleConfigValues(db: DbClient, moduleId: string): Record { const values: Record = {}; for (const row of db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all()) { values[row.key] = row.valueJson ?? row.value; } return values; } function readSystemConfigValue(db: DbClient, key: string): string | undefined { const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get(); return row?.value && row.value.length > 0 ? row.value : undefined; } function writeSystemConfigValue(db: DbClient, key: string, value: string): void { db.insert(systemConfig) .values({ key, value }) .onConflictDoUpdate({ target: systemConfig.key, set: { value } }) .run(); } /** * The address a router answers on inside `subnet`, from the machine catalogue. * * Matched by CONTAINMENT rather than by an interface's recorded zone, because on * a fleet that is still being described those are not the same thing: an * interface is classified into a zone by comparing it against declared subnets, * so before the subnet exists the interface has no zone. Containment answers the * moment the operator supplies the range, which is exactly when this runs. * * `role: 'router'` is what distinguishes the gateway from any other host that * happens to sit in the subnet. */ export function observeGateway(db: DbClient, subnet: string): string | undefined { for (const machine of db.select().from(machines).all()) { if (machine.role !== 'router') continue; for (const iface of machine.interfaces) { if (iface.ipAddress && subnetContains(subnet, iface.ipAddress)) { return iface.ipAddress; } } } return undefined; } /** * Ensure each of `manifest.requires.networks` is defined: a subnet, a vlan tag * where one applies, and a gateway wherever celilo can see one. */ export async function ensureRequiredNetworks( moduleId: string, manifest: ModuleManifest, db: DbClient, ask: NetworkAsker = askText, ): Promise { // A `from:` requirement resolves against the module's OWN config, so the // module's values have to be loaded before its required set is even knowable. const names = getRequiredNetworkNames(manifest, loadModuleConfigValues(db, moduleId)); if (names.length === 0) return { success: true, applied: [] }; const schema = loadSchema(); const applied: string[] = []; for (const name of names) { const subnetKey = `network.${name}.subnet`; const subnetProperty = schema.properties[subnetKey]; if (!subnetProperty) { return { success: false, applied, error: [ `Module "${moduleId}" requires a network called "${name}", which celilo does not know`, `about: there is no "${subnetKey}" in celilo's system-config schema. Networks are`, "celilo's to define, so a new one is added to schemas/system_config.json — a module", 'cannot introduce one.', ].join(' '), }; } // 1. The subnet — asked, because nothing in the fleet can be consulted for it. // // Whether this network already EXISTED is the fact everything below turns // on: celilo is here to define a network, not to audit one it already holds. let subnet = readSystemConfigValue(db, subnetKey); const defining = subnet === undefined; if (defining) { const answer = ( await ask({ scope: `network:${name}`, key: 'subnet', message: `Subnet CIDR for the "${name}" network:`, description: [ `${moduleId} requires the "${name}" network, and celilo has no subnet for it.`, "This becomes celilo's definition of the network — every module that needs it reads", 'this one value.', ].join(' '), defaultValue: subnetProperty.suggested, // A defaultValue MUST have a matching placeholder, or the operator // cannot see what pressing Enter would accept. placeholder: subnetProperty.suggested, required: true, pattern: subnetProperty.pattern, }) ).trim(); if (answer.length === 0) { return { success: false, applied, error: `No subnet supplied for the "${name}" network; ${moduleId} cannot deploy without it.`, }; } writeSystemConfigValue(db, subnetKey, answer); applied.push(`${subnetKey} = ${answer}`); subnet = answer; } // 2. The VLAN tag — asked, because it cannot be observed: a catalogued // interface carries no tag. Optional: an untagged segment genuinely has // none, and a blank answer writes nothing rather than writing "". // // ONLY when celilo is defining the network. An existing network was // already defined without a tag — deliberately, or because it is // untagged — and re-opening that question every time a new module // requires it is not a question, it is a deploy that stops. Which is // exactly what happened: `iptables` requires `internal`, whose subnet the // management install had already recorded and whose vlan nothing ever // set, so a headless deploy died on `interview.required.network:internal.vlan` // with no responder to answer it. Absent means untagged; if that is wrong, // `celilo system config set network..vlan ` says so once. const vlanKey = `network.${name}.vlan`; if ( defining && schema.properties[vlanKey] && readSystemConfigValue(db, vlanKey) === undefined ) { const answer = ( await ask({ scope: `network:${name}`, key: 'vlan', message: `VLAN tag for the "${name}" network (blank if untagged):`, description: [ 'Container provisioning reads this as the NIC tag, so a tagged fleet that leaves it', 'unset puts containers on the wrong VLAN without saying so. Leave it blank if this', 'segment is untagged.', ].join(' '), required: false, }) ).trim(); if (answer.length > 0) { writeSystemConfigValue(db, vlanKey, answer); applied.push(`${vlanKey} = ${answer}`); } } // 3. The gateway — OBSERVED. celilo catalogues every machine's interfaces at // `machine add`, so the router's address inside this subnet is a fact it // already holds. Absent means no router leg is catalogued there yet, which // a later `machine add` or firewall deploy resolves; it is not a question. const gatewayKey = `network.${name}.gateway`; if ( subnet && schema.properties[gatewayKey] && readSystemConfigValue(db, gatewayKey) === undefined ) { const observed = observeGateway(db, subnet); if (observed) { writeSystemConfigValue(db, gatewayKey, observed); applied.push(`${gatewayKey} = ${observed} (observed from the machine catalogue)`); } } } return { success: true, applied }; }