import type { DeployedSystem } from '@celilo/capabilities'; import { and, eq, inArray } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { NETWORK_ZONES, type NetworkZone, machines, moduleConfigs, moduleInfrastructure, moduleSystems, modules, } from '../db/schema'; import { type ModuleManifest, getDeclaredSystems } from '../manifest/schema'; import type { InfrastructureSelection } from '../types/infrastructure'; import type { InfraSystemFields } from '../variables/types'; /** * The deployment-STATE layer: a module's 0..N deployed systems * (openspec/specs/module-systems-addressing/spec.md). Replaces the scalar `target_ip`/`vmid` * rows that used to live in module_configs and the single-result * `getModuleHostAndIp`. There is deliberately no "get THE system" helper — * callers work with the array so the 0/1/N reality stays visible. */ /** Strip CIDR notation from an IPv4 address ("10.0.20.10/24" → "10.0.20.10"). */ function stripCidr(addr: string): string { const slash = addr.indexOf('/'); return slash === -1 ? addr : addr.slice(0, slash); } function rowToSystem(row: typeof moduleSystems.$inferSelect): DeployedSystem { return { name: row.name, hostname: row.hostname, ipv4_address: row.ipv4Address, zone: row.zone, infrastructure: { type: row.infraType, ...(row.machineId ? { machineId: row.machineId } : {}), ...(row.serviceId ? { serviceId: row.serviceId } : {}), ...(row.vmid != null ? { vmid: row.vmid } : {}), }, }; } /** * All systems a module has deployed onto, ordered by name for determinism. * Returns [] for API-only modules (e.g. namecheap) — that is a modeled state, * not an error. */ /** * The module that IS celilo's control plane. * * celilo knows this module by name in several places — its privileged * capability allow-list, the subnet it trusts, the fleet checks that ask where * the control plane runs, and the deploy step that initialises the box's own * state. The id lives here because every one of those already reaches for * `getModuleSystems`, and three separate private copies of the string is how * they drift. */ export const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt'; export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem[] { const rows = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all(); return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name)); } /** * A celilo-provisioned instance with its canonical size (ISS-0150). CLI-internal * shape (NOT the `DeployedSystem` capability type) for the `celilo proxmox * vm/ct …` surface, which needs the sizing columns the capability type omits. */ export interface ProvisionedSystem { moduleId: string; name: string; hostname: string; ipv4Address: string; zone: NetworkZone; serviceId: string | null; vmid: number | null; /** Canonical desired size (null until seeded / for non-Proxmox). */ cpu: number | null; memory: number | null; disk: number | null; } /** * Every celilo-provisioned (container_service) system with a Proxmox vmid, across * all modules, including its canonical sizing — the read model behind * `celilo proxmox vm/ct list|show`. Ordered by vmid for stable output. */ export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] { return db .select() .from(moduleSystems) .where(eq(moduleSystems.infraType, 'container_service')) .all() .filter((r) => r.vmid != null) .map((r) => ({ moduleId: r.moduleId, name: r.name, hostname: r.hostname, ipv4Address: r.ipv4Address, zone: r.zone, serviceId: r.serviceId, vmid: r.vmid, cpu: r.cpu, memory: r.memory, disk: r.disk, })) .sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0)); } /** One deployed system, with the module on it — the whole fleet, both infra types. */ export interface ModulePlacementRow { moduleId: string; hostname: string; infraType: 'machine' | 'container_service'; vmid: number | null; } /** * Every module deployment across the fleet, machine-pool and container alike. * * The complement to `getModuleSystems` (one module) and `getProvisionedSystems` * (containers only). Added for the doctor's host-liveness check (celilo#728), * which has to ask about EVERY host something is running on — the defect there * was precisely that no fleet-wide view of "what runs where" was being * consulted. */ export function listAllModuleSystems(db: DbClient): ModulePlacementRow[] { return db .select() .from(moduleSystems) .all() .map((r) => ({ moduleId: r.moduleId, hostname: r.hostname, infraType: r.infraType, vmid: r.vmid ?? null, })) .sort((a, b) => a.moduleId.localeCompare(b.moduleId)); } /** * All container_service systems (Proxmox LXCs, droplets, …) whose zone is in * `zones`, across every module — the LXC complement to machine-pool's * `getSystemsByZone`. Used by the aspect fan-out to reach LXCs, not just * machine-pool boxes (ISS-0028). Deduped by hostname (one LXC = one module = one * row, but defensive). Ordered by hostname for determinism. */ export function getContainerSystemsInZones(zones: string[], db: DbClient): DeployedSystem[] { if (zones.length === 0) return []; const rows = db .select() .from(moduleSystems) .where( and( inArray(moduleSystems.zone, zones as NetworkZone[]), eq(moduleSystems.infraType, 'container_service'), ), ) .all(); const seen = new Set(); const out: DeployedSystem[] = []; for (const row of rows) { if (seen.has(row.hostname)) continue; seen.add(row.hostname); out.push(rowToSystem(row)); } return out.sort((a, b) => a.hostname.localeCompare(b.hostname)); } /** Input to {@link upsertDeployedSystem} — the realized facts about one host. */ export interface DeployedSystemInput { /** Stable handle from requires.systems[].name — the per-system key. */ name: string; hostname: string; /** Accepts CIDR or bare; stored CIDR-stripped. */ ipv4Address: string; zone: NetworkZone; infraType: 'machine' | 'container_service'; machineId?: string | null; serviceId?: string | null; vmid?: number | null; /** * Canonical deployed size (ISS-0150), seeded from the module's * `requires.system` at first provision. Seed-once: written on INSERT only and * preserved across re-deploys (omitted from the conflict update), so a later * `celilo proxmox … resize` is not reset back to the manifest minimum. */ cpu?: number | null; memory?: number | null; disk?: number | null; } /** * Insert or update one deployed system for a module, keyed by (moduleId, name). * Idempotent — re-deploying the same system overwrites its address / infra * fields. This is the single sink the old `target_ip` write sites collapse into. */ export function upsertDeployedSystem( db: DbClient, moduleId: string, system: DeployedSystemInput, ): void { const ipv4 = stripCidr(system.ipv4Address); db.insert(moduleSystems) .values({ moduleId, name: system.name, hostname: system.hostname, ipv4Address: ipv4, zone: system.zone, infraType: system.infraType, machineId: system.machineId ?? null, serviceId: system.serviceId ?? null, vmid: system.vmid ?? null, // Seed-once (ISS-0150): set on insert; deliberately omitted from the // conflict update below so a resize survives re-deploys. cpu: system.cpu ?? null, memory: system.memory ?? null, disk: system.disk ?? null, updatedAt: new Date(), }) .onConflictDoUpdate({ target: [moduleSystems.moduleId, moduleSystems.name], set: { hostname: system.hostname, ipv4Address: ipv4, zone: system.zone, infraType: system.infraType, machineId: system.machineId ?? null, serviceId: system.serviceId ?? null, vmid: system.vmid ?? null, // NOTE: cpu/memory/disk intentionally NOT updated here — sizing is // canonical state owned by `celilo proxmox … resize`, not reset by a // routine re-deploy (seed-once). See ISS-0150 / CLAUDE.md. updatedAt: new Date(), }, }) .run(); } export function asZone(value: string | null | undefined): NetworkZone | null { return value && (NETWORK_ZONES as readonly string[]).includes(value) ? (value as NetworkZone) : null; } /** True for an IPv4 loopback address, including CIDR-form input. */ function isLoopbackIpv4(value: string): boolean { return stripCidr(value).startsWith('127.'); } /** * The machine pool uses 127.0.0.1 as a transport sentinel for the machine * running celilo itself: it selects Ansible's local connection rather than * SSH. That value is not the machine's network identity and must never leak * into module_systems (and, downstream, internal DNS). * * For an ordinary machine, its primary address remains canonical. For the * local sentinel, use the catalogued non-loopback interface in the system's * resolved zone. Deliberately do not fall back to an arbitrary interface: a * control plane can also have an upstream/Wi-Fi address, and publishing that * would be a quieter version of the same identity bug. */ export function resolveMachineIdentityAddress( machine: typeof machines.$inferSelect | undefined, zone: NetworkZone, ): string | undefined { if (!machine) return undefined; if (!isLoopbackIpv4(machine.ipAddress)) return machine.ipAddress; return machine.interfaces.find( (iface) => asZone(iface.zone) === zone && !isLoopbackIpv4(iface.ipAddress), )?.ipAddress; } /** * Resolve a module's deployed system(s) from the deploy state and persist them * to `module_systems`. Called during deploy after infrastructure variables are * resolved (the IP is known for machine / proxmox / DO alike). This is the * single sink the old scattered `target_ip` writes collapse into. * * Transition: every current module declares exactly one system (via * `requires.system`, normalized to name `main`, or one `requires.systems` * entry), so this records that single host from `hostname` config + the * resolved IP. Returns the systems it recorded ([] for an API-only module with * no host — e.g. namecheap). See openspec/specs/module-systems-addressing/spec.md. */ export async function recordDeployedSystemForModule( moduleId: string, manifest: ModuleManifest, infrastructure: InfrastructureSelection | undefined, db: DbClient, ): Promise { const declared = getDeclaredSystems(manifest); // No declared systems → API-only module (e.g. namecheap). Records nothing. if (declared.length === 0) return []; const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all(); const cfg = (key: string) => configs.find((c) => c.key === key)?.value; const hostname = cfg('hostname'); // No hostname → not yet addressable. A modeled state, not an error. if (!hostname) return []; const machine = infrastructure?.machineId ? db.select().from(machines).where(eq(machines.id, infrastructure.machineId)).get() : undefined; // Single-system transition: take the first declared system's name + zone. const decl = declared[0]; // A machine-pool deploy lands on a specific box, and THAT box's zone is where // the system actually is. `requires.system.zone` is the minimum used to select // a host — not a description of the result — exactly as `requires.system.memory` // is a floor rather than the deployed size. Recording the manifest's zone here // would report the control plane as living on `internal` while it sits on // `secure-mgmt`, which is the misreading this whole change exists to remove. const zone = asZone(machine?.zone) ?? asZone(decl.resources.zone) ?? asZone(cfg('zone')); if (!zone) { throw new Error( `Cannot record deployed system for '${moduleId}': no resolvable network zone (checked requires.systems[].resources.zone and config.zone).`, ); } // IP: the module's resolved target_ip, else ip.primary, else the assigned // machine's network identity. A local machine's 127.0.0.1 primary address is // an execution-transport sentinel, so resolve its identity from the // catalogued interface in the system's actual zone instead. const ip = cfg('target_ip') ?? cfg('ip.primary') ?? resolveMachineIdentityAddress(machine, zone); if (!ip) return []; const infraType = infrastructure?.type ?? 'machine'; const vmidStr = cfg('vmid'); const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null; upsertDeployedSystem(db, moduleId, { name: decl.name, hostname, ipv4Address: ip, zone, infraType, machineId: infrastructure?.machineId ?? null, serviceId: infrastructure?.serviceId ?? null, vmid: Number.isNaN(vmid as number) ? null : vmid, // Seed canonical size from requires.system (seed-once; preserved across // re-deploys). Only meaningful for celilo-provisioned instances. cpu: decl.resources.cpu ?? null, memory: decl.resources.memory ?? null, disk: decl.resources.disk ?? null, }); return getModuleSystems(moduleId, db); } /** * One-time upgrade backfill: populate `module_systems` for deployments that * predate `0007_module_systems` (openspec/specs/module-systems-addressing/spec.md). A DB created * before the target_ip → systems refactor has its host data in * `module_configs.target_ip` / `vmid` / `zone` + `ip_allocations` + * `module_infrastructure`, but an empty `module_systems` — so `$infra:` and * `ctx.systems` resolve to nothing and the migrated hooks throw "No deployed * system found". This reconstructs each module's system from that existing * state, mirroring `recordDeployedSystemForModule`'s field derivation. * * Idempotent: skips any module that already has a `module_systems` row, so it's * safe to call on every schema upgrade. Intended to run once, right after * migrations apply, gated on a migration actually having happened (a fresh DB * has no `module_infrastructure` rows, so this is a no-op there). Returns the * module ids it backfilled, for operator-visible logging. */ export function backfillModuleSystems(db: DbClient): string[] { const backfilled: string[] = []; for (const infra of db.select().from(moduleInfrastructure).all()) { // Already recorded (post-refactor deploy, or a prior backfill run) — skip. if (getModuleSystems(infra.moduleId, db).length > 0) continue; const moduleRow = db.select().from(modules).where(eq(modules.id, infra.moduleId)).get(); if (!moduleRow?.manifestData) continue; const declared = getDeclaredSystems(moduleRow.manifestData as ModuleManifest); // API-only module (no host) — nothing to record. if (declared.length === 0) continue; const configs = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, infra.moduleId)) .all(); const cfg = (key: string) => configs.find((c) => c.key === key)?.value; const hostname = cfg('hostname'); if (!hostname) continue; const machine = infra.machineId ? db.select().from(machines).where(eq(machines.id, infra.machineId)).get() : undefined; const decl = declared[0]; // Same precedence as recordDeployedSystemForModule: the machine a deploy // landed on is where the system IS; `requires.system.zone` is only the // minimum used to pick a host. These two paths must agree — the control // plane is recorded HERE, not by the deploy path, so a divergence between // them is invisible until the firewall trusts the wrong subnet. const zone = asZone(machine?.zone) ?? asZone(decl.resources.zone) ?? asZone(cfg('zone')); // Can't address a system without a zone; skip rather than abort the whole // backfill (a re-deploy will record it properly). if (!zone) continue; // Mirrors recordDeployedSystemForModule. For celilo's local machine, // 127.0.0.1 is only a local-transport sentinel; the zone-matching // catalogued interface is the deployed system's address. const ip = cfg('target_ip') ?? cfg('ip.primary') ?? resolveMachineIdentityAddress(machine, zone); if (!ip) continue; const vmidStr = cfg('vmid'); const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null; upsertDeployedSystem(db, infra.moduleId, { name: decl.name, hostname, ipv4Address: ip, zone, infraType: infra.infrastructureType, machineId: infra.machineId ?? null, serviceId: infra.serviceId ?? null, vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null, // Seed canonical size from requires.system for upgraded deployments. cpu: decl.resources.cpu ?? null, memory: decl.resources.memory ?? null, disk: decl.resources.disk ?? null, }); backfilled.push(infra.moduleId); } return backfilled; } /** Default CIDR prefix length when a zone has no `network..subnet` set. */ const DEFAULT_PREFIX = 24; /** Extract the prefix length ("/24" → 24) from a CIDR; default 24. */ function prefixOf(subnetCidr: string | undefined): number { if (!subnetCidr) return DEFAULT_PREFIX; const slash = subnetCidr.indexOf('/'); if (slash === -1) return DEFAULT_PREFIX; const n = Number.parseInt(subnetCidr.slice(slash + 1), 10); return Number.isNaN(n) ? DEFAULT_PREFIX : n; } /** * Build the `$infra:.` lookup for a module — one entry per deployed * system, keyed by its `name`. `cidr` is derived from `ipv4_address` + the * zone's prefix (from `network..subnet` in system config). Consumed by the * variable resolver at generate time. openspec/specs/module-systems-addressing/spec.md. */ export function buildInfraSystemsMap( moduleId: string, db: DbClient, systemConfig: Record, ): Record { const map: Record = {}; for (const sys of getModuleSystems(moduleId, db)) { const prefix = prefixOf(systemConfig[`network.${sys.zone}.subnet`]); map[sys.name] = { name: sys.name, hostname: sys.hostname, ipv4_address: sys.ipv4_address, zone: sys.zone, cidr: `${sys.ipv4_address}/${prefix}`, vmid: sys.infrastructure.vmid != null ? String(sys.infrastructure.vmid) : '', }; } return map; } /** Remove all deployed-system rows for a module (used on uninstall/teardown). */ export function deleteModuleSystems(db: DbClient, moduleId: string): void { db.delete(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).run(); } /** Remove a single deployed system (used when one host of N is torn down). */ export function deleteDeployedSystem(db: DbClient, moduleId: string, hostname: string): void { db.delete(moduleSystems) .where(and(eq(moduleSystems.moduleId, moduleId), eq(moduleSystems.hostname, hostname))) .run(); }