//! Turns a queue of user operations (GParted-style) into a validated, simulated plan of //! whitelisted remote steps (port of `disks/planner.js`). Each operation is checked against the //! layout produced by the operations before it. 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. //! //! The simulation works on the same JSON model as 0.1.0 and mutates it the same way, so the //! generated plan text (which the remote helper executes) is identical. use super::inventory::{build_segments, partition_type_code, MIB, SYSTEM_MOUNTS}; use super::js::{self, num, num_string, number, truthy}; use crate::core::error::CoreError; use indexmap::IndexMap; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; use std::collections::HashMap; pub const MAX_OPERATIONS: usize = 32; const AUTO_MIN_BYTES: f64 = 16.0 * MIB; const FILESYSTEMS: [&str; 6] = ["ext4", "xfs", "btrfs", "vfat", "ntfs", "swap"]; const OPERATION_TYPES: [&str; 17] = [ "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 MOUNTABLE: [&str; 8] = ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs", "exfat"]; static LABEL_PATTERN: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._-]{1,36}$").unwrap()); pub static MOUNTPOINT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"^/(mnt|media)/[A-Za-z0-9._-]{1,64}(/[A-Za-z0-9._-]{1,64}){0,3}$").unwrap()); static STEP_ARG: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._/:+,=@-]{1,160}$").unwrap()); static EXT: Lazy = Lazy::new(|| Regex::new(r"^ext[234]$").unwrap()); static LV_NAME: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._+-]{1,64}/[A-Za-z0-9._+-]{1,64}$").unwrap()); static ATTRS: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9,:-]{1,120}$").unwrap()); static ATTR_SPLIT: Lazy = Lazy::new(|| Regex::new(r"[\s,]+").unwrap()); static KEYRING: Lazy = Lazy::new(|| Regex::new(r"(?i)keyring").unwrap()); fn mkfs_tool(fs: &str) -> &'static str { match fs { "ext4" => "mkfs.ext4", "xfs" => "mkfs.xfs", "btrfs" => "mkfs.btrfs", "vfat" => "mkfs.vfat", "ntfs" => "mkfs.ntfs", _ => "mkswap", } } fn min_fs_bytes(fs: &str) -> f64 { match fs { "ext4" => 16.0 * MIB, "xfs" => 300.0 * MIB, "btrfs" => 256.0 * MIB, "vfat" => 33.0 * MIB, "ntfs" => 16.0 * MIB, _ => MIB, } } fn label_limit(fs: &str) -> usize { match fs { "ext2" | "ext3" | "ext4" => 16, "xfs" => 12, "btrfs" => 36, "vfat" => 11, "ntfs" => 32, "swap" => 15, _ => 16, } } fn flags(table: &str) -> &'static [&'static str] { match table { "gpt" => &["legacy_boot", "esp", "bios-boot", "lvm", "raid", "swap", "basic-data"], "dos" => &["boot", "esp", "lvm", "raid", "swap"], _ => &[], } } fn fail(code: &str, message: impl Into) -> CoreError { CoreError::new(&format!("DISKS_{code}"), message) } fn is_ext(fstype: &Value) -> bool { fstype.as_str().is_some_and(|f| EXT.is_match(f)) } pub fn part_path(disk_name: &str, number_value: &Value) -> String { let p = if disk_name.ends_with(|c: char| c.is_ascii_digit()) { "p" } else { "" }; format!("/dev/{disk_name}{p}{}", js::arg(number_value)) } /// `Math.max`/`Math.min` propagate NaN. fn jmax(a: f64, b: f64) -> f64 { if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) } } fn jmin(a: f64, b: f64) -> f64 { if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) } } fn align_up(value: f64, align: f64) -> f64 { (value / align).ceil() * align } fn align_down(value: f64, align: f64) -> f64 { (value / align).floor() * align } fn safe_integer(value: f64) -> bool { value.is_finite() && value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 } fn ceil_mib(value: f64) -> String { num_string((value / MIB).ceil()) } // ---------- operations ---------- #[derive(Debug, Clone)] pub enum Size { Max, Bytes(f64), } #[derive(Debug, Clone, Default)] pub struct Op { pub kind: String, pub strings: HashMap<&'static str, String>, pub number: Option, pub start: Option, pub size: Option, pub enabled: Option, pub repair: Option, pub auto: Option, pub advanced: Option, } const STRING_KEYS: [&str; 9] = ["disk", "device", "label", "name", "fs", "flag", "mountpoint", "lv", "typeFlag"]; impl Op { fn s(&self, key: &str) -> Option<&str> { self.strings.get(key).map(String::as_str) } fn is_max(&self) -> bool { matches!(self.size, Some(Size::Max)) } fn size_number(&self) -> f64 { match self.size { Some(Size::Bytes(n)) => n, Some(Size::Max) => f64::NAN, None => f64::NAN, } } pub fn to_value(&self) -> Value { let mut map = Map::new(); map.insert("type".into(), json!(self.kind)); for key in STRING_KEYS { if let Some(v) = self.strings.get(key) { map.insert(key.into(), json!(v)); } } if let Some(n) = self.number { map.insert("number".into(), num(n)); } if let Some(n) = self.start { map.insert("start".into(), num(n)); } match &self.size { Some(Size::Max) => { map.insert("size".into(), json!("max")); } Some(Size::Bytes(n)) => { map.insert("size".into(), num(*n)); } None => {} } for (key, value) in [("enabled", self.enabled), ("repair", self.repair), ("auto", self.auto), ("advanced", self.advanced)] { if let Some(v) = value { map.insert(key.into(), json!(v)); } } Value::Object(map) } } pub fn normalize_operation(input: &Value) -> Result { if !(input.is_object() || input.is_array()) { return Err(fail("ARGUMENTS_INVALID", "Invalid operation.")); } let kind = js::string(input.get("type")); if !OPERATION_TYPES.contains(&kind.as_str()) { return Err(fail("ARGUMENTS_INVALID", format!("Unknown operation {kind}."))); } let mut op = Op { kind, ..Default::default() }; for key in STRING_KEYS { match input.get(key) { None | Some(Value::Null) => {} Some(Value::String(s)) if s.is_empty() => {} Some(v) => { op.strings.insert(key, js::cut(&js::string(Some(v)), 200)); } } } op.number = input.get("number").map(number); op.start = input.get("start").map(number); op.size = input.get("size").map(|v| if v == "max" { Size::Max } else { Size::Bytes(number(v)) }); let flag = |key: &str| input.get(key).map(|v| *v == true); op.enabled = flag("enabled"); op.repair = flag("repair"); op.auto = flag("auto"); op.advanced = flag("advanced"); Ok(op) } // ---------- simulation state ---------- struct Sim<'a> { inventory: &'a Value, disks: IndexMap, arena: Vec, nodes: IndexMap, by_path: HashMap, lvm: Value, } impl<'a> Sim<'a> { fn new(inventory: &'a Value) -> Self { let mut sim = Sim { inventory, disks: IndexMap::new(), arena: Vec::new(), nodes: IndexMap::new(), by_path: HashMap::new(), lvm: Value::Null }; sim.lvm = if truthy(&inventory["lvm"]) { inventory["lvm"].clone() } else { json!({ "pvs": [], "vgs": [], "lvs": [] }) }; for disk in inventory["disks"].as_array().cloned().unwrap_or_default() { sim.disks.insert(js::string(disk.get("name")), disk); } for node in inventory["nodes"].as_array().cloned().unwrap_or_default() { sim.add_node(node); } sim } fn add_node(&mut self, node: Value) -> usize { let index = self.arena.len(); let kname = js::string(node.get("kname")); let mut paths = vec![node["path"].clone(), json!(format!("/dev/{kname}")), node.get("displayPath").cloned().unwrap_or(Value::Null)]; paths.retain(truthy); self.arena.push(node); self.nodes.insert(kname, index); for path in paths { self.by_path.insert(js::string(Some(&path)), index); } index } fn node(&self, kname: &str) -> Option { self.nodes.get(kname).copied() } fn node_for(&self, device: Option<&str>) -> Result { let device = device.unwrap_or(""); self.by_path.get(device).copied().ok_or_else(|| fail("DEVICE_NOT_FOUND", format!("{device} was not found in the current inventory."))) } fn disk_for(&self, name: Option<&str>) -> Result { let raw = name.unwrap_or(""); let key = raw.strip_prefix("/dev/").unwrap_or(raw); if self.disks.contains_key(key) { Ok(key.to_string()) } else { Err(fail("DEVICE_NOT_FOUND", format!("Disk {} was not found in the current inventory.", name.unwrap_or("undefined")))) } } fn disk(&self, name: &str) -> &Value { &self.disks[name] } fn disk_mut(&mut self, name: &str) -> &mut Value { self.disks.get_mut(name).unwrap() } fn part_index(&self, disk: &str, number_value: Option) -> Result { let n = number_value.unwrap_or(f64::NAN); let d = self.disk(disk); d["partitions"].as_array().and_then(|parts| parts.iter().position(|p| number(&p["number"]) == n && !p["number"].is_null())).ok_or_else(|| { fail("DEVICE_NOT_FOUND", format!("Partition {} does not exist on {}.", number_value.map(num_string).unwrap_or_else(|| "undefined".into()), js::string(d.get("path")))) }) } fn part(&self, disk: &str, index: usize) -> &Value { &self.disks[disk]["partitions"][index] } fn part_mut(&mut self, disk: &str, index: usize) -> &mut Value { &mut self.disks.get_mut(disk).unwrap()["partitions"][index] } fn part_node(&self, disk: &str, part: &Value) -> Option { if let Some(kernel) = part["kernel"].as_str().filter(|k| !k.is_empty()) { if let Some(i) = self.node(kernel) { return Some(i); } } self.by_path.get(&part_path(disk, &part["number"])).copied() } fn display(&self, index: usize) -> String { let node = &self.arena[index]; js::string(node.get("displayPath").filter(|v| truthy(v)).or(node.get("path"))) } fn in_use(&self, index: Option) -> Vec { let Some(index) = index else { return Vec::new() }; let node = &self.arena[index]; let mut reasons = Vec::new(); let points: Vec = node["mountpoints"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default(); if !points.is_empty() { reasons.push(format!("mounted at {}", points.join(", "))); } if truthy(&node["swapActive"]) { reasons.push("active swap".into()); } let children: Vec = node["children"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default(); if !children.is_empty() { let names: Vec = children .iter() .map(|name| match self.node(name) { Some(i) => { let n = &self.arena[i]; if truthy(&n["displayPath"]) { js::string(Some(&n["displayPath"])) } else if truthy(&n["path"]) { js::string(Some(&n["path"])) } else { name.clone() } } None => name.clone(), }) .collect(); reasons.push(format!("used by {}", names.join(", "))); } if node["fstype"] == "zfs_member" { reasons.push("ZFS pool member".into()); } if node["fstype"] == "linux_raid_member" { reasons.push("RAID member".into()); } reasons } fn require_free(&self, index: Option, code: &str) -> Result<(), CoreError> { let reasons = self.in_use(index); if let (false, Some(i)) = (reasons.is_empty(), index) { return Err(fail(code, format!("{} is in use ({}). CH-J never unmounts, deactivates or closes it automatically.", self.display(i), reasons.join("; ")))); } Ok(()) } fn refresh_segments(&mut self, disk: &str) { let d = self.disk_mut(disk); if !truthy(&d["table"]) { d["segments"] = json!([]); return; } let mut parts = d["partitions"].as_array().cloned().unwrap_or_default(); parts.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal)); let segments = build_segments(&parts, number(&d["table"]["firstUsable"]), number(&d["table"]["lastUsable"]), number(&d["alignSectors"]), number(&d["logicalSectorSize"]), number(&d["table"]["lastUsableNow"])); d["partitions"] = Value::Array(parts); d["segments"] = Value::Array(segments); } /// The free space directly after a partition (up to the next partition or the end of the usable area). fn boundary_after(&self, disk: &str, part: &Value) -> f64 { let d = self.disk(disk); let start = number(&part["start"]); let mut next: Vec<&Value> = d["partitions"].as_array().unwrap().iter().filter(|e| e["logical"] != true && number(&e["start"]) > start).collect(); next.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal)); match next.first() { Some(n) => number(&n["start"]) - 1.0, None => number(&d["table"]["lastUsable"]), } } fn need_tool(&self, tool: &str, purpose: &str) -> Result<(), CoreError> { if !truthy(&self.inventory["tools"][tool]) { return Err(fail("TOOL_MISSING", format!("{tool} is required for {purpose} but is not installed on the server. CH-J does not install packages.")).with("tool", tool)); } Ok(()) } fn disk_of_node(&self, index: usize) -> Option { let mut current = Some(index); for _ in 0..16 { let i = current?; let node = &self.arena[i]; if node["kind"] == "part" { let parent = js::string(node.get("parent")); return self.disks.contains_key(&parent).then_some(parent); } if node["kind"] == "disk" || node["kind"] == "loop" { let kname = js::string(node.get("kname")); return self.disks.contains_key(&kname).then_some(kname); } current = node["parents"].get(0).and_then(|p| p.as_str()).and_then(|p| self.node(p)); } None } fn pv_index(&self, index: usize) -> Option { let node = &self.arena[index]; let path = js::string(node.get("path")); let alt = format!("/dev/{}", js::string(node.get("kname"))); self.lvm["pvs"].as_array().and_then(|pvs| pvs.iter().position(|pv| pv["path"] == path.as_str() || pv["path"] == alt.as_str())) } fn vg_index(&self, name: &Value) -> Option { self.lvm["vgs"].as_array().and_then(|vgs| vgs.iter().position(|vg| vg["name"] == *name)) } fn lv_node(&self, lv: &Value) -> Option { self.by_path.get(&js::string(lv.get("dmPath"))).or_else(|| self.by_path.get(&js::string(lv.get("path")))).copied() } fn sync_part(&mut self, index: usize) { let node = self.arena[index].clone(); if node["kind"] != "part" { return; } let parent = js::string(node.get("parent")); let Some(disk) = self.disks.get_mut(&parent) else { return }; let Some(parts) = disk["partitions"].as_array_mut() else { return }; if let Some(part) = parts.iter_mut().find(|e| (e["number"] == node["partition"]) || (!e["kernel"].is_null() && e["kernel"] == node["kname"])) { for key in ["fstype", "label", "mountpoints", "usage"] { part[key] = node.get(key).cloned().unwrap_or(Value::Null); } } } } // ---------- plan context ---------- #[derive(Default)] struct Ctx { steps: Vec, notes: Vec, verify_disks: Vec, backup_disks: Vec, verify_sizes: Vec<(String, Value)>, relocated: Vec, data_loss: bool, offline: Vec, may_reboot: bool, current_operation: usize, } fn push_unique(list: &mut Vec, value: String) { if !list.contains(&value) { list.push(value); } } fn step(ctx: &mut Ctx, name: &str, args: Vec, kind: &str, destructive: bool) -> Result<(), CoreError> { let values: Vec = args.iter().map(js::arg).collect(); if values.iter().any(|v| !STEP_ARG.is_match(v)) { return Err(fail("ARGUMENTS_INVALID", format!("Invalid step argument for {name}."))); } ctx.steps.push(json!({ "name": name, "args": values, "operation": ctx.current_operation, "kind": kind, "destructive": destructive })); Ok(()) } fn touch_disk(sim: &Sim, ctx: &mut Ctx, disk: &str, backup: bool) -> Result<(), CoreError> { let d = sim.disk(disk); if truthy(&d["protected"]) && backup { return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage; CH-J does not change its partition table.", js::string(d.get("path"))))); } push_unique(&mut ctx.verify_disks, disk.to_string()); if backup { push_unique(&mut ctx.backup_disks, disk.to_string()); } Ok(()) } fn verify_device(sim: &Sim, ctx: &mut Ctx, index: usize) -> Result<(), CoreError> { let node = &sim.arena[index]; if node["kind"] == "part" { let parent = js::string(node.get("parent")); if sim.disks.contains_key(&parent) { touch_disk(sim, ctx, &parent, false)?; } return Ok(()); } let kname = &node["kname"]; if let Some(original) = sim.inventory["nodes"].as_array().and_then(|n| n.iter().find(|e| e["kname"] == *kname)) { let path = js::string(original.get("path")); if !ctx.verify_sizes.iter().any(|(p, _)| *p == path) { ctx.verify_sizes.push((path, original["size"].clone())); } } Ok(()) } /// Boot partitions of the disk that runs the system make the server unbootable when deleted or formatted. fn assert_not_boot_partition(disk: Option<&Value>, part: Option<&Value>) -> Result<(), CoreError> { let (Some(disk), Some(part)) = (disk, part) else { return Ok(()) }; let type_name = part["typeName"].as_str().unwrap_or(""); if truthy(&disk["system"]) && (type_name == "bios-boot" || type_name == "esp") { let label = if truthy(&part["path"]) { js::string(Some(&part["path"])) } else { format!("Partition {}", js::arg(&part["number"])) }; return Err(fail("SYSTEM_PARTITION", format!("{label} is the {} partition of the disk that runs the system; CH-J does not delete or format it.", if type_name == "esp" { "EFI system" } else { "BIOS boot" }))); } Ok(()) } fn assert_not_protected(sim: &Sim, index: usize, action: &str) -> Result<(), CoreError> { let disk = sim.disk_of_node(index); let node = &sim.arena[index]; if disk.is_some_and(|d| truthy(&sim.disk(&d)["protected"])) || truthy(&node["lvm"]["pveGuest"]) { return Err(fail("PROTECTED_DEVICE", format!("{} belongs to Proxmox host or guest storage; CH-J does not {action} it.", sim.display(index)))); } Ok(()) } fn validate_label(fstype: &str, label: &str) -> Result<(), CoreError> { let limit = label_limit(fstype); if !LABEL_PATTERN.is_match(label) || label.encode_utf16().count() > limit { return Err(fail("LABEL_INVALID", format!("Labels of {fstype} may have at most {limit} characters: A-Z, a-z, 0-9, dot, dash and underscore."))); } Ok(()) } fn relocate(sim: &mut Sim, ctx: &mut Ctx, disk: &str) -> Result<(), CoreError> { if ctx.relocated.iter().any(|d| d == disk) { return Ok(()); } ctx.relocated.push(disk.to_string()); touch_disk(sim, ctx, disk, true)?; let path = sim.disk(disk)["path"].clone(); step(ctx, "gpt-relocate", vec![path], "partition", false)?; let d = sim.disk_mut(disk); d["table"]["lastUsableNow"] = d["table"]["lastUsable"].clone(); d["table"]["gptBackupMisplaced"] = json!(false); Ok(()) } // ---------- content chains (grow outward from the partition, shrink inward) ---------- fn grow_content(sim: &mut Sim, ctx: &mut Ctx, index: Option, new_bytes: f64, auto_lv: bool) -> Result<(), CoreError> { let Some(index) = index else { return Ok(()) }; let node = sim.arena[index].clone(); let fstype = node["fstype"].clone(); let path = node["path"].clone(); let path_s = js::string(Some(&path)); let mounted = node["mountpoints"].as_array().is_some_and(|a| !a.is_empty()); if !truthy(&fstype) { sim.arena[index]["size"] = num(new_bytes); return Ok(()); } let fs = fstype.as_str().unwrap_or(""); if is_ext(&fstype) { sim.need_tool("resize2fs", "growing ext file systems")?; step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?; } else if fs == "xfs" { sim.need_tool("xfs_growfs", "growing XFS")?; if !mounted { return Err(fail("XFS_GROW_NEEDS_MOUNT", format!("XFS can only be grown while mounted; mount {path_s} first (add a mount operation before this one)."))); } step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?; } else if fs == "btrfs" { sim.need_tool("btrfs", "growing Btrfs")?; if !mounted { return Err(fail("BTRFS_NEEDS_MOUNT", format!("Btrfs is resized while mounted; mount {path_s} first."))); } step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?; } else if fs == "ntfs" { sim.need_tool("ntfsresize", "growing NTFS")?; sim.require_free(Some(index), "NTFS_MOUNTED")?; step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?; } else if fs == "swap" { sim.need_tool("mkswap", "resizing swap")?; if truthy(&node["swapActive"]) { return Err(fail("SWAP_ACTIVE", format!("{path_s} is active swap; CH-J never disables swap automatically. Deactivate it yourself first."))); } step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?; } else if fs == "crypto_LUKS" { let mapping = node["children"].get(0).and_then(|c| c.as_str()).and_then(|c| sim.node(c)); let Some(mapping) = mapping else { ctx.notes.push(json!({ "code": "LUKS_CLOSED", "device": path })); sim.arena[index]["size"] = num(new_bytes); return Ok(()); }; sim.need_tool("cryptsetup", "resizing LUKS mappings")?; let m = sim.arena[mapping].clone(); let key_location = m["crypt"]["keyLocation"].clone(); if KEYRING.is_match(&if truthy(&key_location) { js::string(Some(&key_location)) } else { String::new() }) { return Err(fail("LUKS_PASSPHRASE_REQUIRED", format!("The LUKS2 volume key of {} is held in the kernel keyring, so cryptsetup needs the passphrase to resize it. CH-J never handles LUKS secrets; resize the mapping manually.", js::string(m.get("path"))))); } if !truthy(&key_location) { return Err(fail("LUKS_UNVERIFIED", format!("The key location of {} could not be verified; only display and diagnostics are available.", js::string(m.get("path"))))); } step(ctx, "luks-resize", vec![m["dmName"].clone()], "luks", false)?; let or0 = |v: &Value| if truthy(v) { number(v) } else { 0.0 }; let header = jmax(0.0, or0(&node["size"]) - or0(&m["size"])); let mapping_size = new_bytes - header; sim.arena[mapping]["size"] = num(mapping_size); grow_content(sim, ctx, Some(mapping), mapping_size, auto_lv)?; } else if fs == "LVM2_member" { sim.need_tool("pvresize", "resizing LVM physical volumes")?; step(ctx, "pv-resize", vec![path], "lvm", false)?; let pv = sim.pv_index(index); let vg = pv.and_then(|p| sim.vg_index(&sim.lvm["pvs"][p]["vg"].clone())); let base = match pv.map(|p| &sim.lvm["pvs"][p]["size"]).filter(|v| !v.is_null()) { Some(v) => number(v), None => { if node["size"].is_null() { new_bytes } else { number(&node["size"]) } } }; let growth = new_bytes - base; let or0 = |v: &Value| if truthy(v) { number(v) } else { 0.0 }; if let Some(p) = pv { let free = or0(&sim.lvm["pvs"][p]["free"]) + jmax(0.0, growth); sim.lvm["pvs"][p]["size"] = num(new_bytes); sim.lvm["pvs"][p]["free"] = num(free); } if let Some(v) = vg { let free = or0(&sim.lvm["vgs"][v]["free"]) + jmax(0.0, growth); sim.lvm["vgs"][v]["free"] = num(free); } if auto_lv { if let Some(v) = vg { match auto_lv_index(sim, v) { Some(lv) => extend_lv(sim, ctx, lv, None)?, None => ctx.notes.push(json!({ "code": "VG_FREE_LEFT", "vg": sim.lvm["vgs"][v]["name"] })), } } } } else if fs == "vfat" { return Err(fail("FAT_RESIZE_UNSUPPORTED", "Resizing FAT is not implemented (fatresize is not reliable enough for unattended use).")); } else { return Err(fail("RESIZE_UNSUPPORTED", format!("Resizing content of type {fs} is not supported."))); } sim.arena[index]["fsSize"] = num(new_bytes); sim.arena[index]["size"] = num(new_bytes); Ok(()) } fn shrink_content(sim: &mut Sim, ctx: &mut Ctx, index: Option, new_bytes: f64) -> Result<(), CoreError> { let Some(index) = index else { return Ok(()) }; let node = sim.arena[index].clone(); if !truthy(&node["fstype"]) { return Ok(()); } let fstype = node["fstype"].clone(); let fs = fstype.as_str().unwrap_or(""); let path = node["path"].clone(); let path_s = js::string(Some(&path)); let points: Vec = node["mountpoints"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default(); let used_of = |v: &Value| if v.is_null() { None } else { Some(number(v)) }; if is_ext(&fstype) { sim.need_tool("resize2fs", "shrinking ext file systems")?; sim.need_tool("e2fsck", "checking ext file systems")?; if !sim.in_use(Some(index)).is_empty() { let root = points.iter().any(|p| SYSTEM_MOUNTS.is_match(p)); return Err(fail( "SHRINK_REQUIRES_UNMOUNT", if root { format!("{path_s} is mounted at {}, which the running system needs. Shrinking ext4 is only possible offline: boot a rescue/live system and shrink it there.", points.join(", ")) } else { format!("ext file systems are shrunk only while unmounted; unmount {path_s} first (CH-J never unmounts automatically).") }, )); } let used = used_of(node["usage"].get("used").unwrap_or(&Value::Null)).ok_or_else(|| fail("USAGE_UNKNOWN", format!("The usage of {path_s} could not be determined; shrinking is refused.")))?; let reserve = jmax(used / 10.0, 128.0 * MIB); if new_bytes < used + reserve { return Err(fail("SIZE_TOO_SMALL", format!("The new size must keep the used {} MiB plus a reserve of {} MiB.", ceil_mib(used), ceil_mib(reserve))).with("minimumBytes", num((used + reserve).ceil()))); } step(ctx, "shrink-fs", vec![path.clone(), fstype.clone(), num(new_bytes)], "filesystem", true)?; push_unique(&mut ctx.offline, path_s); } else if fs == "btrfs" { sim.need_tool("btrfs", "shrinking Btrfs")?; if points.is_empty() { return Err(fail("BTRFS_NEEDS_MOUNT", format!("Btrfs is resized while mounted; mount {path_s} first."))); } let devices = if truthy(&node["btrfs"]["devices"]) { number(&node["btrfs"]["devices"]) } else { 1.0 }; if devices > 1.0 { return Err(fail("BTRFS_MULTI_DEVICE", "Shrinking a multi-device Btrfs is not supported.")); } let used = used_of(node["btrfs"].get("allocated").unwrap_or(&Value::Null)) .or_else(|| used_of(node["usage"].get("used").unwrap_or(&Value::Null))) .ok_or_else(|| fail("USAGE_UNKNOWN", format!("The allocation of {path_s} could not be determined; shrinking is refused.")))?; let reserve = jmax(used / 10.0, 256.0 * MIB); if new_bytes < used + reserve { return Err(fail("SIZE_TOO_SMALL", format!("The new size must keep the allocated {} MiB plus a reserve of {} MiB.", ceil_mib(used), ceil_mib(reserve))).with("minimumBytes", num((used + reserve).ceil()))); } step(ctx, "shrink-fs", vec![path, fstype.clone(), num(new_bytes)], "filesystem", true)?; } else if fs == "ntfs" { sim.need_tool("ntfsresize", "shrinking NTFS")?; sim.require_free(Some(index), "NTFS_MOUNTED")?; step(ctx, "shrink-fs", vec![path, fstype.clone(), num(new_bytes)], "filesystem", true)?; } else if fs == "swap" { if truthy(&node["swapActive"]) { return Err(fail("SWAP_ACTIVE", format!("{path_s} is active swap; CH-J never disables swap automatically."))); } sim.need_tool("mkswap", "resizing swap")?; } else if fs == "xfs" { return Err(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 fs == "vfat" { return Err(fail("FAT_RESIZE_UNSUPPORTED", "Resizing FAT is not implemented.")); } else if fs == "crypto_LUKS" { return Err(fail("LUKS_SHRINK_UNSUPPORTED", "Shrinking LUKS (and the layers inside it) is never done automatically by CH-J.")); } else if fs == "LVM2_member" { return Err(fail("PV_SHRINK_UNSUPPORTED", "Shrinking an LVM physical volume is not automated; move extents and use pvresize manually.")); } else { return Err(fail("RESIZE_UNSUPPORTED", format!("Shrinking content of type {fs} is not supported."))); } Ok(()) } fn auto_lv_index(sim: &Sim, vg: usize) -> Option { let vg_name = sim.lvm["vgs"][vg]["name"].clone(); let lvs = sim.lvm["lvs"].as_array()?; let candidates: Vec = lvs .iter() .enumerate() .filter(|(_, lv)| lv["vg"] == vg_name && !truthy(&lv["thinPool"]) && !truthy(&lv["thinVolume"]) && !truthy(&lv["snapshot"]) && !truthy(&lv["pveGuest"])) .map(|(i, _)| i) .collect(); let with_fs: Vec = candidates .into_iter() .filter(|i| sim.lv_node(&lvs[*i]).is_some_and(|n| ["ext2", "ext3", "ext4", "xfs", "btrfs"].contains(&sim.arena[n]["fstype"].as_str().unwrap_or("")))) .collect(); if with_fs.len() == 1 { return Some(with_fs[0]); } with_fs.into_iter().find(|i| sim.lv_node(&lvs[*i]).is_some_and(|n| sim.arena[n]["mountpoints"].as_array().is_some_and(|m| m.iter().any(|p| p == "/")))) } fn lv_for(sim: &Sim, name: Option<&str>) -> Result { let raw = name.unwrap_or(""); let mut parts = raw.split('/'); let vg = parts.next().unwrap_or(""); let lv = parts.next(); let found = sim.lvm["lvs"].as_array().and_then(|lvs| lvs.iter().position(|e| e["vg"] == vg && lv.is_some_and(|l| e["name"] == l))); match found { Some(i) if LV_NAME.is_match(raw) => Ok(i), _ => Err(fail("DEVICE_NOT_FOUND", format!("Logical volume {} was not found.", name.unwrap_or("undefined")))), } } /// `size`: `None` = "max". fn extend_lv(sim: &mut Sim, ctx: &mut Ctx, lv: usize, size: Option) -> Result<(), CoreError> { sim.need_tool("lvextend", "extending logical volumes")?; let entry = sim.lvm["lvs"][lv].clone(); if truthy(&entry["thinPool"]) || truthy(&entry["thinVolume"]) { return Err(fail("LV_THIN_UNSUPPORTED", "Thin pools and thin volumes are shown read-only: their virtual size, pool data and allocation need manual planning.")); } if truthy(&entry["snapshot"]) { return Err(fail("LV_SNAPSHOT_UNSUPPORTED", "Snapshots are not resized by CH-J.")); } if truthy(&entry["pveGuest"]) { return Err(fail("PROTECTED_DEVICE", format!("This logical volume is a disk of Proxmox guest {}; CH-J does not change it.", js::string(entry.get("pveGuest"))))); } let vg = sim.vg_index(&entry["vg"]); let node = sim.lv_node(&entry); let vg_value = vg.map(|v| sim.lvm["vgs"][v].clone()); let extent = vg_value.as_ref().map(|v| &v["extentSize"]).filter(|v| truthy(v)).map(number).unwrap_or(4.0 * MIB); let free = vg_value.as_ref().map(|v| &v["free"]).filter(|v| truthy(v)).map(number).unwrap_or(0.0); let lv_size = number(&entry["size"]); let target = match size { None => lv_size + (free / extent).floor() * extent, Some(s) => (s / extent).floor() * extent, }; if !target.is_finite() || target <= lv_size { return Err(if size.is_none() { fail("NO_FREE_SPACE", format!("Volume group {} has no free extents.", js::string(entry.get("vg")))) } else { fail("SIZE_INVALID", "The new size must be larger than the current size.") }); } if target - lv_size > free { return Err(fail("NO_FREE_SPACE", format!("Volume group {} does not have enough free space.", js::string(entry.get("vg"))))); } if let Some(n) = node { verify_device(sim, ctx, n)?; } let name = json!(format!("{}/{}", js::string(entry.get("vg")), js::string(entry.get("name")))); step(ctx, "lv-extend", vec![name, if size.is_none() { json!("max") } else { num(target) }], "lvm", false)?; if let Some(v) = vg { let current = number(&sim.lvm["vgs"][v]["free"]); sim.lvm["vgs"][v]["free"] = num(current - (target - lv_size)); } sim.lvm["lvs"][lv]["size"] = num(target); if let Some(n) = node { grow_content(sim, ctx, Some(n), target, false)?; } Ok(()) } // ---------- operation handlers ---------- fn handle(sim: &mut Sim, ctx: &mut Ctx, op: &Op) -> Result<(), CoreError> { match op.kind.as_str() { "create-table" => { let disk = sim.disk_for(op.s("disk"))?; let label = op.s("label").unwrap_or(""); if label != "gpt" && label != "dos" { return Err(fail("ARGUMENTS_INVALID", "The partition table must be gpt or dos (MBR).")); } if truthy(&sim.disk(&disk)["protected"]) { return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage.", js::string(sim.disk(&disk).get("path"))))); } let node = sim.node(&disk); sim.require_free(node, "DEVICE_IN_USE")?; let parts = sim.disk(&disk)["partitions"].as_array().cloned().unwrap_or_default(); for part in &parts { sim.require_free(sim.part_node(&disk, part), "DEVICE_IN_USE")?; } sim.need_tool("sfdisk", "writing partition tables")?; touch_disk(sim, ctx, &disk, true)?; step(ctx, "create-table", vec![sim.disk(&disk)["path"].clone(), json!(label)], "partition", true)?; ctx.data_loss = true; let d = sim.disk_mut(&disk); let lbs = number(&d["logicalSectorSize"]); let sectors = number(&d["sectors"]); let tail = (16384.0 / lbs).ceil() + 1.0; let last = if label == "gpt" { sectors - tail - 1.0 } else { jmin(sectors - 1.0, 4_294_967_295.0) }; d["partitions"] = json!([]); d["table"] = json!({ "type": label, "id": null, "firstUsable": d["alignSectors"], "lastUsable": num(last), "hash": null, "source": "plan", "gptBackupMisplaced": false, "maxPrimary": if label == "dos" { 4 } else { 128 }, "lastUsableNow": num(last) }); d["wholeDevice"] = Value::Null; if let Some(n) = node { sim.arena[n]["fstype"] = Value::Null; } sim.refresh_segments(&disk); } "create-partition" => { let disk = sim.disk_for(op.s("disk"))?; let d = sim.disk(&disk).clone(); let table_type = d["table"]["type"].as_str().unwrap_or("").to_string(); if !truthy(&d["table"]) || (table_type != "gpt" && table_type != "dos") { return Err(fail("NO_PARTITION_TABLE", format!("{} has no supported partition table; create one first.", js::string(d.get("path"))))); } if truthy(&d["protected"]) { return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage.", js::string(d.get("path"))))); } sim.need_tool("sfdisk", "creating partitions")?; let fs = op.s("fs").unwrap_or("none").to_string(); if fs != "none" && !FILESYSTEMS.contains(&fs.as_str()) { return Err(fail("ARGUMENTS_INVALID", "Unsupported file system.")); } if fs != "none" { sim.need_tool(mkfs_tool(&fs), &format!("creating {fs}"))?; } let align = number(&d["alignSectors"]); let start = align_up(jmax(op.start.unwrap_or(f64::NAN), number(&d["table"]["firstUsable"])), align); let segment = d["segments"].as_array().and_then(|s| s.iter().find(|e| e["kind"] == "free" && start >= number(&e["start"]) && start <= number(&e["end"])).cloned()); let Some(segment) = segment else { return Err(fail("NO_FREE_SPACE", "The requested start is not inside unallocated space.")) }; let seg_end = number(&segment["end"]); let mut size = if op.is_max() { seg_end - start + 1.0 } else { op.size_number() }; if !safe_integer(size) || size < align { return Err(fail("SIZE_INVALID", "The partition must be at least 1 MiB.")); } if start + size - 1.0 > seg_end { return Err(fail("NO_FREE_SPACE", "The partition does not fit into the unallocated space.")); } if start + size - 1.0 != seg_end { size = align_down(size, align); } let lbs = number(&d["logicalSectorSize"]); if fs != "none" && size * lbs < min_fs_bytes(&fs) { return Err(fail("SIZE_TOO_SMALL", format!("{fs} needs at least {} MiB.", ceil_mib(min_fs_bytes(&fs))))); } if table_type == "dos" && start + size - 1.0 > 4_294_967_295.0 { return Err(fail("MBR_LIMIT", "MBR cannot address space beyond 2 TiB; use GPT.")); } let used: Vec = d["partitions"].as_array().unwrap().iter().map(|p| number(&p["number"])).collect(); let max = if table_type == "dos" { 4.0 } else { 128.0 }; let mut number_value = 1.0; while used.contains(&number_value) && number_value <= max { number_value += 1.0; } if number_value > max { return Err(fail("PARTITION_LIMIT", if table_type == "dos" { "All four MBR primary partitions are used; CH-J does not create extended/logical partitions." } else { "The GPT has no free entry." })); } let flag = if op.s("typeFlag") == Some("esp") && fs == "vfat" { "esp" } else if fs == "swap" { "swap" } else if fs == "vfat" { "fat32" } else if fs == "ntfs" { "basic-data" } else { "linux" }; let type_code = partition_type_code(flag, &table_type); let name = op.s("name").map(str::to_string).unwrap_or_else(|| "-".into()); if name != "-" && (table_type != "gpt" || !LABEL_PATTERN.is_match(&name)) { return Err(fail("ARGUMENTS_INVALID", "Partition names (GPT only) may contain A-Z, a-z, 0-9, dot, dash and underscore (max. 36).")); } let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into()); if label != "-" && fs != "none" { validate_label(&fs, &label)?; } if d["table"]["gptBackupMisplaced"] == true && start + size - 1.0 > number(&d["table"]["lastUsableNow"]) { relocate(sim, ctx, &disk)?; } touch_disk(sim, ctx, &disk, true)?; let type_value = type_code.map(Value::from).unwrap_or(Value::Null); step(ctx, "create-part", vec![d["path"].clone(), num(number_value), num(start), num(size), type_value.clone(), json!(name)], "partition", false)?; let path = part_path(&disk, &num(number_value)); let kname = path[5..].to_string(); if fs != "none" { let fs_label = if label == "-" { "-".to_string() } else if fs == "vfat" { label.to_uppercase() } else { label.clone() }; step(ctx, "mkfs", vec![json!(path), json!(fs), json!(fs_label)], "filesystem", true)?; } let bytes = size * lbs; let fstype = if fs == "none" { Value::Null } else { json!(fs) }; let label_value = if label == "-" { Value::Null } else { json!(label) }; let part = json!({ "number": num(number_value), "start": num(start), "size": num(size), "end": num(start + size - 1.0), "bytes": num(bytes), "type": type_value, "typeName": flag, "name": if name == "-" { Value::Null } else { json!(name) }, "kernel": kname, "path": path, "fstype": fstype, "label": label_value, "mountpoints": [], "usage": null, "inUse": false, "inUseReasons": [], "systemRoles": [], "children": [], "created": true }); sim.disk_mut(&disk)["partitions"].as_array_mut().unwrap().push(part); sim.add_node(json!({ "kname": kname, "path": path, "kind": "part", "parent": disk, "partition": num(number_value), "size": num(bytes), "fstype": fstype, "label": label_value, "mountpoints": [], "children": [], "parents": [disk], "swapActive": false, "synthetic": true })); sim.refresh_segments(&disk); if let Some(last) = ctx.steps.last_mut() { last["created"] = json!(path); } } "delete-partition" => { let disk = sim.disk_for(op.s("disk"))?; let pi = sim.part_index(&disk, op.number)?; let part = sim.part(&disk, pi).clone(); if truthy(&part["extended"]) || truthy(&part["logical"]) { return Err(fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only.")); } assert_not_boot_partition(Some(sim.disk(&disk)), Some(&part))?; let node = sim.part_node(&disk, &part); sim.require_free(node, "DEVICE_IN_USE")?; sim.need_tool("sfdisk", "deleting partitions")?; touch_disk(sim, ctx, &disk, true)?; if let Some(n) = node { step(ctx, "require-free", vec![sim.arena[n]["path"].clone()], "check", false)?; } step(ctx, "delete-part", vec![sim.disk(&disk)["path"].clone(), part["number"].clone(), part["start"].clone(), part["size"].clone()], "partition", true)?; ctx.data_loss = true; sim.disk_mut(&disk)["partitions"].as_array_mut().unwrap().remove(pi); if let Some(n) = node { let kname = js::string(sim.arena[n].get("kname")); let path = js::string(sim.arena[n].get("path")); sim.nodes.shift_remove(&kname); sim.by_path.remove(&path); } sim.refresh_segments(&disk); } "resize-partition" => { let disk = sim.disk_for(op.s("disk"))?; let pi = sim.part_index(&disk, op.number)?; let d = sim.disk(&disk).clone(); let table_type = d["table"]["type"].as_str().unwrap_or("").to_string(); if !truthy(&d["table"]) || (table_type != "gpt" && table_type != "dos") { return Err(fail("NO_PARTITION_TABLE", "Unsupported partition table.")); } let part = sim.part(&disk, pi).clone(); if truthy(&part["extended"]) || truthy(&part["logical"]) { return Err(fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only.")); } sim.need_tool("sfdisk", "resizing partitions")?; let tools = &sim.inventory["tools"]; if !truthy(&tools["partx"]) && !truthy(&tools["blockdev"]) && !truthy(&tools["partprobe"]) { return Err(fail("TOOL_MISSING", "partx, blockdev or partprobe is required to inform the kernel.")); } let node = sim.part_node(&disk, &part); let boundary = sim.boundary_after(&disk, &part); let lbs = number(&d["logicalSectorSize"]); let align = number(&d["alignSectors"]); let start = number(&part["start"]); let old = number(&part["size"]); let size = if op.is_max() { boundary - start + 1.0 } else { let s = op.size_number(); if !safe_integer(s) || s < align { return Err(fail("SIZE_INVALID", "Invalid partition size.")); } if start + s - 1.0 != boundary { jmax(align, align_down(start + s, align) - start) } else { s } }; if start + size - 1.0 > boundary { return Err(fail("NO_FREE_SPACE", "There is not enough unallocated space directly after the partition.")); } if table_type == "dos" && start + size - 1.0 > 4_294_967_295.0 { return Err(fail("MBR_LIMIT", "MBR cannot address space beyond 2 TiB.")); } if size == old { return Err(if op.is_max() { fail("NO_FREE_SPACE", "There is no unallocated space directly after the partition.") } else { fail("NO_CHANGE", "The size is unchanged.") }); } let new_bytes = size * lbs; let set_part = |sim: &mut Sim| { let p = sim.part_mut(&disk, pi); p["size"] = num(size); p["end"] = num(start + size - 1.0); p["bytes"] = num(new_bytes); }; if size > old { if d["table"]["gptBackupMisplaced"] == true && start + size - 1.0 > number(&d["table"]["lastUsableNow"]) { relocate(sim, ctx, &disk)?; } touch_disk(sim, ctx, &disk, true)?; if node.is_some() && !sim.in_use(node).is_empty() { ctx.may_reboot = true; } step(ctx, "resize-part", vec![d["path"].clone(), part["number"].clone(), part["start"].clone(), part["size"].clone(), num(size)], "partition", false)?; set_part(sim); if let Some(n) = node { sim.arena[n]["size"] = num(new_bytes); } grow_content(sim, ctx, node, new_bytes, op.auto == Some(true))?; } else { if truthy(&d["protected"]) { return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage; shrinking is not offered.", js::string(d.get("path"))))); } if node.is_some() { shrink_content(sim, ctx, node, new_bytes)?; } touch_disk(sim, ctx, &disk, true)?; step(ctx, "resize-part", vec![d["path"].clone(), part["number"].clone(), part["start"].clone(), part["size"].clone(), num(size)], "partition", true)?; ctx.data_loss = true; set_part(sim); if let Some(n) = node { sim.arena[n]["size"] = num(new_bytes); let fstype = sim.arena[n]["fstype"].clone(); let path = sim.arena[n]["path"].clone(); if fstype == "swap" { step(ctx, "grow-fs", vec![path.clone(), json!("swap")], "filesystem", false)?; } if truthy(&fstype) { sim.arena[n]["fsSize"] = num(new_bytes); } if is_ext(&fstype) { step(ctx, "verify-fs", vec![path, fstype], "verify", false)?; } } } sim.refresh_segments(&disk); } "format" => { let n = sim.node_for(op.s("device"))?; let fs = op.s("fs").unwrap_or("").to_string(); if !FILESYSTEMS.contains(&fs.as_str()) { return Err(fail("ARGUMENTS_INVALID", "Unsupported file system.")); } sim.need_tool(mkfs_tool(&fs), &format!("creating {fs}"))?; let node = sim.arena[n].clone(); let kind = node["kind"].as_str().unwrap_or(""); let kname = js::string(node.get("kname")); if (kind == "disk" || kind == "loop") && sim.disks.get(&kname).is_some_and(|d| truthy(&d["table"])) { return Err(fail("HAS_PARTITION_TABLE", "This disk has a partition table; format a partition instead.")); } if !["part", "lvm", "disk", "loop"].contains(&kind) { return Err(fail("FORMAT_UNSUPPORTED", "Only partitions, logical volumes and unpartitioned disks can be formatted.")); } if truthy(&node["lvm"]["thinPool"]) { return Err(fail("LV_THIN_UNSUPPORTED", "Thin pools cannot be formatted.")); } assert_not_protected(sim, n, "format")?; if kind == "part" { let parent = js::string(node.get("parent")); let disk = sim.disks.get(&parent); let part = disk.and_then(|d| d["partitions"].as_array().and_then(|p| p.iter().find(|e| e["number"] == node["partition"]))); assert_not_boot_partition(disk, part)?; } sim.require_free(Some(n), "DEVICE_IN_USE")?; let size = if truthy(&node["size"]) { number(&node["size"]) } else { 0.0 }; if size < min_fs_bytes(&fs) { return Err(fail("SIZE_TOO_SMALL", format!("{fs} needs at least {} MiB.", ceil_mib(min_fs_bytes(&fs))))); } let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into()); if label != "-" { validate_label(&fs, &label)?; } verify_device(sim, ctx, n)?; let fs_label = if label == "-" { "-".to_string() } else if fs == "vfat" { label.to_uppercase() } else { label.clone() }; step(ctx, "mkfs", vec![node["path"].clone(), json!(fs), json!(fs_label)], "filesystem", true)?; ctx.data_loss = true; let target = &mut sim.arena[n]; target["fstype"] = json!(fs); target["label"] = if label == "-" { Value::Null } else { json!(label) }; target["usage"] = Value::Null; target["fsSize"] = node.get("size").cloned().unwrap_or(Value::Null); target["uuid"] = Value::Null; sim.sync_part(n); } "set-label" => { let n = sim.node_for(op.s("device"))?; let node = sim.arena[n].clone(); let fstype = node["fstype"].as_str().unwrap_or("").to_string(); let tool = match fstype.as_str() { "ext2" | "ext3" | "ext4" => "e2label", "xfs" => "xfs_admin", "btrfs" => "btrfs", "vfat" => "fatlabel", "ntfs" => "ntfslabel", "swap" => "swaplabel", _ => return Err(fail("LABEL_UNSUPPORTED", format!("Labels of {} cannot be changed.", if fstype.is_empty() { "this device" } else { &fstype }))), }; sim.need_tool(tool, "changing labels")?; let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into()); if label != "-" { validate_label(&fstype, &label)?; } if label == "-" && fstype == "vfat" { return Err(fail("ARGUMENTS_INVALID", "Enter a FAT label.")); } if ["xfs", "vfat", "ntfs"].contains(&fstype.as_str()) { sim.require_free(Some(n), "LABEL_REQUIRES_UNMOUNT")?; } verify_device(sim, ctx, n)?; let fs_label = if label == "-" { "-".to_string() } else if fstype == "vfat" { label.to_uppercase() } else { label.clone() }; step(ctx, "set-label", vec![node["path"].clone(), json!(fstype), json!(fs_label)], "filesystem", false)?; sim.arena[n]["label"] = if label == "-" { Value::Null } else { json!(label) }; sim.sync_part(n); } "set-name" => { let disk = sim.disk_for(op.s("disk"))?; let pi = sim.part_index(&disk, op.number)?; if sim.disk(&disk)["table"]["type"] != "gpt" { return Err(fail("NAME_UNSUPPORTED", "Partition names exist only in GPT.")); } let name = op.s("name").map(str::to_string).unwrap_or_else(|| "-".into()); if name != "-" && !LABEL_PATTERN.is_match(&name) { return Err(fail("ARGUMENTS_INVALID", "Partition names may contain A-Z, a-z, 0-9, dot, dash and underscore (max. 36).")); } sim.need_tool("sfdisk", "naming partitions")?; touch_disk(sim, ctx, &disk, true)?; let number_value = sim.part(&disk, pi)["number"].clone(); step(ctx, "set-name", vec![sim.disk(&disk)["path"].clone(), number_value, json!(name)], "partition", false)?; sim.part_mut(&disk, pi)["name"] = if name == "-" { Value::Null } else { json!(name) }; } "set-flag" => { let disk = sim.disk_for(op.s("disk"))?; let pi = sim.part_index(&disk, op.number)?; let d = sim.disk(&disk).clone(); let table_type = d["table"]["type"].as_str().unwrap_or("").to_string(); let flag = op.s("flag").unwrap_or("").to_string(); if table_type.is_empty() || !flags(&table_type).contains(&flag.as_str()) { return Err(fail("FLAG_UNSUPPORTED", format!("The flag {} is not supported for this partition table.", op.s("flag").unwrap_or("undefined")))); } let part = sim.part(&disk, pi).clone(); if truthy(&part["extended"]) || truthy(&part["logical"]) { return Err(fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only.")); } let node = sim.part_node(&disk, &part); let roles = node.and_then(|n| sim.arena[n].get("systemRoles").filter(|v| truthy(v)).cloned()).or_else(|| part.get("systemRoles").filter(|v| truthy(v)).cloned()).unwrap_or_else(|| json!([])); if roles.as_array().is_some_and(|r| !r.is_empty()) && flag != "boot" && flag != "legacy_boot" { return Err(fail("SYSTEM_PARTITION", "The partition is used by the running system; its type is not changed by CH-J.")); } sim.need_tool("sfdisk", "changing partition flags")?; touch_disk(sim, ctx, &disk, true)?; let enabled = op.enabled == Some(true); if flag == "boot" { let mut list: Vec = d["partitions"].as_array().unwrap().iter().filter(|e| truthy(&e["bootable"])).map(|e| number(&e["number"])).collect(); let own = number(&part["number"]); list.retain(|n| *n != own); if enabled { list.push(own); } list.sort_by(|a, b| a.partial_cmp(b).unwrap()); list.dedup(); let value = if list.is_empty() { "-".to_string() } else { list.iter().map(|n| num_string(*n)).collect::>().join(",") }; step(ctx, "set-boot", vec![d["path"].clone(), json!(value)], "partition", false)?; sim.part_mut(&disk, pi)["bootable"] = json!(enabled); } else if flag == "legacy_boot" { let source = if truthy(&part["attrs"]) { js::string(Some(&part["attrs"])) } else { String::new() }; let mut attrs: Vec = Vec::new(); for a in ATTR_SPLIT.split(&source).filter(|s| !s.is_empty()) { if !attrs.iter().any(|x| x == a) { attrs.push(a.to_string()); } } if enabled { if !attrs.iter().any(|a| a == "LegacyBIOSBootable") { attrs.push("LegacyBIOSBootable".into()); } } else { attrs.retain(|a| a != "LegacyBIOSBootable"); } let value = if attrs.is_empty() { "-".to_string() } else { attrs.join(",") }; if !ATTRS.is_match(&value) { return Err(fail("FLAG_UNSUPPORTED", "The existing GPT attributes cannot be represented safely.")); } step(ctx, "set-attrs", vec![d["path"].clone(), part["number"].clone(), json!(value)], "partition", false)?; sim.part_mut(&disk, pi)["attrs"] = if value == "-" { Value::Null } else { json!(value) }; } else { let target = if enabled { flag.as_str() } else { "linux" }; if !enabled && part["typeName"] != flag.as_str() { return Err(fail("NO_CHANGE", "The partition does not have this type.")); } let code = partition_type_code(target, &table_type).ok_or_else(|| fail("FLAG_UNSUPPORTED", "This type is not available for the partition table."))?; step(ctx, "set-type", vec![d["path"].clone(), part["number"].clone(), json!(code)], "partition", false)?; let p = sim.part_mut(&disk, pi); p["type"] = json!(code); p["typeName"] = json!(target); } } "check" => { let n = sim.node_for(op.s("device"))?; let node = sim.arena[n].clone(); let fstype = node["fstype"].as_str().unwrap_or("").to_string(); let repair = op.repair == Some(true); let supported = match fstype.as_str() { "ext2" | "ext3" | "ext4" => Some("e2fsck"), "xfs" => Some("xfs_repair"), "btrfs" if !repair => Some("btrfs"), "vfat" => Some("fsck.vfat"), _ => None, }; let Some(tool) = supported else { return Err(fail( "CHECK_UNSUPPORTED", if repair && fstype == "btrfs" { "btrfs check --repair is not offered: it can make damage worse. Use btrfs scrub or expert recovery.".to_string() } else { format!("Checking {} is not supported.", if fstype.is_empty() { "this device" } else { &fstype }) }, )); }; sim.need_tool(tool, "file system checks")?; sim.require_free(Some(n), "CHECK_REQUIRES_UNMOUNT")?; verify_device(sim, ctx, n)?; step(ctx, "fsck", vec![node["path"].clone(), json!(fstype), json!(if repair { "repair" } else { "check" })], "check", repair)?; } "grow-fs" => { let n = sim.node_for(op.s("device"))?; let node = sim.arena[n].clone(); if !["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap"].contains(&node["fstype"].as_str().unwrap_or("")) { return Err(fail("RESIZE_UNSUPPORTED", "This content cannot be grown.")); } if truthy(&node["fsSize"]) && truthy(&node["size"]) && number(&node["size"]) - number(&node["fsSize"]) < MIB { return Err(fail("NO_CHANGE", "The file system already fills the device.")); } verify_device(sim, ctx, n)?; grow_content(sim, ctx, Some(n), number(&node["size"]), false)?; } "mount" => { let n = sim.node_for(op.s("device"))?; let node = sim.arena[n].clone(); if !MOUNTABLE.contains(&node["fstype"].as_str().unwrap_or("")) { return Err(fail("MOUNT_UNSUPPORTED", "This device does not contain a mountable file system.")); } if node["mountpoints"].as_array().is_some_and(|m| !m.is_empty()) { return Err(fail("ALREADY_MOUNTED", format!("{} is already mounted.", js::string(node.get("path"))))); } let mountpoint = op.s("mountpoint").unwrap_or("").to_string(); if !MOUNTPOINT_PATTERN.is_match(&mountpoint) || mountpoint.contains("..") { return Err(fail("MOUNTPOINT_INVALID", "Mount points must be below /mnt or /media (letters, digits, dot, dash, underscore).")); } for (_, index) in sim.nodes.iter() { if sim.arena[*index]["mountpoints"].as_array().is_some_and(|m| m.iter().any(|p| p == mountpoint.as_str())) { return Err(fail("MOUNTPOINT_INVALID", format!("{mountpoint} is already in use."))); } } step(ctx, "mount", vec![node["path"].clone(), json!(mountpoint)], "mount", false)?; sim.arena[n]["mountpoints"] = json!([mountpoint]); sim.sync_part(n); } "unmount" => { let n = sim.node_for(op.s("device"))?; let node = sim.arena[n].clone(); let points: Vec = node["mountpoints"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default(); if points.is_empty() { return Err(fail("NOT_MOUNTED", format!("{} is not mounted.", js::string(node.get("path"))))); } if let Some(system) = points.iter().find(|p| SYSTEM_MOUNTS.is_match(p)) { return Err(fail("SYSTEM_MOUNT", format!("{system} is needed by the running system; CH-J never unmounts it."))); } step(ctx, "unmount", vec![node["path"].clone()], "mount", false)?; sim.arena[n]["mountpoints"] = json!([]); if truthy(&node["usage"]) { sim.arena[n]["usage"]["source"] = json!("superblock"); } sim.sync_part(n); } "lv-extend" => { let lv = lv_for(sim, op.s("lv"))?; extend_lv(sim, ctx, lv, if op.is_max() { None } else { Some(op.size_number()) })?; } "lv-reduce" => { if op.advanced != Some(true) { return Err(fail("ADVANCED_CONFIRMATION_REQUIRED", "Shrinking a logical volume is an advanced operation and must be confirmed explicitly.")); } let lv = lv_for(sim, op.s("lv"))?; sim.need_tool("lvreduce", "reducing logical volumes")?; let entry = sim.lvm["lvs"][lv].clone(); if truthy(&entry["thinPool"]) || truthy(&entry["thinVolume"]) || truthy(&entry["snapshot"]) || truthy(&entry["pveGuest"]) { return Err(fail("LV_THIN_UNSUPPORTED", "Thin, snapshot and Proxmox guest volumes are not reduced by CH-J.")); } let n = sim.lv_node(&entry).ok_or_else(|| fail("DEVICE_NOT_FOUND", "The logical volume is not active."))?; let fstype = sim.arena[n]["fstype"].clone(); if !is_ext(&fstype) { return Err(if fstype == "xfs" { fail("XFS_SHRINK_UNSUPPORTED", "XFS cannot be shrunk; migrate the data to a new, smaller volume instead.") } else { fail("LV_SHRINK_UNSUPPORTED", "Only logical volumes with an unmounted ext file system can be reduced.") }); } let vg = sim.vg_index(&entry["vg"]); let extent = vg.map(|v| &sim.lvm["vgs"][v]["extentSize"]).filter(|v| truthy(v)).map(number).unwrap_or(4.0 * MIB); let target = (op.size_number() / extent).floor() * extent; let lv_size = number(&entry["size"]); if !safe_integer(target) || target <= 0.0 || target >= lv_size { return Err(fail("SIZE_INVALID", "The new size must be smaller than the current size.")); } verify_device(sim, ctx, n)?; shrink_content(sim, ctx, Some(n), target)?; step(ctx, "lv-reduce", vec![json!(format!("{}/{}", js::string(entry.get("vg")), js::string(entry.get("name")))), num(target)], "lvm", true)?; let path = sim.arena[n]["path"].clone(); step(ctx, "verify-fs", vec![path, fstype], "verify", false)?; ctx.data_loss = true; if let Some(v) = vg { let free = number(&sim.lvm["vgs"][v]["free"]); sim.lvm["vgs"][v]["free"] = num(free + (lv_size - target)); } sim.lvm["lvs"][lv]["size"] = num(target); sim.arena[n]["size"] = num(target); sim.arena[n]["fsSize"] = num(target); } "pv-resize" => { let n = sim.node_for(op.s("device"))?; let node = sim.arena[n].clone(); if node["fstype"] != "LVM2_member" { return Err(fail("NOT_A_PV", format!("{} is not an LVM physical volume.", js::string(node.get("path"))))); } if let Some(pv) = sim.pv_index(n) { let entry = &sim.lvm["pvs"][pv]; if number(&entry["deviceSize"]) - number(&entry["size"]) < MIB { return Err(fail("NO_CHANGE", "The physical volume already uses the whole device.")); } } verify_device(sim, ctx, n)?; grow_content(sim, ctx, Some(n), number(&node["size"]), op.auto == Some(true))?; } "move-partition" => return Err(fail("OPERATION_DISABLED", "Moving partitions is prepared in the architecture but disabled until a reliable, tested mechanism exists.")), "copy-partition" => return Err(fail("OPERATION_DISABLED", "Copying partitions is prepared in the architecture but disabled until a reliable, tested mechanism exists.")), _ => return Err(fail("ARGUMENTS_INVALID", format!("Unknown operation {}.", op.kind))), } Ok(()) } fn disk_view(sim: &Sim, disk: &str) -> Value { let d = sim.disk(disk); let segments: Vec = d["segments"] .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut segment| { if segment["kind"] != "partition" { return segment; } let part = d["partitions"].as_array().and_then(|p| p.iter().find(|e| e["number"] == segment["number"])); let node = part.and_then(|p| sim.part_node(disk, p)).map(|n| &sim.arena[n]); let coalesce = |key: &str| { node.and_then(|n| n.get(key)).filter(|v| !v.is_null()).or_else(|| part.and_then(|p| p.get(key)).filter(|v| !v.is_null())).cloned().unwrap_or(Value::Null) }; segment["fstype"] = coalesce("fstype"); segment["label"] = coalesce("label"); segment["path"] = part.map(|p| p["path"].clone()).filter(truthy).unwrap_or(Value::Null); segment["mountpoints"] = node.and_then(|n| n.get("mountpoints")).filter(|v| truthy(v)).cloned().unwrap_or_else(|| json!([])); segment["created"] = json!(part.is_some_and(|p| p["created"] == true)); segment }) .collect(); json!({ "name": d["name"], "path": d["path"], "size": d["size"], "sectors": d["sectors"], "logicalSectorSize": d["logicalSectorSize"], "table": if truthy(&d["table"]) { json!({ "type": d["table"]["type"] }) } else { Value::Null }, "segments": segments }) } /// `plan_operations(inventory, operations, title)`. pub fn plan_operations(inventory: &Value, operations: &Value, title: Option<&Value>) -> Result { if !truthy(&inventory["supported"]) { return Err(fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers.")); } if !truthy(&inventory["privileged"]) { return Err(fail("PRIVILEGE_REQUIRED", "Planning changes needs the privileged (root/sudo) inventory.")); } let list = operations.as_array().filter(|l| !l.is_empty() && l.len() <= MAX_OPERATIONS).ok_or_else(|| fail("ARGUMENTS_INVALID", format!("Plan 1 to {MAX_OPERATIONS} operations.")))?; let mut sim = Sim::new(inventory); let names: Vec = sim.disks.keys().cloned().collect(); let before: HashMap = names.iter().map(|n| (n.clone(), disk_view(&sim, n))).collect(); let mut ctx = Ctx::default(); let normalized = list.iter().map(normalize_operation).collect::, _>>()?; for (index, op) in normalized.iter().enumerate() { ctx.current_operation = index; handle(&mut sim, &mut ctx, op).map_err(|e| e.with("operation", index))?; } if ctx.steps.is_empty() { return Err(fail("NO_CHANGE", "The plan has no steps.")); } let original_disk = |name: &str| inventory["disks"].as_array().and_then(|d| d.iter().find(|e| e["name"] == name)).cloned().unwrap_or(Value::Null); // Guard steps run first: the job refuses to touch anything if the layout differs from this plan. let mut guards: Vec = Vec::new(); for name in &ctx.verify_disks { let disk = original_disk(name); let hash = if truthy(&disk["table"]["hash"]) { js::string(Some(&disk["table"]["hash"])) } else { "none".to_string() }; guards.push(json!({ "name": "verify-disk", "args": [js::arg(&disk["path"]), js::arg(&disk["sectors"]), hash], "operation": null, "kind": "verify", "destructive": false })); } for (path, size) in &ctx.verify_sizes { guards.push(json!({ "name": "verify-size", "args": [path, js::arg(size)], "operation": null, "kind": "verify", "destructive": false })); } for name in &ctx.backup_disks { guards.push(json!({ "name": "backup-table", "args": [js::arg(&original_disk(name)["path"])], "operation": null, "kind": "backup", "destructive": false })); } // Final verification of every partition the plan created or resized. let mut finals: Vec = Vec::new(); for entry in &ctx.steps { let args = entry["args"].as_array().unwrap(); if entry["name"] == "resize-part" { finals.push(json!({ "name": "verify-part", "args": [args[0], args[1], args[2], args[4]], "operation": entry["operation"], "kind": "verify", "destructive": false })); } if entry["name"] == "create-part" { finals.push(json!({ "name": "verify-part", "args": [args[0], args[1], args[2], args[3]], "operation": entry["operation"], "kind": "verify", "destructive": false })); } } let steps: Vec = guards.into_iter().chain(ctx.steps.iter().cloned()).chain(finals).collect(); let plan_text = steps .iter() .map(|s| std::iter::once(js::string(s.get("name"))).chain(s["args"].as_array().unwrap().iter().map(|a| js::string(Some(a)))).collect::>().join(" ")) .collect::>() .join("\n"); let title_value = match title { Some(t) if truthy(t) => js::string(Some(t)), _ => normalized.iter().map(|o| o.kind.clone()).collect::>().join(", "), }; Ok(json!({ "operations": normalized.iter().map(Op::to_value).collect::>(), "steps": steps, "planText": plan_text, "before": ctx.verify_disks.iter().filter_map(|n| before.get(n).cloned()).collect::>(), "after": ctx.verify_disks.iter().map(|n| disk_view(&sim, n)).collect::>(), "dataLoss": ctx.data_loss, "destructive": steps.iter().any(|s| s["destructive"] == true), "offline": ctx.offline, "mayRequireReboot": ctx.may_reboot, "backupDisks": ctx.backup_disks.iter().map(|n| original_disk(n)["path"].clone()).collect::>(), "notes": ctx.notes, "title": js::cut(&title_value, 200) })) } // ---------- automatic expansion ---------- pub fn suggestions(inventory: &Value) -> Vec { if !truthy(&inventory["supported"]) { return Vec::new(); } let mut result: Vec = Vec::new(); let mut seen: Vec = Vec::new(); let mut attempt = |id: String, kind: &str, target: Value, free: f64, operations: Value, result: &mut Vec| { if seen.contains(&id) { return; } seen.push(id.clone()); let mut entry = json!({ "id": id, "kind": kind, "target": target, "freeBytes": num(free), "operations": operations, "available": false, "blocked": null, "steps": [] }); if !truthy(&inventory["privileged"]) { entry["blocked"] = json!({ "code": "DISKS_PRIVILEGE_REQUIRED", "message": "Load the privileged inventory (sudo) to evaluate this expansion." }); } else { let title = json!(format!("auto-expand {}", js::string(Some(&target)))); match plan_operations(inventory, &operations, Some(&title)) { Ok(plan) => { entry["available"] = json!(true); entry["steps"] = Value::Array(plan["steps"].as_array().unwrap().iter().map(|s| s["name"].clone()).collect()); entry["mayRequireReboot"] = plan["mayRequireReboot"].clone(); entry["notes"] = plan["notes"].clone(); } Err(error) => entry["blocked"] = json!({ "code": if error.code.is_empty() { "DISKS_FAILED".to_string() } else { error.code.clone() }, "message": error.message }), } } result.push(entry); }; for disk in inventory["disks"].as_array().cloned().unwrap_or_default() { let table_type = disk["table"]["type"].as_str().unwrap_or(""); if !truthy(&disk["table"]) || (table_type != "gpt" && table_type != "dos") { continue; } let segments = disk["segments"].as_array().cloned().unwrap_or_default(); for part in disk["partitions"].as_array().cloned().unwrap_or_default() { if truthy(&part["extended"]) || truthy(&part["logical"]) { continue; } let position = segments.iter().position(|s| s["kind"] == "partition" && s["number"] == part["number"]).map(|i| i as i64).unwrap_or(-1); let Some(next) = segments.get((position + 1) as usize) else { continue }; if next["kind"] != "free" || number(&next["bytes"]) < AUTO_MIN_BYTES { continue; } if truthy(&part["fstype"]) && ["linux_raid_member", "zfs_member", "vfat"].contains(&part["fstype"].as_str().unwrap_or("")) { continue; } let disk_name = js::string(disk.get("name")); let target = if truthy(&part["path"]) { part["path"].clone() } else { json!(part_path(&disk_name, &part["number"])) }; attempt( format!("partition:{disk_name}:{}", js::arg(&part["number"])), "partition", target, number(&next["bytes"]), json!([{ "type": "resize-partition", "disk": disk_name, "number": part["number"], "size": "max", "auto": true }]), &mut result, ); } } for node in inventory["nodes"].as_array().cloned().unwrap_or_default() { if !truthy(&node["fsSize"]) || !truthy(&node["size"]) || !["ext2", "ext3", "ext4", "xfs", "btrfs"].contains(&node["fstype"].as_str().unwrap_or("")) { continue; } let free = number(&node["size"]) - number(&node["fsSize"]); if free < AUTO_MIN_BYTES { continue; } let target = if truthy(&node["displayPath"]) { node["displayPath"].clone() } else { node["path"].clone() }; attempt(format!("filesystem:{}", js::string(node.get("kname"))), "filesystem", target, free, json!([{ "type": "grow-fs", "device": node["path"] }]), &mut result); } for pv in inventory["lvm"]["pvs"].as_array().cloned().unwrap_or_default() { if !truthy(&pv["deviceSize"]) || number(&pv["deviceSize"]) - number(&pv["size"]) < AUTO_MIN_BYTES { continue; } let free = number(&pv["deviceSize"]) - number(&pv["size"]); attempt(format!("pv:{}", js::string(pv.get("path"))), "pv", pv["path"].clone(), free, json!([{ "type": "pv-resize", "device": pv["path"], "auto": true }]), &mut result); } for vg in inventory["lvm"]["vgs"].as_array().cloned().unwrap_or_default() { if !truthy(&vg["free"]) || number(&vg["free"]) < AUTO_MIN_BYTES { continue; } let sim = Sim::new(inventory); let Some(vg_index) = sim.vg_index(&vg["name"]) else { continue }; if let Some(lv) = auto_lv_index(&sim, vg_index) { let entry = &sim.lvm["lvs"][lv]; let name = format!("{}/{}", js::string(entry.get("vg")), js::string(entry.get("name"))); attempt(format!("lv:{name}"), "lv", json!(name), number(&vg["free"]), json!([{ "type": "lv-extend", "lv": name, "size": "max" }]), &mut result); } } result }