/** * IPAM Auto-Allocator * Automatic VMID and IP allocation for container-based modules * * - Modules declare zone, Celilo auto-allocates vmid/IP * - Sequential allocation with reservation support * - Persistent storage in ip_allocations table */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { ipAllocations } from '../db/schema'; import { releaseIngressReservations } from './allocator'; export interface IpamAllocation { moduleId: string; vmid: number; containerIp: string; zone: string; } /** * Get existing allocation for module * * @param moduleId - Module identifier * @param db - Drizzle database instance * @returns Existing allocation or null */ export function getAllocation(moduleId: string, db: DbClient): IpamAllocation | null { const allocation = db .select() .from(ipAllocations) .where(eq(ipAllocations.moduleId, moduleId)) .get(); if (!allocation) { return null; } return { moduleId: allocation.moduleId, vmid: allocation.vmid, containerIp: allocation.containerIp, zone: allocation.zone, }; } /** * Allocate VMID from available pool * * Algorithm: * - Start from 200 (100-199 reserved for infrastructure) * - Skip already allocated VMIDs * - Skip reserved VMID ranges * - Return first available VMID * * @param db - SQLite database instance (for raw queries) * @returns Next available VMID * @throws Error if no VMIDs available */ function allocateVMID(db: DbClient['$client']): number { // Get all allocated VMIDs const allocatedRows = db.prepare('SELECT vmid FROM ip_allocations').all() as Array<{ vmid: number; }>; // Get all reserved VMIDs (including ranges) const reservedRows = db.prepare('SELECT vmid FROM vmid_reservations').all() as Array<{ vmid: number; }>; const used = new Set(); // Add allocated VMIDs for (const row of allocatedRows) { used.add(row.vmid); } // Add reserved VMIDs for (const row of reservedRows) { used.add(row.vmid); } // Find first available VMID starting from 200 const START_VMID = 200; const MAX_VMID = 999999999; for (let candidate = START_VMID; candidate <= MAX_VMID; candidate++) { if (!used.has(candidate)) { return candidate; } } throw new Error('No available VMIDs in pool (200-999999999)'); } /** * Parse CIDR subnet to get network address and prefix * * @param subnet - CIDR subnet (e.g., "10.0.10.0/24") * @returns Parsed subnet components */ function parseSubnet(subnet: string): { octets: number[]; prefix: number; } { const [network, prefixStr] = subnet.split('/'); const octets = network.split('.').map(Number); const prefix = Number.parseInt(prefixStr, 10); return { octets, prefix }; } /** * Allocate IP address from zone subnet * * Algorithm: * - Get zone subnet from system config * - Parse CIDR to get network range * - Skip .0 (network), .1 (gateway), .255 (broadcast) * - Start allocation from .10 (reserve .2-.9 for infrastructure) * - Skip already allocated IPs * - Skip reserved IP ranges * - Return first available IP * * @param zone - Network zone (dmz, app, secure) * @param db - SQLite database instance * @returns Next available IP address * @throws Error if no IPs available or subnet not configured */ function allocateIP(zone: string, db: DbClient['$client']): string { // Get zone subnet from system config const subnetRow = db .prepare('SELECT value FROM system_config WHERE key = ?') .get(`network.${zone}.subnet`) as { value: string } | undefined; if (!subnetRow) { throw new Error( `Zone subnet not configured: network.${zone}.subnet\n` + `Run: celilo system config set network.${zone}.subnet `, ); } const subnet = subnetRow.value; const { octets, prefix } = parseSubnet(subnet); const [a, b, c] = octets; // IP allocation range: .10 to .254 // .0 = network address // .1 = gateway (reserved) // .2-.9 = infrastructure (reserved for manual use) // .10-.254 = auto-allocation pool // .255 = broadcast address const START_OCTET = 10; const END_OCTET = 254; // Get all allocated IPs in this zone const allocatedRows = db .prepare('SELECT container_ip FROM ip_allocations WHERE zone = ?') .all(zone) as Array<{ container_ip: string }>; // Get all reserved IPs in this zone const reservedRows = db .prepare('SELECT ip_start, ip_end FROM ip_reservations WHERE zone = ?') .all(zone) as Array<{ ip_start: string; ip_end: string | null }>; const used = new Set(); // Add allocated IPs (strip CIDR suffix for comparison) for (const row of allocatedRows) { const ipOnly = row.container_ip.split('/')[0]; used.add(ipOnly); } // Add reserved IPs (including ranges) for (const row of reservedRows) { if (row.ip_end) { // Handle IP range: "10.0.10.1-10.0.10.9" const startOctet = Number.parseInt(row.ip_start.split('.')[3], 10); const endOctet = Number.parseInt(row.ip_end.split('.')[3], 10); for (let i = startOctet; i <= endOctet; i++) { used.add(`${a}.${b}.${c}.${i}`); } } else { // Single IP reservation used.add(row.ip_start); } } // Find first available IP for (let octet = START_OCTET; octet <= END_OCTET; octet++) { const candidate = `${a}.${b}.${c}.${octet}`; if (!used.has(candidate)) { return `${candidate}/${prefix}`; } } throw new Error( `No available IPs in zone ${zone} subnet ${subnet}\n` + `Allocated: ${allocatedRows.length}, Reserved: ${reservedRows.length}`, ); } /** * Allocate VMID and IP for module * * Main allocation function - coordinates VMID and IP allocation * and stores result in database. * * @param moduleId - Module identifier * @param zone - Network zone (dmz, app, secure) * @param drizzleDb - Drizzle database instance (for inserts) * @param sqliteDb - SQLite database instance (for raw queries) * @returns Allocated VMID and IP */ export async function allocateForModule( moduleId: string, zone: string, drizzleDb: DbClient, sqliteDb: DbClient['$client'], ): Promise { // Check if already allocated const existing = getAllocation(moduleId, drizzleDb); if (existing) { return existing; } // Allocate VMID const vmid = allocateVMID(sqliteDb); // Allocate IP from zone subnet const containerIp = allocateIP(zone, sqliteDb); // Store allocation await drizzleDb.insert(ipAllocations).values({ moduleId, vmid, containerIp, zone: zone as 'dmz' | 'app' | 'secure' | 'internal', }); return { moduleId, vmid, containerIp, zone, }; } /** * Deallocate VMID and IP for module * * Removes allocation from database, making VMID and IP available * for future allocations. * * @param moduleId - Module identifier * @param db - Drizzle database instance * @returns True if allocation was removed, false if none existed */ export async function deallocateForModule(moduleId: string, db: DbClient): Promise { // Ingress reservations are released FIRST and unconditionally (celilo#892). // A module can hold one with no `ip_allocations` row at all — it deploys onto // a machine rather than a celilo-provisioned container — so releasing it // after the early return below would skip exactly the modules that leak. await releaseIngressReservations(moduleId, db); // Check if allocation exists before deleting const existing = getAllocation(moduleId, db); if (!existing) { return false; } await db.delete(ipAllocations).where(eq(ipAllocations.moduleId, moduleId)); return true; }