/** * IPAM (IP Address Management) commands * Manage VMID and IP address reservations and allocations */ import { getDb } from '../../db/client'; import { inferZoneFromIP, listReservations, listVMIDReservations, reserveIP, reserveVMID, unreserveIP, unreserveVMID, updateReservationReason, } from '../../ipam/allocator'; import { getArg, getFlag, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; /** * Handle IPAM VMID reserve command * * Usage: celilo ipam vmid reserve --reason * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamVmidReserve( args: string[], flags: Record, ): Promise { const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo ipam vmid reserve --reason `, }; } const vmidArg = getArg(args, 0); if (!vmidArg) { return { success: false, error: 'VMID or range is required', }; } // Parse reason flag const reason = getFlag(flags, 'reason'); if (!reason) { return { success: false, error: 'Reason is required. Use --reason "description"', }; } const db = getDb(); try { // Parse VMID or range if (vmidArg.includes('-')) { // Range format: "2100-2110" const [startStr, endStr] = vmidArg.split('-'); const start = Number.parseInt(startStr, 10); const end = Number.parseInt(endStr, 10); if (Number.isNaN(start) || Number.isNaN(end)) { return { success: false, error: `Invalid VMID range: ${vmidArg}`, }; } if (start >= end) { return { success: false, error: 'Invalid range: start must be less than end', }; } // Reserve each VMID in range for (let vmid = start; vmid <= end; vmid++) { await reserveVMID(vmid, reason, db); } return { success: true, message: `Reserved VMIDs ${start}-${end}: ${reason}`, }; } // Single VMID const vmid = Number.parseInt(vmidArg, 10); if (Number.isNaN(vmid)) { return { success: false, error: `Invalid VMID: ${vmidArg}`, }; } await reserveVMID(vmid, reason, db); return { success: true, message: `Reserved VMID ${vmid}: ${reason}`, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM VMID unreserve command * * Usage: celilo ipam vmid unreserve * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamVmidUnreserve( args: string[], _flags: Record, ): Promise { const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo ipam vmid unreserve `, }; } const vmidArg = getArg(args, 0); if (!vmidArg) { return { success: false, error: 'VMID or range is required', }; } const db = getDb(); try { // Parse VMID or range if (vmidArg.includes('-')) { // Range format: "2100-2110" const [startStr, endStr] = vmidArg.split('-'); const start = Number.parseInt(startStr, 10); const end = Number.parseInt(endStr, 10); if (Number.isNaN(start) || Number.isNaN(end)) { return { success: false, error: `Invalid VMID range: ${vmidArg}`, }; } if (start >= end) { return { success: false, error: 'Invalid range: start must be less than end', }; } // Unreserve each VMID in range for (let vmid = start; vmid <= end; vmid++) { await unreserveVMID(vmid, db); } return { success: true, message: `Unreserved VMIDs ${start}-${end}`, }; } // Single VMID const vmid = Number.parseInt(vmidArg, 10); if (Number.isNaN(vmid)) { return { success: false, error: `Invalid VMID: ${vmidArg}`, }; } await unreserveVMID(vmid, db); return { success: true, message: `Unreserved VMID ${vmid}`, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM VMID list-reservations command * * Usage: celilo ipam vmid list-reservations * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamVmidListReservations( _args: string[], _flags: Record, ): Promise { const db = getDb(); try { const reservations = await listVMIDReservations(db); if (reservations.length === 0) { return { success: true, message: 'No VMID reservations', }; } const lines = ['VMID Reservations:', '']; for (const reservation of reservations) { lines.push(`VMID ${reservation.vmid}: ${reservation.reason}`); lines.push(` Reserved: ${new Date(reservation.reservedAt).toLocaleString()}`); } return { success: true, message: lines.join('\n'), data: reservations, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM IP exclude command * * Usage: celilo ipam ip exclude --reason [--zone ] * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamIpReserve( args: string[], flags: Record, ): Promise { const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo ipam ip exclude --reason [--zone ]`, }; } const ipArg = getArg(args, 0); if (!ipArg) { return { success: false, error: 'IP or range is required', }; } // Parse reason flag const reason = getFlag(flags, 'reason'); if (!reason) { return { success: false, error: 'Reason is required. Use --reason "description"', }; } const db = getDb(); try { // Parse IP or range let ipStart: string; let ipEnd: string | null = null; if (ipArg.includes('-')) { // Range format: "10.0.10.1-10.0.10.9" const [start, end] = ipArg.split('-'); ipStart = start.trim(); ipEnd = end.trim(); } else { // Single IP ipStart = ipArg; } // Infer zone from IP, or use explicit --zone override const explicitZone = getFlag(flags, 'zone'); const zone = explicitZone || (await inferZoneFromIP(ipStart, db)); if (!zone) { return { success: false, error: `Cannot determine zone for IP ${ipStart}. It doesn't match any configured zone subnet. Use --zone to specify manually.`, }; } if ( zone !== 'dmz' && zone !== 'app' && zone !== 'secure' && zone !== 'secure-mgmt' && zone !== 'internal' ) { return { success: false, error: `Invalid zone: ${zone}. Must be internal, dmz, app, or secure`, }; } await reserveIP(ipStart, zone, reason, ipEnd, db); return { success: true, message: `Excluded IP ${ipEnd ? `${ipStart}-${ipEnd}` : ipStart} in zone ${zone}: ${reason}`, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM IP include command (remove exclusion) * * Usage: celilo ipam ip include [--zone ] * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamIpUnreserve( args: string[], flags: Record, ): Promise { const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo ipam ip include [--zone ]`, }; } const ipArg = getArg(args, 0); if (!ipArg) { return { success: false, error: 'IP is required', }; } const db = getDb(); try { // Parse IP (take first part if range) const ipStart = ipArg.includes('-') ? ipArg.split('-')[0].trim() : ipArg; // Infer zone from IP, or use explicit --zone override const explicitZone = getFlag(flags, 'zone'); const zone = explicitZone || (await inferZoneFromIP(ipStart, db)); if (!zone) { return { success: false, error: `Cannot determine zone for IP ${ipStart}. Use --zone to specify manually.`, }; } if ( zone !== 'dmz' && zone !== 'app' && zone !== 'secure' && zone !== 'secure-mgmt' && zone !== 'internal' ) { return { success: false, error: `Invalid zone: ${zone}. Must be internal, dmz, app, or secure`, }; } await unreserveIP(ipStart, zone, db); return { success: true, message: `Removed exclusion for IP ${ipArg} in zone ${zone}`, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM IP edit command (change an exclusion's reason in place) * * Usage: celilo ipam ip edit --reason [--zone ] * * Editing beats include-then-exclude: that dance drops the row for a moment * and can race an allocation into the address it was holding. It also matters * because reservations celilo writes itself carry generated reasons that can be * identical between a live row and a dead one (celilo#892) — an operator needs * a way to annotate which is which. * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamIpEdit( args: string[], flags: Record, ): Promise { const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo ipam ip edit --reason [--zone ]`, }; } const ipArg = getArg(args, 0); if (!ipArg) { return { success: false, error: 'IP is required', }; } const reason = getFlag(flags, 'reason'); if (!reason) { return { success: false, error: 'Reason is required. Use --reason "description"', }; } const db = getDb(); try { // A range is addressed by its first IP, the same way `include` does it. const ipStart = ipArg.includes('-') ? ipArg.split('-')[0].trim() : ipArg; const explicitZone = getFlag(flags, 'zone'); const zone = explicitZone || (await inferZoneFromIP(ipStart, db)); if (!zone) { return { success: false, error: `Cannot determine zone for IP ${ipStart}. Use --zone to specify manually.`, }; } if ( zone !== 'dmz' && zone !== 'app' && zone !== 'secure' && zone !== 'secure-mgmt' && zone !== 'internal' ) { return { success: false, error: `Invalid zone: ${zone}. Must be internal, dmz, app, or secure`, }; } const updated = await updateReservationReason(ipStart, zone, reason, db); if (!updated) { return { success: false, error: `No IP exclusion for ${ipStart} in zone ${zone}. List them with: celilo ipam ip list-exclusions`, }; } return { success: true, message: `Updated exclusion for IP ${ipStart} in zone ${zone}: ${reason}`, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM IP list-exclusions command * * Usage: celilo ipam ip list-exclusions * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamIpListReservations( _args: string[], _flags: Record, ): Promise { const db = getDb(); try { const reservations = await listReservations(db); if (reservations.length === 0) { return { success: true, message: 'No IP exclusions', }; } const lines = ['IP Exclusions:', '']; for (const reservation of reservations) { const range = reservation.ipEnd ? `${reservation.ipStart}-${reservation.ipEnd}` : reservation.ipStart; lines.push(`${range} (${reservation.zone}): ${reservation.reason}`); lines.push(` Excluded: ${new Date(reservation.reservedAt).toLocaleString()}`); } return { success: true, message: lines.join('\n'), data: reservations, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM show command - comprehensive summary * * Usage: celilo ipam show * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamShow( _args: string[], _flags: Record, ): Promise { const db = getDb(); try { const { ipAllocations } = await import('../../db/schema'); const allocations = await db.select().from(ipAllocations).all(); const ipReservations = await listReservations(db); const vmidReservations = await listVMIDReservations(db); const lines: string[] = ['IPAM Allocations Summary', '']; // VMID section lines.push('VMID Allocations:'); if (allocations.length === 0) { lines.push(' (none)'); } else { for (const allocation of allocations) { lines.push(` ${allocation.vmid}: ${allocation.moduleId} (allocated)`); } } lines.push(''); lines.push('VMID Reservations:'); if (vmidReservations.length === 0) { lines.push(' (none)'); } else { for (const reservation of vmidReservations) { lines.push(` ${reservation.vmid}: ${reservation.reason}`); } } lines.push(''); // IP section - group by zone const zones = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt']; for (const zone of zones) { const zoneAllocations = allocations.filter((a) => a.zone === zone); const zoneReservations = ipReservations.filter((r) => r.zone === zone); if (zoneAllocations.length === 0 && zoneReservations.length === 0) { continue; // Skip empty zones } const zoneName = zone.toUpperCase(); lines.push(`IP Allocations (${zoneName}):`); if (zoneAllocations.length === 0) { lines.push(' (none)'); } else { for (const allocation of zoneAllocations) { lines.push(` ${allocation.containerIp}: ${allocation.moduleId} (allocated)`); } } lines.push(''); lines.push(`IP Exclusions (${zoneName}):`); if (zoneReservations.length === 0) { lines.push(' (none)'); } else { for (const reservation of zoneReservations) { const range = reservation.ipEnd ? `${reservation.ipStart}-${reservation.ipEnd}` : reservation.ipStart; lines.push(` ${range}: ${reservation.reason}`); } } lines.push(''); } // Summary lines.push('Summary:'); lines.push(` VMIDs: ${allocations.length} allocated, ${vmidReservations.length} reserved`); const totalIpAllocations = allocations.length; const totalIpReservations = ipReservations.reduce((sum, r) => { if (r.ipEnd) { // Calculate range size const startOctet = Number.parseInt(r.ipStart.split('.')[3], 10); const endOctet = Number.parseInt(r.ipEnd.split('.')[3], 10); return sum + (endOctet - startOctet + 1); } return sum + 1; }, 0); lines.push(` IPs: ${totalIpAllocations} allocated, ${totalIpReservations} excluded`); return { success: true, message: lines.join('\n'), data: { allocations, ipReservations, vmidReservations, }, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } } /** * Handle IPAM list-allocations command * * Usage: celilo ipam list-allocations * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleIpamListAllocations( _args: string[], _flags: Record, ): Promise { const db = getDb(); try { const { ipAllocations } = await import('../../db/schema'); const allocations = await db.select().from(ipAllocations).all(); if (allocations.length === 0) { return { success: true, message: 'No IPAM allocations', }; } const lines = ['IPAM Allocations:', '']; for (const allocation of allocations) { lines.push(`Module: ${allocation.moduleId}`); lines.push(` VMID: ${allocation.vmid}`); lines.push(` IP: ${allocation.containerIp}`); lines.push(` Zone: ${allocation.zone}`); lines.push(` Allocated: ${new Date(allocation.allocatedAt).toLocaleString()}`); lines.push(''); } return { success: true, message: lines.join('\n'), data: allocations, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } }