/** * Pure guardrails for `celilo proxmox … resize` (ISS-0150 D4, disk landed in * celilo#1133). Kept separate from the command so the floor + capacity logic is * unit-testable without Proxmox or a DB — which is what let the disk paths be * proven with the fleet unreachable. * * See openspec/changes/proxmox-capacity-lifecycle/. (The header used to point at * apps/celilo/designs/PROXMOX_INSTANCE_SIZING.md, which does not exist and cannot: * that directory is gitignored, so a doc written there is never committed and * leaves with the worktree. CLAUDE.md flags the path as a trap.) */ export interface ResizeRequest { cpu?: number; memory?: number; // MB disk?: number; // GB } export interface ResizeContext { /** Current canonical size of the system (null where unknown). */ current: { cpu: number | null; memory: number | null; disk: number | null }; /** * Minimum floor — `max(requires.system.)` across every module co-hosted * on this system. A resize may never drop below any tenant's minimum. */ floor: { cpu: number; memory: number; disk: number }; /** Free RAM on the target node — memory growth must fit (delta check). */ nodeFreeMemMb: number; /** Physical cores on the target node — a VM's vCPU can't exceed this (absolute). */ nodeTotalCores: number; /** Free storage on the target node — disk growth must fit (delta check). */ nodeFreeDiskGb: number; } export interface ResizeDecision { /** Hard errors that block the resize (unless overridden where noted). */ errors: string[]; /** The size that would be applied (requested fields merged over current). */ effective: { cpu: number | null; memory: number | null; disk: number | null }; /** cpu/memory changes need a Proxmox stop/start; disk-only growth does not. */ needsReboot: boolean; /** True when the only blocker is capacity (so `--force` can override). */ capacityOnly: boolean; } /** * Validate a resize request against the floor and node capacity. * * Disk shrink is rejected: Proxmox grows a disk online but has no safe shrink * for either lxc or qemu — the guest filesystem would have to be shrunk first, * from inside, and celilo does not do that. Disk GROWTH is additive and needs * no stop/start, which is why `needsReboot` keys on cpu/memory alone. */ export function validateResize( req: ResizeRequest, ctx: ResizeContext, opts: { force: boolean } = { force: false }, ): ResizeDecision { const effective = { cpu: req.cpu ?? ctx.current.cpu, memory: req.memory ?? ctx.current.memory, disk: req.disk ?? ctx.current.disk, }; // Hard errors — never overridable, not even with --force. const hardErrors: string[] = []; if (req.cpu != null && req.cpu < ctx.floor.cpu) { hardErrors.push(`cpu ${req.cpu} is below the module minimum (${ctx.floor.cpu})`); } if (req.memory != null && req.memory < ctx.floor.memory) { hardErrors.push(`memory ${req.memory}MB is below the module minimum (${ctx.floor.memory}MB)`); } if (req.disk != null && req.disk < ctx.floor.disk) { hardErrors.push(`disk ${req.disk}GB is below the module minimum (${ctx.floor.disk}GB)`); } // Shrink is refused on its own terms, not as a floor violation: a disk well // above every tenant's minimum still cannot be shrunk. Say WHY rather than // rejecting bare, because the operator's next move differs — a floor error // means "pick a bigger number", a shrink error means "you cannot do this at // all, migrate instead". if (req.disk != null && ctx.current.disk != null && req.disk < ctx.current.disk) { hardErrors.push( `disk ${req.disk}GB is smaller than the current ${ctx.current.disk}GB — Proxmox cannot shrink a disk safely. Provision a smaller instance and migrate the data instead.`, ); } const capErrors: string[] = []; // cpu: a single VM's vCPU can't usefully exceed the node's physical cores. if (req.cpu != null && req.cpu > ctx.nodeTotalCores) { capErrors.push(`cpu ${req.cpu} exceeds node physical cores (${ctx.nodeTotalCores})`); } // memory: only the GROWTH beyond the current size draws on free RAM. if (req.memory != null && ctx.current.memory != null) { const addedMb = req.memory - ctx.current.memory; if (addedMb > ctx.nodeFreeMemMb) { capErrors.push(`+${addedMb}MB exceeds node free RAM (${ctx.nodeFreeMemMb}MB)`); } } // disk: only the GROWTH beyond the current size draws on the node's storage. if (req.disk != null && ctx.current.disk != null) { const addedGb = req.disk - ctx.current.disk; if (addedGb > ctx.nodeFreeDiskGb) { capErrors.push(`+${addedGb}GB exceeds node free storage (${ctx.nodeFreeDiskGb}GB)`); } } const capacityOnly = hardErrors.length === 0 && capErrors.length > 0; // Floor and shrink are hard (never overridable). Capacity yields to --force. const errors = [...hardErrors, ...(opts.force ? [] : capErrors)]; // Disk growth is applied online by Proxmox's resize API — no stop/start. Only // a cpu/memory change needs the guest power-cycled. const needsReboot = req.cpu != null || req.memory != null; return { errors, effective, needsReboot, capacityOnly }; } /** * Compute the floor for a system from the requires.system minimums of every * module co-hosted on it. Pure over the extracted minimums. */ export function computeFloor( mins: Array<{ cpu?: number | null; memory?: number | null; disk?: number | null }>, ): { cpu: number; memory: number; disk: number } { const maxOf = (pick: (m: (typeof mins)[number]) => number | null | undefined): number => mins.reduce((acc, m) => Math.max(acc, pick(m) ?? 0), 0); return { cpu: maxOf((m) => m.cpu), memory: maxOf((m) => m.memory), disk: maxOf((m) => m.disk), }; }