"use strict"; const { SessionError } = require("../sessions/sessionManager"); const { MiB, PARTITION_TYPE_CODES, SYSTEM_MOUNTS, buildSegments } = require("./inventory"); // Turns a queue of user operations (GParted-style) into a validated, simulated plan of whitelisted remote steps. // Each operation is checked against the layout produced by the operations before it, so "delete partition 3, then // grow partition 2" works. Changing a partition boundary and resizing the content are separate steps, ordered by // the content type and its layers (disk -> partition -> LUKS -> LVM -> file system). Nothing here touches a server. const MAX_OPERATIONS = 32; const AUTO_MIN_BYTES = 16 * MiB; const FILESYSTEMS = Object.freeze(["ext4", "xfs", "btrfs", "vfat", "ntfs", "swap"]); const MKFS_TOOL = Object.freeze({ ext4: "mkfs.ext4", xfs: "mkfs.xfs", btrfs: "mkfs.btrfs", vfat: "mkfs.vfat", ntfs: "mkfs.ntfs", swap: "mkswap" }); const MIN_FS_BYTES = Object.freeze({ ext4: 16 * MiB, xfs: 300 * MiB, btrfs: 256 * MiB, vfat: 33 * MiB, ntfs: 16 * MiB, swap: MiB }); const LABEL_LIMITS = Object.freeze({ ext2: 16, ext3: 16, ext4: 16, xfs: 12, btrfs: 36, vfat: 11, ntfs: 32, swap: 15 }); const MOUNTABLE = new Set(["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs", "exfat"]); const OPERATION_TYPES = new Set(["create-table", "create-partition", "delete-partition", "resize-partition", "format", "set-label", "set-name", "set-flag", "check", "grow-fs", "mount", "unmount", "lv-extend", "lv-reduce", "pv-resize", "move-partition", "copy-partition"]); const FLAGS = Object.freeze({ gpt: ["legacy_boot", "esp", "bios-boot", "lvm", "raid", "swap", "basic-data"], dos: ["boot", "esp", "lvm", "raid", "swap"] }); const LABEL_PATTERN = /^[A-Za-z0-9._-]{1,36}$/; const MOUNTPOINT_PATTERN = /^\/(mnt|media)\/[A-Za-z0-9._-]{1,64}(\/[A-Za-z0-9._-]{1,64}){0,3}$/; function fail(code, message, details) { return new SessionError(`DISKS_${code}`, message, details); } const clone = (value) => JSON.parse(JSON.stringify(value)); const isExt = (fstype) => /^ext[234]$/.test(String(fstype || "")); function partPath(diskName, number) { return `/dev/${diskName}${/\d$/.test(diskName) ? "p" : ""}${number}`; } // ---------- simulation state ---------- function createSim(inventory) { const sim = { inventory, disks: new Map(), nodes: new Map(), byPath: new Map(), lvm: clone(inventory.lvm || { pvs: [], vgs: [], lvs: [] }) }; for (const disk of inventory.disks) sim.disks.set(disk.name, clone(disk)); for (const node of inventory.nodes) addNode(sim, clone(node)); return sim; } function addNode(sim, node) { sim.nodes.set(node.kname, node); for (const path of [node.path, `/dev/${node.kname}`, node.displayPath].filter(Boolean)) sim.byPath.set(path, node); } function nodeFor(sim, device) { const node = sim.byPath.get(String(device || "")); if (!node) throw fail("DEVICE_NOT_FOUND", `${device} was not found in the current inventory.`); return node; } function diskFor(sim, name) { const disk = sim.disks.get(String(name || "").replace(/^\/dev\//, "")); if (!disk) throw fail("DEVICE_NOT_FOUND", `Disk ${name} was not found in the current inventory.`); return disk; } function partFor(disk, number) { const part = disk.partitions.find((entry) => entry.number === Number(number)); if (!part) throw fail("DEVICE_NOT_FOUND", `Partition ${number} does not exist on ${disk.path}.`); return part; } function partNode(sim, disk, part) { return (part.kernel && sim.nodes.get(part.kernel)) || sim.byPath.get(partPath(disk.name, part.number)) || null; } function inUse(sim, node) { if (!node) return []; const reasons = []; if (node.mountpoints?.length) reasons.push(`mounted at ${node.mountpoints.join(", ")}`); if (node.swapActive) reasons.push("active swap"); if (node.children?.length) reasons.push(`used by ${node.children.map((name) => sim.nodes.get(name)?.displayPath || sim.nodes.get(name)?.path || name).join(", ")}`); if (node.fstype === "zfs_member") reasons.push("ZFS pool member"); if (node.fstype === "linux_raid_member") reasons.push("RAID member"); return reasons; } function requireFree(sim, node, code = "DEVICE_IN_USE") { const reasons = inUse(sim, node); if (reasons.length) throw fail(code, `${node.displayPath || node.path} is in use (${reasons.join("; ")}). CH-J never unmounts, deactivates or closes it automatically.`); } function refreshSegments(disk) { if (!disk.table) { disk.segments = []; return; } disk.partitions.sort((a, b) => a.start - b.start); disk.segments = buildSegments(disk.partitions, disk.table.firstUsable, disk.table.lastUsable, disk.alignSectors, disk.logicalSectorSize, disk.table.lastUsableNow); } const alignUp = (value, align) => Math.ceil(value / align) * align; const alignDown = (value, align) => Math.floor(value / align) * align; // The free space directly after a partition (up to the next partition or the end of the usable area). function boundaryAfter(disk, part) { const next = disk.partitions.filter((entry) => !entry.logical && entry.start > part.start).sort((a, b) => a.start - b.start)[0]; return next ? next.start - 1 : disk.table.lastUsable; } // ---------- plan context ---------- function createContext(sim) { return { sim, steps: [], warnings: [], notes: [], verifyDisks: new Set(), backupDisks: new Set(), verifySizes: new Map(), relocated: new Set(), dataLoss: false, offline: new Set(), mayReboot: false, operations: [] }; } function step(ctx, name, args, info = {}) { const values = args.map((value) => String(value)); for (const value of values) if (!/^[A-Za-z0-9._/:+,=@-]{1,160}$/.test(value)) throw fail("ARGUMENTS_INVALID", `Invalid step argument for ${name}.`); ctx.steps.push({ name, args: values, operation: ctx.currentOperation, kind: info.kind || "change", destructive: info.destructive === true }); } function touchDisk(ctx, disk, { backup = false } = {}) { if (disk.protected && backup) throw fail("PROTECTED_DEVICE", `${disk.path} holds Proxmox host storage; CH-J does not change its partition table.`); ctx.verifyDisks.add(disk.name); if (backup) ctx.backupDisks.add(disk.name); } function verifyDevice(ctx, sim, node) { if (node.kind === "part") { const disk = sim.disks.get(node.parent); if (disk) touchDisk(ctx, disk); return; } const original = sim.inventory.nodes.find((entry) => entry.kname === node.kname); if (original && !ctx.verifySizes.has(original.path)) ctx.verifySizes.set(original.path, original.size); } function needTool(sim, tool, purpose) { if (!sim.inventory.tools[tool]) throw fail("TOOL_MISSING", `${tool} is required for ${purpose} but is not installed on the server. CH-J does not install packages.`, { tool }); } function diskOfNode(sim, node) { let current = node; for (let depth = 0; current && depth < 16; depth += 1) { if (current.kind === "part") return sim.disks.get(current.parent) || null; if (current.kind === "disk" || current.kind === "loop") return sim.disks.get(current.kname) || null; current = sim.nodes.get(current.parents?.[0]); } return null; } // Boot partitions of the disk that runs the system are not "in use" by a mount, but deleting or formatting them // makes the server unbootable. They are treated like mounted system partitions. function assertNotBootPartition(disk, part) { if (disk?.system && ["bios-boot", "esp"].includes(part?.typeName)) throw fail("SYSTEM_PARTITION", `${part.path || `Partition ${part.number}`} is the ${part.typeName === "esp" ? "EFI system" : "BIOS boot"} partition of the disk that runs the system; CH-J does not delete or format it.`); } function assertNotProtected(sim, node, action) { const disk = diskOfNode(sim, node); if (disk?.protected || node?.lvm?.pveGuest) throw fail("PROTECTED_DEVICE", `${node.displayPath || node.path} belongs to Proxmox host or guest storage; CH-J does not ${action} it.`); } // ---------- content chains (grow outward from the partition, shrink inward) ---------- function growContent(sim, ctx, node, newBytes, options = {}) { if (!node) return; const fstype = node.fstype; const path = node.path; if (!fstype) { node.size = newBytes; return; } if (isExt(fstype)) { needTool(sim, "resize2fs", "growing ext file systems"); step(ctx, "grow-fs", [path, fstype], { kind: "filesystem" }); } else if (fstype === "xfs") { needTool(sim, "xfs_growfs", "growing XFS"); if (!node.mountpoints?.length) throw fail("XFS_GROW_NEEDS_MOUNT", `XFS can only be grown while mounted; mount ${path} first (add a mount operation before this one).`); step(ctx, "grow-fs", [path, fstype], { kind: "filesystem" }); } else if (fstype === "btrfs") { needTool(sim, "btrfs", "growing Btrfs"); if (!node.mountpoints?.length) throw fail("BTRFS_NEEDS_MOUNT", `Btrfs is resized while mounted; mount ${path} first.`); step(ctx, "grow-fs", [path, fstype], { kind: "filesystem" }); } else if (fstype === "ntfs") { needTool(sim, "ntfsresize", "growing NTFS"); requireFree(sim, node, "NTFS_MOUNTED"); step(ctx, "grow-fs", [path, fstype], { kind: "filesystem" }); } else if (fstype === "swap") { needTool(sim, "mkswap", "resizing swap"); if (node.swapActive) throw fail("SWAP_ACTIVE", `${path} is active swap; CH-J never disables swap automatically. Deactivate it yourself first.`); step(ctx, "grow-fs", [path, fstype], { kind: "filesystem" }); } else if (fstype === "crypto_LUKS") { const mapping = sim.nodes.get(node.children?.[0]); if (!mapping) { ctx.notes.push({ code: "LUKS_CLOSED", device: path }); node.size = newBytes; return; } needTool(sim, "cryptsetup", "resizing LUKS mappings"); if (/keyring/i.test(mapping.crypt?.keyLocation || "")) throw fail("LUKS_PASSPHRASE_REQUIRED", `The LUKS2 volume key of ${mapping.path} is held in the kernel keyring, so cryptsetup needs the passphrase to resize it. CH-J never handles LUKS secrets; resize the mapping manually.`); if (!mapping.crypt?.keyLocation) throw fail("LUKS_UNVERIFIED", `The key location of ${mapping.path} could not be verified; only display and diagnostics are available.`); step(ctx, "luks-resize", [mapping.dmName], { kind: "luks" }); const headerBytes = Math.max(0, (node.size || 0) - (mapping.size || 0)); mapping.size = newBytes - headerBytes; growContent(sim, ctx, mapping, mapping.size, options); } else if (fstype === "LVM2_member") { needTool(sim, "pvresize", "resizing LVM physical volumes"); step(ctx, "pv-resize", [path], { kind: "lvm" }); const pv = sim.lvm.pvs.find((entry) => entry.path === path || entry.path === `/dev/${node.kname}`); const vg = pv && sim.lvm.vgs.find((entry) => entry.name === pv.vg); const growth = newBytes - (pv?.size ?? node.size ?? newBytes); if (pv) { pv.size = newBytes; pv.free = (pv.free || 0) + Math.max(0, growth); } if (vg) vg.free = (vg.free || 0) + Math.max(0, growth); if (options.autoLv && vg) { const lv = autoLv(sim, vg); if (lv) extendLv(sim, ctx, lv, "max"); else ctx.notes.push({ code: "VG_FREE_LEFT", vg: vg.name }); } } else if (fstype === "vfat") { throw fail("FAT_RESIZE_UNSUPPORTED", "Resizing FAT is not implemented (fatresize is not reliable enough for unattended use)."); } else { throw fail("RESIZE_UNSUPPORTED", `Resizing content of type ${fstype} is not supported.`); } node.fsSize = newBytes; node.size = newBytes; } function shrinkContent(sim, ctx, node, newBytes) { if (!node || !node.fstype) return; const fstype = node.fstype; const path = node.path; if (isExt(fstype)) { needTool(sim, "resize2fs", "shrinking ext file systems"); needTool(sim, "e2fsck", "checking ext file systems"); if (inUse(sim, node).length) { const root = node.mountpoints?.some((point) => SYSTEM_MOUNTS.test(point)); throw fail("SHRINK_REQUIRES_UNMOUNT", root ? `${path} is mounted at ${node.mountpoints.join(", ")}, which the running system needs. Shrinking ext4 is only possible offline: boot a rescue/live system and shrink it there.` : `ext file systems are shrunk only while unmounted; unmount ${path} first (CH-J never unmounts automatically).`); } const used = node.usage?.used ?? null; if (used === null) throw fail("USAGE_UNKNOWN", `The usage of ${path} could not be determined; shrinking is refused.`); const reserve = Math.max(used / 10, 128 * MiB); if (newBytes < used + reserve) throw fail("SIZE_TOO_SMALL", `The new size must keep the used ${Math.ceil(used / MiB)} MiB plus a reserve of ${Math.ceil(reserve / MiB)} MiB.`, { minimumBytes: Math.ceil(used + reserve) }); step(ctx, "shrink-fs", [path, fstype, newBytes], { kind: "filesystem", destructive: true }); ctx.offline.add(path); } else if (fstype === "btrfs") { needTool(sim, "btrfs", "shrinking Btrfs"); if (!node.mountpoints?.length) throw fail("BTRFS_NEEDS_MOUNT", `Btrfs is resized while mounted; mount ${path} first.`); if ((node.btrfs?.devices || 1) > 1) throw fail("BTRFS_MULTI_DEVICE", "Shrinking a multi-device Btrfs is not supported."); const used = node.btrfs?.allocated ?? node.usage?.used ?? null; if (used === null) throw fail("USAGE_UNKNOWN", `The allocation of ${path} could not be determined; shrinking is refused.`); const reserve = Math.max(used / 10, 256 * MiB); if (newBytes < used + reserve) throw fail("SIZE_TOO_SMALL", `The new size must keep the allocated ${Math.ceil(used / MiB)} MiB plus a reserve of ${Math.ceil(reserve / MiB)} MiB.`, { minimumBytes: Math.ceil(used + reserve) }); step(ctx, "shrink-fs", [path, fstype, newBytes], { kind: "filesystem", destructive: true }); } else if (fstype === "ntfs") { needTool(sim, "ntfsresize", "shrinking NTFS"); requireFree(sim, node, "NTFS_MOUNTED"); step(ctx, "shrink-fs", [path, fstype, newBytes], { kind: "filesystem", destructive: true }); } else if (fstype === "swap") { if (node.swapActive) throw fail("SWAP_ACTIVE", `${path} is active swap; CH-J never disables swap automatically.`); needTool(sim, "mkswap", "resizing swap"); } else if (fstype === "xfs") { throw fail("XFS_SHRINK_UNSUPPORTED", "XFS cannot be shrunk: the standard XFS tools only grow file systems. To get a smaller volume, create a new, smaller file system and migrate the data (for example with xfsdump/xfsrestore or rsync)."); } else if (fstype === "vfat") { throw fail("FAT_RESIZE_UNSUPPORTED", "Resizing FAT is not implemented."); } else if (fstype === "crypto_LUKS") { throw fail("LUKS_SHRINK_UNSUPPORTED", "Shrinking LUKS (and the layers inside it) is never done automatically by CH-J."); } else if (fstype === "LVM2_member") { throw fail("PV_SHRINK_UNSUPPORTED", "Shrinking an LVM physical volume is not automated; move extents and use pvresize manually."); } else { throw fail("RESIZE_UNSUPPORTED", `Shrinking content of type ${fstype} is not supported.`); } } function autoLv(sim, vg) { const candidates = sim.lvm.lvs.filter((lv) => lv.vg === vg.name && !lv.thinPool && !lv.thinVolume && !lv.snapshot && !lv.pveGuest); const withFs = candidates.filter((lv) => { const node = sim.byPath.get(lv.dmPath) || sim.byPath.get(lv.path); return node && ["ext2", "ext3", "ext4", "xfs", "btrfs"].includes(node.fstype); }); if (withFs.length === 1) return withFs[0]; return withFs.find((lv) => (sim.byPath.get(lv.dmPath) || sim.byPath.get(lv.path))?.mountpoints?.includes("/")) || null; } function lvFor(sim, name) { const [vgName, lvName] = String(name || "").split("/"); const lv = sim.lvm.lvs.find((entry) => entry.vg === vgName && entry.name === lvName); if (!lv || !/^[A-Za-z0-9._+-]{1,64}\/[A-Za-z0-9._+-]{1,64}$/.test(String(name))) throw fail("DEVICE_NOT_FOUND", `Logical volume ${name} was not found.`); return lv; } function extendLv(sim, ctx, lv, size) { needTool(sim, "lvextend", "extending logical volumes"); if (lv.thinPool || lv.thinVolume) throw fail("LV_THIN_UNSUPPORTED", "Thin pools and thin volumes are shown read-only: their virtual size, pool data and allocation need manual planning."); if (lv.snapshot) throw fail("LV_SNAPSHOT_UNSUPPORTED", "Snapshots are not resized by CH-J."); if (lv.pveGuest) throw fail("PROTECTED_DEVICE", `This logical volume is a disk of Proxmox guest ${lv.pveGuest}; CH-J does not change it.`); const vg = sim.lvm.vgs.find((entry) => entry.name === lv.vg); const node = sim.byPath.get(lv.dmPath) || sim.byPath.get(lv.path); const extent = vg?.extentSize || 4 * MiB; let target; if (size === "max") target = lv.size + Math.floor((vg?.free || 0) / extent) * extent; else target = Math.floor(Number(size) / extent) * extent; if (!Number.isFinite(target) || target <= lv.size) throw fail(size === "max" ? "NO_FREE_SPACE" : "SIZE_INVALID", size === "max" ? `Volume group ${lv.vg} has no free extents.` : "The new size must be larger than the current size."); if (target - lv.size > (vg?.free || 0)) throw fail("NO_FREE_SPACE", `Volume group ${lv.vg} does not have enough free space.`); if (node) verifyDevice(ctx, sim, node); step(ctx, "lv-extend", [`${lv.vg}/${lv.name}`, size === "max" ? "max" : target], { kind: "lvm" }); if (vg) vg.free -= target - lv.size; lv.size = target; if (node) growContent(sim, ctx, node, target); } // ---------- operation handlers ---------- const HANDLERS = { "create-table"(sim, ctx, op) { const disk = diskFor(sim, op.disk); if (!["gpt", "dos"].includes(op.label)) throw fail("ARGUMENTS_INVALID", "The partition table must be gpt or dos (MBR)."); if (disk.protected) throw fail("PROTECTED_DEVICE", `${disk.path} holds Proxmox host storage.`); const node = sim.nodes.get(disk.name); requireFree(sim, node); for (const part of disk.partitions) requireFree(sim, partNode(sim, disk, part)); needTool(sim, "sfdisk", "writing partition tables"); touchDisk(ctx, disk, { backup: true }); step(ctx, "create-table", [disk.path, op.label], { kind: "partition", destructive: true }); ctx.dataLoss = true; const lbs = disk.logicalSectorSize; const tail = Math.ceil(16384 / lbs) + 1; disk.partitions = []; disk.table = { type: op.label, id: null, firstUsable: disk.alignSectors, lastUsable: op.label === "gpt" ? disk.sectors - tail - 1 : Math.min(disk.sectors - 1, 0xffffffff), hash: null, source: "plan", gptBackupMisplaced: false, maxPrimary: op.label === "dos" ? 4 : 128 }; disk.table.lastUsableNow = disk.table.lastUsable; disk.wholeDevice = null; node.fstype = null; refreshSegments(disk); }, "create-partition"(sim, ctx, op) { const disk = diskFor(sim, op.disk); if (!disk.table || !["gpt", "dos"].includes(disk.table.type)) throw fail("NO_PARTITION_TABLE", `${disk.path} has no supported partition table; create one first.`); if (disk.protected) throw fail("PROTECTED_DEVICE", `${disk.path} holds Proxmox host storage.`); needTool(sim, "sfdisk", "creating partitions"); const fs = op.fs || "none"; if (fs !== "none" && !FILESYSTEMS.includes(fs)) throw fail("ARGUMENTS_INVALID", "Unsupported file system."); if (fs !== "none") needTool(sim, MKFS_TOOL[fs], `creating ${fs}`); const align = disk.alignSectors; const start = alignUp(Math.max(Number(op.start), disk.table.firstUsable), align); const segment = disk.segments.find((entry) => entry.kind === "free" && start >= entry.start && start <= entry.end); if (!segment) throw fail("NO_FREE_SPACE", "The requested start is not inside unallocated space."); let size = op.size === "max" ? segment.end - start + 1 : Number(op.size); if (!Number.isSafeInteger(size) || size < align) throw fail("SIZE_INVALID", "The partition must be at least 1 MiB."); if (start + size - 1 > segment.end) throw fail("NO_FREE_SPACE", "The partition does not fit into the unallocated space."); if (start + size - 1 !== segment.end) size = alignDown(size, align); if (fs !== "none" && size * disk.logicalSectorSize < MIN_FS_BYTES[fs]) throw fail("SIZE_TOO_SMALL", `${fs} needs at least ${Math.ceil(MIN_FS_BYTES[fs] / MiB)} MiB.`); if (disk.table.type === "dos" && start + size - 1 > 0xffffffff) throw fail("MBR_LIMIT", "MBR cannot address space beyond 2 TiB; use GPT."); const used = new Set(disk.partitions.map((part) => part.number)); const max = disk.table.type === "dos" ? 4 : 128; let number = 1; while (used.has(number) && number <= max) number += 1; if (number > max) throw fail("PARTITION_LIMIT", disk.table.type === "dos" ? "All four MBR primary partitions are used; CH-J does not create extended/logical partitions." : "The GPT has no free entry."); const flag = op.typeFlag === "esp" && fs === "vfat" ? "esp" : fs === "swap" ? "swap" : fs === "vfat" ? "fat32" : fs === "ntfs" ? "basic-data" : "linux"; const type = PARTITION_TYPE_CODES[flag][disk.table.type]; const name = op.name ? String(op.name) : "-"; if (name !== "-" && (disk.table.type !== "gpt" || !LABEL_PATTERN.test(name))) throw fail("ARGUMENTS_INVALID", "Partition names (GPT only) may contain A-Z, a-z, 0-9, dot, dash and underscore (max. 36)."); const label = op.label ? String(op.label) : "-"; if (label !== "-" && fs !== "none") validateLabel(fs, label); if (disk.table.gptBackupMisplaced && start + size - 1 > disk.table.lastUsableNow) relocate(ctx, disk); touchDisk(ctx, disk, { backup: true }); step(ctx, "create-part", [disk.path, number, start, size, type, name], { kind: "partition" }); const path = partPath(disk.name, number); const kname = path.slice(5); if (fs !== "none") step(ctx, "mkfs", [path, fs, label === "-" ? "-" : fs === "vfat" ? label.toUpperCase() : label], { kind: "filesystem", destructive: true }); const part = { number, start, size, end: start + size - 1, bytes: size * disk.logicalSectorSize, type, typeName: flag, name: name === "-" ? null : name, kernel: kname, path, fstype: fs === "none" ? null : fs, label: label === "-" ? null : label, mountpoints: [], usage: null, inUse: false, inUseReasons: [], systemRoles: [], children: [], created: true }; disk.partitions.push(part); addNode(sim, { kname, path, kind: "part", parent: disk.name, partition: number, size: part.bytes, fstype: part.fstype, label: part.label, mountpoints: [], children: [], parents: [disk.name], swapActive: false, synthetic: true }); refreshSegments(disk); ctx.steps.at(-1).created = path; }, "delete-partition"(sim, ctx, op) { const disk = diskFor(sim, op.disk); const part = partFor(disk, op.number); if (part.extended || part.logical) throw fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only."); assertNotBootPartition(disk, part); const node = partNode(sim, disk, part); requireFree(sim, node); needTool(sim, "sfdisk", "deleting partitions"); touchDisk(ctx, disk, { backup: true }); if (node) step(ctx, "require-free", [node.path], { kind: "check" }); step(ctx, "delete-part", [disk.path, part.number, part.start, part.size], { kind: "partition", destructive: true }); ctx.dataLoss = true; disk.partitions = disk.partitions.filter((entry) => entry !== part); if (node) { sim.nodes.delete(node.kname); sim.byPath.delete(node.path); } refreshSegments(disk); }, "resize-partition"(sim, ctx, op) { const disk = diskFor(sim, op.disk); const part = partFor(disk, op.number); if (!disk.table || !["gpt", "dos"].includes(disk.table.type)) throw fail("NO_PARTITION_TABLE", "Unsupported partition table."); if (part.extended || part.logical) throw fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only."); needTool(sim, "sfdisk", "resizing partitions"); if (!sim.inventory.tools.partx && !sim.inventory.tools.blockdev && !sim.inventory.tools.partprobe) throw fail("TOOL_MISSING", "partx, blockdev or partprobe is required to inform the kernel."); const node = partNode(sim, disk, part); const boundary = boundaryAfter(disk, part); const lbs = disk.logicalSectorSize; let size; if (op.size === "max") size = boundary - part.start + 1; else { size = Number(op.size); if (!Number.isSafeInteger(size) || size < disk.alignSectors) throw fail("SIZE_INVALID", "Invalid partition size."); if (part.start + size - 1 !== boundary) size = Math.max(disk.alignSectors, alignDown(part.start + size, disk.alignSectors) - part.start); } if (part.start + size - 1 > boundary) throw fail("NO_FREE_SPACE", "There is not enough unallocated space directly after the partition."); if (disk.table.type === "dos" && part.start + size - 1 > 0xffffffff) throw fail("MBR_LIMIT", "MBR cannot address space beyond 2 TiB."); if (size === part.size) throw fail(op.size === "max" ? "NO_FREE_SPACE" : "NO_CHANGE", op.size === "max" ? "There is no unallocated space directly after the partition." : "The size is unchanged."); const newBytes = size * lbs; if (size > part.size) { if (disk.table.gptBackupMisplaced && part.start + size - 1 > disk.table.lastUsableNow) relocate(ctx, disk); touchDisk(ctx, disk, { backup: true }); if (node && inUse(sim, node).length) ctx.mayReboot = true; step(ctx, "resize-part", [disk.path, part.number, part.start, part.size, size], { kind: "partition" }); part.size = size; part.end = part.start + size - 1; part.bytes = newBytes; if (node) node.size = newBytes; growContent(sim, ctx, node, newBytes, { autoLv: op.auto === true }); } else { if (disk.protected) throw fail("PROTECTED_DEVICE", `${disk.path} holds Proxmox host storage; shrinking is not offered.`); if (node) shrinkContent(sim, ctx, node, newBytes); touchDisk(ctx, disk, { backup: true }); step(ctx, "resize-part", [disk.path, part.number, part.start, part.size, size], { kind: "partition", destructive: true }); ctx.dataLoss = true; part.size = size; part.end = part.start + size - 1; part.bytes = newBytes; if (node) { node.size = newBytes; if (node.fstype === "swap") step(ctx, "grow-fs", [node.path, "swap"], { kind: "filesystem" }); if (node.fstype) node.fsSize = newBytes; if (isExt(node.fstype)) step(ctx, "verify-fs", [node.path, node.fstype], { kind: "verify" }); } } refreshSegments(disk); }, format(sim, ctx, op) { const node = nodeFor(sim, op.device); const fs = String(op.fs || ""); if (!FILESYSTEMS.includes(fs)) throw fail("ARGUMENTS_INVALID", "Unsupported file system."); needTool(sim, MKFS_TOOL[fs], `creating ${fs}`); if (["disk", "loop"].includes(node.kind) && sim.disks.get(node.kname)?.table) throw fail("HAS_PARTITION_TABLE", "This disk has a partition table; format a partition instead."); if (!["part", "lvm", "disk", "loop"].includes(node.kind)) throw fail("FORMAT_UNSUPPORTED", "Only partitions, logical volumes and unpartitioned disks can be formatted."); if (node.lvm?.thinPool) throw fail("LV_THIN_UNSUPPORTED", "Thin pools cannot be formatted."); assertNotProtected(sim, node, "format"); if (node.kind === "part") { const disk = sim.disks.get(node.parent); assertNotBootPartition(disk, disk?.partitions.find((entry) => entry.number === node.partition)); } requireFree(sim, node); if ((node.size || 0) < MIN_FS_BYTES[fs]) throw fail("SIZE_TOO_SMALL", `${fs} needs at least ${Math.ceil(MIN_FS_BYTES[fs] / MiB)} MiB.`); const label = op.label ? String(op.label) : "-"; if (label !== "-") validateLabel(fs, label); verifyDevice(ctx, sim, node); step(ctx, "mkfs", [node.path, fs, label === "-" ? "-" : fs === "vfat" ? label.toUpperCase() : label], { kind: "filesystem", destructive: true }); ctx.dataLoss = true; Object.assign(node, { fstype: fs, label: label === "-" ? null : label, usage: null, fsSize: node.size, uuid: null }); syncPart(sim, node); }, "set-label"(sim, ctx, op) { const node = nodeFor(sim, op.device); const fstype = node.fstype; const tool = { ext2: "e2label", ext3: "e2label", ext4: "e2label", xfs: "xfs_admin", btrfs: "btrfs", vfat: "fatlabel", ntfs: "ntfslabel", swap: "swaplabel" }[fstype]; if (!tool) throw fail("LABEL_UNSUPPORTED", `Labels of ${fstype || "this device"} cannot be changed.`); needTool(sim, tool, "changing labels"); const label = op.label ? String(op.label) : "-"; if (label !== "-") validateLabel(fstype, label); if (label === "-" && fstype === "vfat") throw fail("ARGUMENTS_INVALID", "Enter a FAT label."); if (["xfs", "vfat", "ntfs"].includes(fstype)) requireFree(sim, node, "LABEL_REQUIRES_UNMOUNT"); verifyDevice(ctx, sim, node); step(ctx, "set-label", [node.path, fstype, label === "-" ? "-" : fstype === "vfat" ? label.toUpperCase() : label], { kind: "filesystem" }); node.label = label === "-" ? null : label; syncPart(sim, node); }, "set-name"(sim, ctx, op) { const disk = diskFor(sim, op.disk); const part = partFor(disk, op.number); if (disk.table?.type !== "gpt") throw fail("NAME_UNSUPPORTED", "Partition names exist only in GPT."); const name = op.name ? String(op.name) : "-"; if (name !== "-" && !LABEL_PATTERN.test(name)) throw fail("ARGUMENTS_INVALID", "Partition names may contain A-Z, a-z, 0-9, dot, dash and underscore (max. 36)."); needTool(sim, "sfdisk", "naming partitions"); touchDisk(ctx, disk, { backup: true }); step(ctx, "set-name", [disk.path, part.number, name], { kind: "partition" }); part.name = name === "-" ? null : name; }, "set-flag"(sim, ctx, op) { const disk = diskFor(sim, op.disk); const part = partFor(disk, op.number); const type = disk.table?.type; if (!type || !FLAGS[type]?.includes(op.flag)) throw fail("FLAG_UNSUPPORTED", `The flag ${op.flag} is not supported for this partition table.`); if (part.extended || part.logical) throw fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only."); const node = partNode(sim, disk, part); if ((node?.systemRoles || part.systemRoles || []).length && op.flag !== "boot" && op.flag !== "legacy_boot") throw fail("SYSTEM_PARTITION", "The partition is used by the running system; its type is not changed by CH-J."); needTool(sim, "sfdisk", "changing partition flags"); touchDisk(ctx, disk, { backup: true }); const enabled = op.enabled === true; if (op.flag === "boot") { const list = new Set(disk.partitions.filter((entry) => entry.bootable).map((entry) => entry.number)); if (enabled) list.add(part.number); else list.delete(part.number); step(ctx, "set-boot", [disk.path, list.size ? [...list].sort((a, b) => a - b).join(",") : "-"], { kind: "partition" }); part.bootable = enabled; } else if (op.flag === "legacy_boot") { const attrs = new Set(String(part.attrs || "").split(/[\s,]+/).filter(Boolean)); if (enabled) attrs.add("LegacyBIOSBootable"); else attrs.delete("LegacyBIOSBootable"); const value = [...attrs].join(",") || "-"; if (!/^[A-Za-z0-9,:-]{1,120}$/.test(value)) throw fail("FLAG_UNSUPPORTED", "The existing GPT attributes cannot be represented safely."); step(ctx, "set-attrs", [disk.path, part.number, value], { kind: "partition" }); part.attrs = value === "-" ? null : value; } else { const target = enabled ? op.flag : "linux"; if (!enabled && part.typeName !== op.flag) throw fail("NO_CHANGE", "The partition does not have this type."); const code = PARTITION_TYPE_CODES[target]?.[type]; if (!code) throw fail("FLAG_UNSUPPORTED", "This type is not available for the partition table."); step(ctx, "set-type", [disk.path, part.number, code], { kind: "partition" }); part.type = code; part.typeName = target; } }, check(sim, ctx, op) { const node = nodeFor(sim, op.device); const fstype = node.fstype; const repair = op.repair === true; const supported = { ext2: "e2fsck", ext3: "e2fsck", ext4: "e2fsck", xfs: "xfs_repair", btrfs: repair ? null : "btrfs", vfat: "fsck.vfat" }[fstype]; if (!supported) throw fail("CHECK_UNSUPPORTED", repair && fstype === "btrfs" ? "btrfs check --repair is not offered: it can make damage worse. Use btrfs scrub or expert recovery." : `Checking ${fstype || "this device"} is not supported.`); needTool(sim, supported, "file system checks"); requireFree(sim, node, "CHECK_REQUIRES_UNMOUNT"); verifyDevice(ctx, sim, node); step(ctx, "fsck", [node.path, fstype, repair ? "repair" : "check"], { kind: "check", destructive: repair }); }, "grow-fs"(sim, ctx, op) { const node = nodeFor(sim, op.device); if (!["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap"].includes(node.fstype)) throw fail("RESIZE_UNSUPPORTED", "This content cannot be grown."); if (node.fsSize && node.size && node.size - node.fsSize < MiB) throw fail("NO_CHANGE", "The file system already fills the device."); verifyDevice(ctx, sim, node); growContent(sim, ctx, node, node.size); }, mount(sim, ctx, op) { const node = nodeFor(sim, op.device); if (!MOUNTABLE.has(node.fstype)) throw fail("MOUNT_UNSUPPORTED", "This device does not contain a mountable file system."); if (node.mountpoints?.length) throw fail("ALREADY_MOUNTED", `${node.path} is already mounted.`); const mountpoint = String(op.mountpoint || ""); if (!MOUNTPOINT_PATTERN.test(mountpoint) || mountpoint.includes("..")) throw fail("MOUNTPOINT_INVALID", "Mount points must be below /mnt or /media (letters, digits, dot, dash, underscore)."); for (const other of sim.nodes.values()) if (other.mountpoints?.includes(mountpoint)) throw fail("MOUNTPOINT_INVALID", `${mountpoint} is already in use.`); step(ctx, "mount", [node.path, mountpoint], { kind: "mount" }); node.mountpoints = [mountpoint]; syncPart(sim, node); }, unmount(sim, ctx, op) { const node = nodeFor(sim, op.device); if (!node.mountpoints?.length) throw fail("NOT_MOUNTED", `${node.path} is not mounted.`); const system = node.mountpoints.find((point) => SYSTEM_MOUNTS.test(point)); if (system) throw fail("SYSTEM_MOUNT", `${system} is needed by the running system; CH-J never unmounts it.`); step(ctx, "unmount", [node.path], { kind: "mount" }); node.mountpoints = []; node.usage = node.usage && { ...node.usage, source: "superblock" }; syncPart(sim, node); }, "lv-extend"(sim, ctx, op) { const lv = lvFor(sim, op.lv); extendLv(sim, ctx, lv, op.size === "max" ? "max" : Number(op.size)); }, "lv-reduce"(sim, ctx, op) { if (op.advanced !== true) throw fail("ADVANCED_CONFIRMATION_REQUIRED", "Shrinking a logical volume is an advanced operation and must be confirmed explicitly."); const lv = lvFor(sim, op.lv); needTool(sim, "lvreduce", "reducing logical volumes"); if (lv.thinPool || lv.thinVolume || lv.snapshot || lv.pveGuest) throw fail("LV_THIN_UNSUPPORTED", "Thin, snapshot and Proxmox guest volumes are not reduced by CH-J."); const node = sim.byPath.get(lv.dmPath) || sim.byPath.get(lv.path); if (!node) throw fail("DEVICE_NOT_FOUND", "The logical volume is not active."); if (!isExt(node.fstype)) throw fail(node.fstype === "xfs" ? "XFS_SHRINK_UNSUPPORTED" : "LV_SHRINK_UNSUPPORTED", node.fstype === "xfs" ? "XFS cannot be shrunk; migrate the data to a new, smaller volume instead." : "Only logical volumes with an unmounted ext file system can be reduced."); const vg = sim.lvm.vgs.find((entry) => entry.name === lv.vg); const extent = vg?.extentSize || 4 * MiB; const target = Math.floor(Number(op.size) / extent) * extent; if (!Number.isSafeInteger(target) || target <= 0 || target >= lv.size) throw fail("SIZE_INVALID", "The new size must be smaller than the current size."); verifyDevice(ctx, sim, node); shrinkContent(sim, ctx, node, target); step(ctx, "lv-reduce", [`${lv.vg}/${lv.name}`, target], { kind: "lvm", destructive: true }); step(ctx, "verify-fs", [node.path, node.fstype], { kind: "verify" }); ctx.dataLoss = true; if (vg) vg.free += lv.size - target; lv.size = target; node.size = target; node.fsSize = target; }, "pv-resize"(sim, ctx, op) { const node = nodeFor(sim, op.device); if (node.fstype !== "LVM2_member") throw fail("NOT_A_PV", `${node.path} is not an LVM physical volume.`); const pv = sim.lvm.pvs.find((entry) => entry.path === node.path || entry.path === `/dev/${node.kname}`); if (pv && pv.deviceSize - pv.size < MiB) throw fail("NO_CHANGE", "The physical volume already uses the whole device."); verifyDevice(ctx, sim, node); growContent(sim, ctx, node, node.size, { autoLv: op.auto === true }); }, "move-partition"() { throw fail("OPERATION_DISABLED", "Moving partitions is prepared in the architecture but disabled until a reliable, tested mechanism exists."); }, "copy-partition"() { throw fail("OPERATION_DISABLED", "Copying partitions is prepared in the architecture but disabled until a reliable, tested mechanism exists."); } }; function validateLabel(fstype, label) { const limit = LABEL_LIMITS[fstype] || 16; if (!LABEL_PATTERN.test(label) || label.length > limit) throw fail("LABEL_INVALID", `Labels of ${fstype} may have at most ${limit} characters: A-Z, a-z, 0-9, dot, dash and underscore.`); } function relocate(ctx, disk) { if (ctx.relocated.has(disk.name)) return; ctx.relocated.add(disk.name); touchDisk(ctx, disk, { backup: true }); step(ctx, "gpt-relocate", [disk.path], { kind: "partition" }); disk.table.lastUsableNow = disk.table.lastUsable; disk.table.gptBackupMisplaced = false; } function syncPart(sim, node) { if (node.kind !== "part") return; const disk = sim.disks.get(node.parent); const part = disk?.partitions.find((entry) => entry.number === node.partition || entry.kernel === node.kname); if (part) Object.assign(part, { fstype: node.fstype, label: node.label, mountpoints: node.mountpoints, usage: node.usage }); } function normalizeOperation(input) { if (!input || typeof input !== "object") throw fail("ARGUMENTS_INVALID", "Invalid operation."); const type = String(input.type || ""); if (!OPERATION_TYPES.has(type)) throw fail("ARGUMENTS_INVALID", `Unknown operation ${type}.`); const op = { type }; for (const key of ["disk", "device", "label", "name", "fs", "flag", "mountpoint", "lv", "typeFlag"]) if (input[key] !== undefined && input[key] !== null && input[key] !== "") op[key] = String(input[key]).slice(0, 200); for (const key of ["number", "start"]) if (input[key] !== undefined) op[key] = Number(input[key]); if (input.size !== undefined) op.size = input.size === "max" ? "max" : Number(input.size); for (const key of ["enabled", "repair", "auto", "advanced"]) if (input[key] !== undefined) op[key] = input[key] === true; return op; } function diskView(disk, sim) { return { name: disk.name, path: disk.path, size: disk.size, sectors: disk.sectors, logicalSectorSize: disk.logicalSectorSize, table: disk.table ? { type: disk.table.type } : null, segments: (disk.segments || []).map((segment) => { if (segment.kind !== "partition") return { ...segment }; const part = disk.partitions.find((entry) => entry.number === segment.number); const node = part && partNode(sim, disk, part); return { ...segment, fstype: node?.fstype ?? part?.fstype ?? null, label: node?.label ?? part?.label ?? null, path: part?.path || null, mountpoints: node?.mountpoints || [], created: part?.created === true }; }) }; } function planOperations(inventory, operations, options = {}) { if (!inventory?.supported) throw fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers."); if (!inventory.privileged) throw fail("PRIVILEGE_REQUIRED", "Planning changes needs the privileged (root/sudo) inventory."); if (!Array.isArray(operations) || !operations.length || operations.length > MAX_OPERATIONS) throw fail("ARGUMENTS_INVALID", `Plan 1 to ${MAX_OPERATIONS} operations.`); const sim = createSim(inventory); const before = new Map([...sim.disks].map(([name, disk]) => [name, diskView(disk, sim)])); const ctx = createContext(sim); const normalized = operations.map((operation) => normalizeOperation(operation)); normalized.forEach((op, index) => { ctx.currentOperation = index; try { HANDLERS[op.type](sim, ctx, op); } catch (error) { error.details = { ...(error.details || {}), operation: index }; throw error; } }); if (!ctx.steps.length) throw fail("NO_CHANGE", "The plan has no steps."); // Guard steps run first: the job refuses to touch anything if the layout differs from this plan. const guards = []; for (const name of ctx.verifyDisks) { const disk = inventory.disks.find((entry) => entry.name === name); guards.push({ name: "verify-disk", args: [disk.path, String(disk.sectors), disk.table?.hash || "none"], operation: null, kind: "verify", destructive: false }); } for (const [path, size] of ctx.verifySizes) guards.push({ name: "verify-size", args: [path, String(size)], operation: null, kind: "verify", destructive: false }); for (const name of ctx.backupDisks) guards.push({ name: "backup-table", args: [inventory.disks.find((entry) => entry.name === name).path], operation: null, kind: "backup", destructive: false }); // Final verification of every partition the plan created or resized. const finals = []; for (const entry of ctx.steps) { if (entry.name === "resize-part") finals.push({ name: "verify-part", args: [entry.args[0], entry.args[1], entry.args[2], entry.args[4]], operation: entry.operation, kind: "verify", destructive: false }); if (entry.name === "create-part") finals.push({ name: "verify-part", args: [entry.args[0], entry.args[1], entry.args[2], entry.args[3]], operation: entry.operation, kind: "verify", destructive: false }); } const steps = [...guards, ...ctx.steps, ...finals]; const touched = new Set([...ctx.verifyDisks]); return { operations: normalized, steps, planText: steps.map((entry) => [entry.name, ...entry.args].join(" ")).join("\n"), before: [...touched].map((name) => before.get(name)).filter(Boolean), after: [...touched].map((name) => diskView(sim.disks.get(name), sim)), dataLoss: ctx.dataLoss, destructive: steps.some((entry) => entry.destructive), offline: [...ctx.offline], mayRequireReboot: ctx.mayReboot, backupDisks: [...ctx.backupDisks].map((name) => inventory.disks.find((entry) => entry.name === name).path), notes: ctx.notes, title: String(options.title || normalized.map((op) => op.type).join(", ")).slice(0, 200) }; } // ---------- automatic expansion ---------- function suggestions(inventory) { if (!inventory?.supported) return []; const result = []; const seen = new Set(); const attempt = (id, kind, target, freeBytes, operations) => { if (seen.has(id)) return; seen.add(id); const entry = { id, kind, target, freeBytes, operations, available: false, blocked: null, steps: [] }; if (!inventory.privileged) entry.blocked = { code: "DISKS_PRIVILEGE_REQUIRED", message: "Load the privileged inventory (sudo) to evaluate this expansion." }; else { try { const plan = planOperations(inventory, operations, { title: `auto-expand ${target}` }); Object.assign(entry, { available: true, steps: plan.steps.map((item) => item.name), mayRequireReboot: plan.mayRequireReboot, notes: plan.notes }); } catch (error) { entry.blocked = { code: error.code || "DISKS_FAILED", message: error.message }; } } result.push(entry); }; for (const disk of inventory.disks) { if (!disk.table || !["gpt", "dos"].includes(disk.table.type)) continue; for (const part of disk.partitions) { if (part.extended || part.logical) continue; const next = disk.segments[disk.segments.findIndex((segment) => segment.kind === "partition" && segment.number === part.number) + 1]; if (!next || next.kind !== "free" || next.bytes < AUTO_MIN_BYTES) continue; if (part.fstype && ["linux_raid_member", "zfs_member", "vfat"].includes(part.fstype)) continue; attempt(`partition:${disk.name}:${part.number}`, "partition", part.path || partPath(disk.name, part.number), next.bytes, [{ type: "resize-partition", disk: disk.name, number: part.number, size: "max", auto: true }]); } } for (const node of inventory.nodes) { if (!node.fsSize || !node.size || !["ext2", "ext3", "ext4", "xfs", "btrfs"].includes(node.fstype)) continue; if (node.size - node.fsSize < AUTO_MIN_BYTES) continue; attempt(`filesystem:${node.kname}`, "filesystem", node.displayPath || node.path, node.size - node.fsSize, [{ type: "grow-fs", device: node.path }]); } for (const pv of inventory.lvm?.pvs || []) { if (!pv.deviceSize || pv.deviceSize - pv.size < AUTO_MIN_BYTES) continue; attempt(`pv:${pv.path}`, "pv", pv.path, pv.deviceSize - pv.size, [{ type: "pv-resize", device: pv.path, auto: true }]); } for (const vg of inventory.lvm?.vgs || []) { if (!vg.free || vg.free < AUTO_MIN_BYTES) continue; const sim = createSim(inventory); const lv = autoLv(sim, sim.lvm.vgs.find((entry) => entry.name === vg.name)); if (lv) attempt(`lv:${lv.vg}/${lv.name}`, "lv", `${lv.vg}/${lv.name}`, vg.free, [{ type: "lv-extend", lv: `${lv.vg}/${lv.name}`, size: "max" }]); } return result; } module.exports = { FILESYSTEMS, LABEL_LIMITS, MAX_OPERATIONS, MOUNTPOINT_PATTERN, OPERATION_TYPES, partPath, planOperations, suggestions };