import type { Module } from '../db/schema'; import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema'; import type { InfrastructureSelection, Machine, MachineRole, ResourceRequirements, } from '../types/infrastructure'; import { listContainerServices } from './container-service'; import { getModulesOnMachine, listMachines } from './machine-pool'; /** * Infrastructure selection error */ export class InfrastructureError extends Error { constructor(message: string) { super(message); this.name = 'InfrastructureError'; } } /** * Extract resource requirements from module manifest */ function getResourceRequirements(module: Module): ResourceRequirements { const manifest = module.manifestData as ModuleManifest; const system = getSingularSystemSpec(manifest); if (!system) { throw new InfrastructureError( `Module ${module.id} manifest missing requires.system configuration`, ); } if (!system.zone) { throw new InfrastructureError( `Module ${module.id} manifest missing requires.system.zone field`, ); } return { cpu: system.cpu ?? 1, memory: system.memory ?? 1024, disk: system.disk ?? 10, storage: system.storage, zone: system.zone, }; } /** * Is this machine big enough for the module at all? * * Deliberately NOT multi-tenant capacity accounting (celilo#773). It used to * subtract an "already allocated" figure that was hard-coded to zero behind a * TODO, so the subtraction never changed an answer and the check has always * been exactly this comparison. Saying so is the honest version; a gate that * cannot fail reads as protection that is not there (Rule 7.6). * * Real accounting needs a decision nobody has made — whether a module's draw is * its manifest MINIMUM (`requires.system`) or its deployed size, which on a * pool machine celilo does not own. Until then the occupancy filter, which IS * now correct, is what keeps two modules off one box. */ function machineHasCapacity(machine: Machine, requirements: ResourceRequirements): boolean { return ( machine.hardware.cpu_cores >= requirements.cpu && machine.hardware.memory_mb >= requirements.memory && machine.hardware.disk_gb >= requirements.disk ); } interface RejectionReason { hostname: string; ipAddress: string; reason: string; } /** * Build a detailed error message explaining why no infrastructure was selected */ function buildInfrastructureErrorMessage(zone: string, rejections: RejectionReason[]): string { if (rejections.length === 0) { return `No infrastructure available for zone '${zone}'. Configure a container service with 'celilo service add' or add a machine with 'celilo machine add'.`; } const details = rejections .map((r) => ` - ${r.hostname} (${r.ipAddress}): ${r.reason}`) .join('\n'); return `No suitable infrastructure in zone '${zone}'. Machines found but rejected:\n${details}`; } /** * Check if a module provides the 'firewall' capability */ function isFirewallModule(module: Module): boolean { const manifest = module.manifestData as ModuleManifest; return manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall') ?? false; } /** * Validate that a machine's role is compatible with a module * Firewall modules require routers; non-firewall modules require hosts. */ function validateMachineRoleForModule(machine: Machine, module: Module): void { const machineRole: MachineRole = machine.role || 'host'; const isFirewall = isFirewallModule(module); if (isFirewall && machineRole === 'host') { throw new InfrastructureError( `Machine '${machine.hostname}' is a single-interface host, but firewall modules require a router with multiple interfaces. Re-classify the machine or choose different infrastructure.`, ); } if (!isFirewall && machineRole === 'router') { throw new InfrastructureError( `Machine '${machine.hostname}' is a multi-interface router. Non-firewall modules cannot be deployed on routers for security reasons.`, ); } } /** * Select infrastructure for a module * Priority: Container services first, then machine pool */ export async function selectInfrastructure(module: Module): Promise { const requirements = getResourceRequirements(module); const zone = requirements.zone; const moduleId = module.id; // 0. Check for earmarked machines first (highest priority). // Deliberately NOT zone-filtered: an earmark is the operator stating outright // which box a module goes on, so it outranks the manifest's zone requirement // (which exists to *pick* a host when nobody said). Filtering by zone made an // earmarked machine silently invisible and produced a "no infrastructure in // zone X" error that never mentioned the box the operator had named. The // control plane is the case that forced this: celilo-mgmt declares `internal` // (it bootstraps before any firewall exists) but legitimately lives on // `secure-mgmt` in a segmented fleet. Role validation below still applies. const earmarked = (await listMachines()).find((m) => m.earmarkedModule === moduleId); if (earmarked) { validateMachineRoleForModule(earmarked, module); return { type: 'machine', machineId: earmarked.id, }; } // 1. Check container services (preferred over generic machines) const allServices = await listContainerServices({ zones: [zone] }); // Filter to only verified services const verifiedServices = allServices.filter((service) => service.verified); if (verifiedServices.length > 0) { // Use first available verified service // TODO: Add load balancing, cost optimization, service preference const service = verifiedServices[0]; return { type: 'container_service', serviceId: service.id, }; } // 2. Fall back to machine pool const allMachines = await listMachines({ zone }); // Filter to machines with sufficient capacity, excluding earmarked machines for other modules const availableMachines: Machine[] = []; const rejections: RejectionReason[] = []; for (const machine of allMachines) { // Skip machines earmarked for a different module if (machine.earmarkedModule && machine.earmarkedModule !== moduleId) { rejections.push({ hostname: machine.hostname, ipAddress: machine.ipAddress, reason: `earmarked for module '${machine.earmarkedModule}'`, }); continue; } // Skip machines already assigned to other modules const occupants = getModulesOnMachine(machine.id); if (occupants.length > 0 && !occupants.includes(moduleId)) { rejections.push({ hostname: machine.hostname, ipAddress: machine.ipAddress, reason: `already assigned to module(s): ${occupants.join(', ')}`, }); continue; } // Skip machines with incompatible role const machineRole: MachineRole = machine.role || 'host'; const isFirewall = isFirewallModule(module); if (isFirewall && machineRole !== 'router') { rejections.push({ hostname: machine.hostname, ipAddress: machine.ipAddress, reason: 'single-interface host, but firewall modules require a router', }); continue; } if (!isFirewall && machineRole === 'router') { rejections.push({ hostname: machine.hostname, ipAddress: machine.ipAddress, reason: 'multi-interface router, cannot be used for non-firewall modules', }); continue; } if (machineHasCapacity(machine, requirements)) { availableMachines.push(machine); } else { const shortfalls: string[] = []; const remainingCpu = machine.hardware.cpu_cores; const remainingMemory = machine.hardware.memory_mb; const remainingDisk = machine.hardware.disk_gb; if (remainingCpu < requirements.cpu) { shortfalls.push(`CPU: ${remainingCpu} available, ${requirements.cpu} required`); } if (remainingMemory < requirements.memory) { shortfalls.push( `memory: ${remainingMemory} MB available, ${requirements.memory} MB required`, ); } if (remainingDisk < requirements.disk) { shortfalls.push(`disk: ${remainingDisk} GB available, ${requirements.disk} GB required`); } rejections.push({ hostname: machine.hostname, ipAddress: machine.ipAddress, reason: `insufficient resources (${shortfalls.join(', ')})`, }); } } if (availableMachines.length > 0) { // Use first available machine // TODO: Add load balancing, resource optimization const machine = availableMachines[0]; return { type: 'machine', machineId: machine.id, }; } // 3. No infrastructure available throw new InfrastructureError(buildInfrastructureErrorMessage(zone, rejections)); }