المصدر المنشور
تنزيل الشيفرة المصدرية بصيغة ZIP
CH-J Tools
تصفّح الأدلة والملفات الخاصة بإصدار محدد من التطبيق.
CH-J / Server Manager
1704 ملفات
0.1.2-beta
تاريخ الإضافة: · تاريخ البناء:
1
//! Turns a queue of user operations (GParted-style) into a validated, simulated plan of2
//! whitelisted remote steps (port of `disks/planner.js`). Each operation is checked against the3
//! layout produced by the operations before it. Changing a partition boundary and resizing the4
//! content are separate steps, ordered by the content type and its layers5
//! (disk ← partition ← LUKS ← LVM ← file system). Nothing here touches a server.6
//!7
//! The simulation works on the same JSON model as 0.1.0 and mutates it the same way, so the8
//! generated plan text (which the remote helper executes) is identical.10
use super::inventory::{build_segments, partition_type_code, MIB, SYSTEM_MOUNTS};11
use super::js::{self, num, num_string, number, truthy};12
use crate::core::error::CoreError;13
use indexmap::IndexMap;14
use once_cell::sync::Lazy;15
use regex::Regex;16
use serde_json::{json, Map, Value};17
use std::collections::HashMap;19
pub const MAX_OPERATIONS: usize = 32;20
const AUTO_MIN_BYTES: f64 = 16.0 * MIB;21
const FILESYSTEMS: [&str; 6] = ["ext4", "xfs", "btrfs", "vfat", "ntfs", "swap"];22
const OPERATION_TYPES: [&str; 17] = [23
"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",24
];25
const MOUNTABLE: [&str; 8] = ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs", "exfat"];26
static LABEL_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._-]{1,36}$").unwrap());27
pub static MOUNTPOINT_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/(mnt|media)/[A-Za-z0-9._-]{1,64}(/[A-Za-z0-9._-]{1,64}){0,3}$").unwrap());28
static STEP_ARG: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._/:+,=@-]{1,160}$").unwrap());29
static EXT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^ext[234]$").unwrap());30
static LV_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._+-]{1,64}/[A-Za-z0-9._+-]{1,64}$").unwrap());31
static ATTRS: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9,:-]{1,120}$").unwrap());32
static ATTR_SPLIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\s,]+").unwrap());33
static KEYRING: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)keyring").unwrap());35
fn mkfs_tool(fs: &str) -> &'static str {36
match fs {37
"ext4" => "mkfs.ext4",38
"xfs" => "mkfs.xfs",39
"btrfs" => "mkfs.btrfs",40
"vfat" => "mkfs.vfat",41
"ntfs" => "mkfs.ntfs",42
_ => "mkswap",43
}44
}46
fn min_fs_bytes(fs: &str) -> f64 {47
match fs {48
"ext4" => 16.0 * MIB,49
"xfs" => 300.0 * MIB,50
"btrfs" => 256.0 * MIB,51
"vfat" => 33.0 * MIB,52
"ntfs" => 16.0 * MIB,53
_ => MIB,54
}55
}57
fn label_limit(fs: &str) -> usize {58
match fs {59
"ext2" | "ext3" | "ext4" => 16,60
"xfs" => 12,61
"btrfs" => 36,62
"vfat" => 11,63
"ntfs" => 32,64
"swap" => 15,65
_ => 16,66
}67
}69
fn flags(table: &str) -> &'static [&'static str] {70
match table {71
"gpt" => &["legacy_boot", "esp", "bios-boot", "lvm", "raid", "swap", "basic-data"],72
"dos" => &["boot", "esp", "lvm", "raid", "swap"],73
_ => &[],74
}75
}77
fn fail(code: &str, message: impl Into<String>) -> CoreError {78
CoreError::new(&format!("DISKS_{code}"), message)79
}81
fn is_ext(fstype: &Value) -> bool {82
fstype.as_str().is_some_and(|f| EXT.is_match(f))83
}85
pub fn part_path(disk_name: &str, number_value: &Value) -> String {86
let p = if disk_name.ends_with(|c: char| c.is_ascii_digit()) { "p" } else { "" };87
format!("/dev/{disk_name}{p}{}", js::arg(number_value))88
}90
/// `Math.max`/`Math.min` propagate NaN.91
fn jmax(a: f64, b: f64) -> f64 {92
if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) }93
}94
fn jmin(a: f64, b: f64) -> f64 {95
if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) }96
}97
fn align_up(value: f64, align: f64) -> f64 {98
(value / align).ceil() * align99
}100
fn align_down(value: f64, align: f64) -> f64 {101
(value / align).floor() * align102
}103
fn safe_integer(value: f64) -> bool {104
value.is_finite() && value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0105
}106
fn ceil_mib(value: f64) -> String {107
num_string((value / MIB).ceil())108
}110
// ---------- operations ----------112
#[derive(Debug, Clone)]113
pub enum Size {114
Max,115
Bytes(f64),116
}118
#[derive(Debug, Clone, Default)]119
pub struct Op {120
pub kind: String,121
pub strings: HashMap<&'static str, String>,122
pub number: Option<f64>,123
pub start: Option<f64>,124
pub size: Option<Size>,125
pub enabled: Option<bool>,126
pub repair: Option<bool>,127
pub auto: Option<bool>,128
pub advanced: Option<bool>,129
}131
const STRING_KEYS: [&str; 9] = ["disk", "device", "label", "name", "fs", "flag", "mountpoint", "lv", "typeFlag"];133
impl Op {134
fn s(&self, key: &str) -> Option<&str> {135
self.strings.get(key).map(String::as_str)136
}137
fn is_max(&self) -> bool {138
matches!(self.size, Some(Size::Max))139
}140
fn size_number(&self) -> f64 {141
match self.size {142
Some(Size::Bytes(n)) => n,143
Some(Size::Max) => f64::NAN,144
None => f64::NAN,145
}146
}148
pub fn to_value(&self) -> Value {149
let mut map = Map::new();150
map.insert("type".into(), json!(self.kind));151
for key in STRING_KEYS {152
if let Some(v) = self.strings.get(key) {153
map.insert(key.into(), json!(v));154
}155
}156
if let Some(n) = self.number {157
map.insert("number".into(), num(n));158
}159
if let Some(n) = self.start {160
map.insert("start".into(), num(n));161
}162
match &self.size {163
Some(Size::Max) => {164
map.insert("size".into(), json!("max"));165
}166
Some(Size::Bytes(n)) => {167
map.insert("size".into(), num(*n));168
}169
None => {}170
}171
for (key, value) in [("enabled", self.enabled), ("repair", self.repair), ("auto", self.auto), ("advanced", self.advanced)] {172
if let Some(v) = value {173
map.insert(key.into(), json!(v));174
}175
}176
Value::Object(map)177
}178
}180
pub fn normalize_operation(input: &Value) -> Result<Op, CoreError> {181
if !(input.is_object() || input.is_array()) {182
return Err(fail("ARGUMENTS_INVALID", "Invalid operation."));183
}184
let kind = js::string(input.get("type"));185
if !OPERATION_TYPES.contains(&kind.as_str()) {186
return Err(fail("ARGUMENTS_INVALID", format!("Unknown operation {kind}.")));187
}188
let mut op = Op { kind, ..Default::default() };189
for key in STRING_KEYS {190
match input.get(key) {191
None | Some(Value::Null) => {}192
Some(Value::String(s)) if s.is_empty() => {}193
Some(v) => {194
op.strings.insert(key, js::cut(&js::string(Some(v)), 200));195
}196
}197
}198
op.number = input.get("number").map(number);199
op.start = input.get("start").map(number);200
op.size = input.get("size").map(|v| if v == "max" { Size::Max } else { Size::Bytes(number(v)) });201
let flag = |key: &str| input.get(key).map(|v| *v == true);202
op.enabled = flag("enabled");203
op.repair = flag("repair");204
op.auto = flag("auto");205
op.advanced = flag("advanced");206
Ok(op)207
}209
// ---------- simulation state ----------211
struct Sim<'a> {212
inventory: &'a Value,213
disks: IndexMap<String, Value>,214
arena: Vec<Value>,215
nodes: IndexMap<String, usize>,216
by_path: HashMap<String, usize>,217
lvm: Value,218
}220
impl<'a> Sim<'a> {221
fn new(inventory: &'a Value) -> Self {222
let mut sim = Sim { inventory, disks: IndexMap::new(), arena: Vec::new(), nodes: IndexMap::new(), by_path: HashMap::new(), lvm: Value::Null };223
sim.lvm = if truthy(&inventory["lvm"]) { inventory["lvm"].clone() } else { json!({ "pvs": [], "vgs": [], "lvs": [] }) };224
for disk in inventory["disks"].as_array().cloned().unwrap_or_default() {225
sim.disks.insert(js::string(disk.get("name")), disk);226
}227
for node in inventory["nodes"].as_array().cloned().unwrap_or_default() {228
sim.add_node(node);229
}230
sim231
}233
fn add_node(&mut self, node: Value) -> usize {234
let index = self.arena.len();235
let kname = js::string(node.get("kname"));236
let mut paths = vec![node["path"].clone(), json!(format!("/dev/{kname}")), node.get("displayPath").cloned().unwrap_or(Value::Null)];237
paths.retain(truthy);238
self.arena.push(node);239
self.nodes.insert(kname, index);240
for path in paths {241
self.by_path.insert(js::string(Some(&path)), index);242
}243
index244
}246
fn node(&self, kname: &str) -> Option<usize> {247
self.nodes.get(kname).copied()248
}250
fn node_for(&self, device: Option<&str>) -> Result<usize, CoreError> {251
let device = device.unwrap_or("");252
self.by_path.get(device).copied().ok_or_else(|| fail("DEVICE_NOT_FOUND", format!("{device} was not found in the current inventory.")))253
}255
fn disk_for(&self, name: Option<&str>) -> Result<String, CoreError> {256
let raw = name.unwrap_or("");257
let key = raw.strip_prefix("/dev/").unwrap_or(raw);258
if self.disks.contains_key(key) {259
Ok(key.to_string())260
} else {261
Err(fail("DEVICE_NOT_FOUND", format!("Disk {} was not found in the current inventory.", name.unwrap_or("undefined"))))262
}263
}265
fn disk(&self, name: &str) -> &Value {266
&self.disks[name]267
}269
fn disk_mut(&mut self, name: &str) -> &mut Value {270
self.disks.get_mut(name).unwrap()271
}273
fn part_index(&self, disk: &str, number_value: Option<f64>) -> Result<usize, CoreError> {274
let n = number_value.unwrap_or(f64::NAN);275
let d = self.disk(disk);276
d["partitions"].as_array().and_then(|parts| parts.iter().position(|p| number(&p["number"]) == n && !p["number"].is_null())).ok_or_else(|| {277
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"))))278
})279
}281
fn part(&self, disk: &str, index: usize) -> &Value {282
&self.disks[disk]["partitions"][index]283
}285
fn part_mut(&mut self, disk: &str, index: usize) -> &mut Value {286
&mut self.disks.get_mut(disk).unwrap()["partitions"][index]287
}289
fn part_node(&self, disk: &str, part: &Value) -> Option<usize> {290
if let Some(kernel) = part["kernel"].as_str().filter(|k| !k.is_empty()) {291
if let Some(i) = self.node(kernel) {292
return Some(i);293
}294
}295
self.by_path.get(&part_path(disk, &part["number"])).copied()296
}298
fn display(&self, index: usize) -> String {299
let node = &self.arena[index];300
js::string(node.get("displayPath").filter(|v| truthy(v)).or(node.get("path")))301
}303
fn in_use(&self, index: Option<usize>) -> Vec<String> {304
let Some(index) = index else { return Vec::new() };305
let node = &self.arena[index];306
let mut reasons = Vec::new();307
let points: Vec<String> = node["mountpoints"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default();308
if !points.is_empty() {309
reasons.push(format!("mounted at {}", points.join(", ")));310
}311
if truthy(&node["swapActive"]) {312
reasons.push("active swap".into());313
}314
let children: Vec<String> = node["children"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default();315
if !children.is_empty() {316
let names: Vec<String> = children317
.iter()318
.map(|name| match self.node(name) {319
Some(i) => {320
let n = &self.arena[i];321
if truthy(&n["displayPath"]) { js::string(Some(&n["displayPath"])) } else if truthy(&n["path"]) { js::string(Some(&n["path"])) } else { name.clone() }322
}323
None => name.clone(),324
})325
.collect();326
reasons.push(format!("used by {}", names.join(", ")));327
}328
if node["fstype"] == "zfs_member" {329
reasons.push("ZFS pool member".into());330
}331
if node["fstype"] == "linux_raid_member" {332
reasons.push("RAID member".into());333
}334
reasons335
}337
fn require_free(&self, index: Option<usize>, code: &str) -> Result<(), CoreError> {338
let reasons = self.in_use(index);339
if let (false, Some(i)) = (reasons.is_empty(), index) {340
return Err(fail(code, format!("{} is in use ({}). CH-J never unmounts, deactivates or closes it automatically.", self.display(i), reasons.join("; "))));341
}342
Ok(())343
}345
fn refresh_segments(&mut self, disk: &str) {346
let d = self.disk_mut(disk);347
if !truthy(&d["table"]) {348
d["segments"] = json!([]);349
return;350
}351
let mut parts = d["partitions"].as_array().cloned().unwrap_or_default();352
parts.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal));353
let segments = build_segments(&parts, number(&d["table"]["firstUsable"]), number(&d["table"]["lastUsable"]), number(&d["alignSectors"]), number(&d["logicalSectorSize"]), number(&d["table"]["lastUsableNow"]));354
d["partitions"] = Value::Array(parts);355
d["segments"] = Value::Array(segments);356
}358
/// The free space directly after a partition (up to the next partition or the end of the usable area).359
fn boundary_after(&self, disk: &str, part: &Value) -> f64 {360
let d = self.disk(disk);361
let start = number(&part["start"]);362
let mut next: Vec<&Value> = d["partitions"].as_array().unwrap().iter().filter(|e| e["logical"] != true && number(&e["start"]) > start).collect();363
next.sort_by(|a, b| number(&a["start"]).partial_cmp(&number(&b["start"])).unwrap_or(std::cmp::Ordering::Equal));364
match next.first() {365
Some(n) => number(&n["start"]) - 1.0,366
None => number(&d["table"]["lastUsable"]),367
}368
}370
fn need_tool(&self, tool: &str, purpose: &str) -> Result<(), CoreError> {371
if !truthy(&self.inventory["tools"][tool]) {372
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));373
}374
Ok(())375
}377
fn disk_of_node(&self, index: usize) -> Option<String> {378
let mut current = Some(index);379
for _ in 0..16 {380
let i = current?;381
let node = &self.arena[i];382
if node["kind"] == "part" {383
let parent = js::string(node.get("parent"));384
return self.disks.contains_key(&parent).then_some(parent);385
}386
if node["kind"] == "disk" || node["kind"] == "loop" {387
let kname = js::string(node.get("kname"));388
return self.disks.contains_key(&kname).then_some(kname);389
}390
current = node["parents"].get(0).and_then(|p| p.as_str()).and_then(|p| self.node(p));391
}392
None393
}395
fn pv_index(&self, index: usize) -> Option<usize> {396
let node = &self.arena[index];397
let path = js::string(node.get("path"));398
let alt = format!("/dev/{}", js::string(node.get("kname")));399
self.lvm["pvs"].as_array().and_then(|pvs| pvs.iter().position(|pv| pv["path"] == path.as_str() || pv["path"] == alt.as_str()))400
}402
fn vg_index(&self, name: &Value) -> Option<usize> {403
self.lvm["vgs"].as_array().and_then(|vgs| vgs.iter().position(|vg| vg["name"] == *name))404
}406
fn lv_node(&self, lv: &Value) -> Option<usize> {407
self.by_path.get(&js::string(lv.get("dmPath"))).or_else(|| self.by_path.get(&js::string(lv.get("path")))).copied()408
}410
fn sync_part(&mut self, index: usize) {411
let node = self.arena[index].clone();412
if node["kind"] != "part" {413
return;414
}415
let parent = js::string(node.get("parent"));416
let Some(disk) = self.disks.get_mut(&parent) else { return };417
let Some(parts) = disk["partitions"].as_array_mut() else { return };418
if let Some(part) = parts.iter_mut().find(|e| (e["number"] == node["partition"]) || (!e["kernel"].is_null() && e["kernel"] == node["kname"])) {419
for key in ["fstype", "label", "mountpoints", "usage"] {420
part[key] = node.get(key).cloned().unwrap_or(Value::Null);421
}422
}423
}424
}426
// ---------- plan context ----------428
#[derive(Default)]429
struct Ctx {430
steps: Vec<Value>,431
notes: Vec<Value>,432
verify_disks: Vec<String>,433
backup_disks: Vec<String>,434
verify_sizes: Vec<(String, Value)>,435
relocated: Vec<String>,436
data_loss: bool,437
offline: Vec<String>,438
may_reboot: bool,439
current_operation: usize,440
}442
fn push_unique(list: &mut Vec<String>, value: String) {443
if !list.contains(&value) {444
list.push(value);445
}446
}448
fn step(ctx: &mut Ctx, name: &str, args: Vec<Value>, kind: &str, destructive: bool) -> Result<(), CoreError> {449
let values: Vec<String> = args.iter().map(js::arg).collect();450
if values.iter().any(|v| !STEP_ARG.is_match(v)) {451
return Err(fail("ARGUMENTS_INVALID", format!("Invalid step argument for {name}.")));452
}453
ctx.steps.push(json!({ "name": name, "args": values, "operation": ctx.current_operation, "kind": kind, "destructive": destructive }));454
Ok(())455
}457
fn touch_disk(sim: &Sim, ctx: &mut Ctx, disk: &str, backup: bool) -> Result<(), CoreError> {458
let d = sim.disk(disk);459
if truthy(&d["protected"]) && backup {460
return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage; CH-J does not change its partition table.", js::string(d.get("path")))));461
}462
push_unique(&mut ctx.verify_disks, disk.to_string());463
if backup {464
push_unique(&mut ctx.backup_disks, disk.to_string());465
}466
Ok(())467
}469
fn verify_device(sim: &Sim, ctx: &mut Ctx, index: usize) -> Result<(), CoreError> {470
let node = &sim.arena[index];471
if node["kind"] == "part" {472
let parent = js::string(node.get("parent"));473
if sim.disks.contains_key(&parent) {474
touch_disk(sim, ctx, &parent, false)?;475
}476
return Ok(());477
}478
let kname = &node["kname"];479
if let Some(original) = sim.inventory["nodes"].as_array().and_then(|n| n.iter().find(|e| e["kname"] == *kname)) {480
let path = js::string(original.get("path"));481
if !ctx.verify_sizes.iter().any(|(p, _)| *p == path) {482
ctx.verify_sizes.push((path, original["size"].clone()));483
}484
}485
Ok(())486
}488
/// Boot partitions of the disk that runs the system make the server unbootable when deleted or formatted.489
fn assert_not_boot_partition(disk: Option<&Value>, part: Option<&Value>) -> Result<(), CoreError> {490
let (Some(disk), Some(part)) = (disk, part) else { return Ok(()) };491
let type_name = part["typeName"].as_str().unwrap_or("");492
if truthy(&disk["system"]) && (type_name == "bios-boot" || type_name == "esp") {493
let label = if truthy(&part["path"]) { js::string(Some(&part["path"])) } else { format!("Partition {}", js::arg(&part["number"])) };494
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" })));495
}496
Ok(())497
}499
fn assert_not_protected(sim: &Sim, index: usize, action: &str) -> Result<(), CoreError> {500
let disk = sim.disk_of_node(index);501
let node = &sim.arena[index];502
if disk.is_some_and(|d| truthy(&sim.disk(&d)["protected"])) || truthy(&node["lvm"]["pveGuest"]) {503
return Err(fail("PROTECTED_DEVICE", format!("{} belongs to Proxmox host or guest storage; CH-J does not {action} it.", sim.display(index))));504
}505
Ok(())506
}508
fn validate_label(fstype: &str, label: &str) -> Result<(), CoreError> {509
let limit = label_limit(fstype);510
if !LABEL_PATTERN.is_match(label) || label.encode_utf16().count() > limit {511
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.")));512
}513
Ok(())514
}516
fn relocate(sim: &mut Sim, ctx: &mut Ctx, disk: &str) -> Result<(), CoreError> {517
if ctx.relocated.iter().any(|d| d == disk) {518
return Ok(());519
}520
ctx.relocated.push(disk.to_string());521
touch_disk(sim, ctx, disk, true)?;522
let path = sim.disk(disk)["path"].clone();523
step(ctx, "gpt-relocate", vec![path], "partition", false)?;524
let d = sim.disk_mut(disk);525
d["table"]["lastUsableNow"] = d["table"]["lastUsable"].clone();526
d["table"]["gptBackupMisplaced"] = json!(false);527
Ok(())528
}530
// ---------- content chains (grow outward from the partition, shrink inward) ----------532
fn grow_content(sim: &mut Sim, ctx: &mut Ctx, index: Option<usize>, new_bytes: f64, auto_lv: bool) -> Result<(), CoreError> {533
let Some(index) = index else { return Ok(()) };534
let node = sim.arena[index].clone();535
let fstype = node["fstype"].clone();536
let path = node["path"].clone();537
let path_s = js::string(Some(&path));538
let mounted = node["mountpoints"].as_array().is_some_and(|a| !a.is_empty());539
if !truthy(&fstype) {540
sim.arena[index]["size"] = num(new_bytes);541
return Ok(());542
}543
let fs = fstype.as_str().unwrap_or("");544
if is_ext(&fstype) {545
sim.need_tool("resize2fs", "growing ext file systems")?;546
step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?;547
} else if fs == "xfs" {548
sim.need_tool("xfs_growfs", "growing XFS")?;549
if !mounted {550
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).")));551
}552
step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?;553
} else if fs == "btrfs" {554
sim.need_tool("btrfs", "growing Btrfs")?;555
if !mounted {556
return Err(fail("BTRFS_NEEDS_MOUNT", format!("Btrfs is resized while mounted; mount {path_s} first.")));557
}558
step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?;559
} else if fs == "ntfs" {560
sim.need_tool("ntfsresize", "growing NTFS")?;561
sim.require_free(Some(index), "NTFS_MOUNTED")?;562
step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?;563
} else if fs == "swap" {564
sim.need_tool("mkswap", "resizing swap")?;565
if truthy(&node["swapActive"]) {566
return Err(fail("SWAP_ACTIVE", format!("{path_s} is active swap; CH-J never disables swap automatically. Deactivate it yourself first.")));567
}568
step(ctx, "grow-fs", vec![path, fstype.clone()], "filesystem", false)?;569
} else if fs == "crypto_LUKS" {570
let mapping = node["children"].get(0).and_then(|c| c.as_str()).and_then(|c| sim.node(c));571
let Some(mapping) = mapping else {572
ctx.notes.push(json!({ "code": "LUKS_CLOSED", "device": path }));573
sim.arena[index]["size"] = num(new_bytes);574
return Ok(());575
};576
sim.need_tool("cryptsetup", "resizing LUKS mappings")?;577
let m = sim.arena[mapping].clone();578
let key_location = m["crypt"]["keyLocation"].clone();579
if KEYRING.is_match(&if truthy(&key_location) { js::string(Some(&key_location)) } else { String::new() }) {580
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")))));581
}582
if !truthy(&key_location) {583
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")))));584
}585
step(ctx, "luks-resize", vec![m["dmName"].clone()], "luks", false)?;586
let or0 = |v: &Value| if truthy(v) { number(v) } else { 0.0 };587
let header = jmax(0.0, or0(&node["size"]) - or0(&m["size"]));588
let mapping_size = new_bytes - header;589
sim.arena[mapping]["size"] = num(mapping_size);590
grow_content(sim, ctx, Some(mapping), mapping_size, auto_lv)?;591
} else if fs == "LVM2_member" {592
sim.need_tool("pvresize", "resizing LVM physical volumes")?;593
step(ctx, "pv-resize", vec![path], "lvm", false)?;594
let pv = sim.pv_index(index);595
let vg = pv.and_then(|p| sim.vg_index(&sim.lvm["pvs"][p]["vg"].clone()));596
let base = match pv.map(|p| &sim.lvm["pvs"][p]["size"]).filter(|v| !v.is_null()) {597
Some(v) => number(v),598
None => {599
if node["size"].is_null() { new_bytes } else { number(&node["size"]) }600
}601
};602
let growth = new_bytes - base;603
let or0 = |v: &Value| if truthy(v) { number(v) } else { 0.0 };604
if let Some(p) = pv {605
let free = or0(&sim.lvm["pvs"][p]["free"]) + jmax(0.0, growth);606
sim.lvm["pvs"][p]["size"] = num(new_bytes);607
sim.lvm["pvs"][p]["free"] = num(free);608
}609
if let Some(v) = vg {610
let free = or0(&sim.lvm["vgs"][v]["free"]) + jmax(0.0, growth);611
sim.lvm["vgs"][v]["free"] = num(free);612
}613
if auto_lv {614
if let Some(v) = vg {615
match auto_lv_index(sim, v) {616
Some(lv) => extend_lv(sim, ctx, lv, None)?,617
None => ctx.notes.push(json!({ "code": "VG_FREE_LEFT", "vg": sim.lvm["vgs"][v]["name"] })),618
}619
}620
}621
} else if fs == "vfat" {622
return Err(fail("FAT_RESIZE_UNSUPPORTED", "Resizing FAT is not implemented (fatresize is not reliable enough for unattended use)."));623
} else {624
return Err(fail("RESIZE_UNSUPPORTED", format!("Resizing content of type {fs} is not supported.")));625
}626
sim.arena[index]["fsSize"] = num(new_bytes);627
sim.arena[index]["size"] = num(new_bytes);628
Ok(())629
}631
fn shrink_content(sim: &mut Sim, ctx: &mut Ctx, index: Option<usize>, new_bytes: f64) -> Result<(), CoreError> {632
let Some(index) = index else { return Ok(()) };633
let node = sim.arena[index].clone();634
if !truthy(&node["fstype"]) {635
return Ok(());636
}637
let fstype = node["fstype"].clone();638
let fs = fstype.as_str().unwrap_or("");639
let path = node["path"].clone();640
let path_s = js::string(Some(&path));641
let points: Vec<String> = node["mountpoints"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default();642
let used_of = |v: &Value| if v.is_null() { None } else { Some(number(v)) };643
if is_ext(&fstype) {644
sim.need_tool("resize2fs", "shrinking ext file systems")?;645
sim.need_tool("e2fsck", "checking ext file systems")?;646
if !sim.in_use(Some(index)).is_empty() {647
let root = points.iter().any(|p| SYSTEM_MOUNTS.is_match(p));648
return Err(fail(649
"SHRINK_REQUIRES_UNMOUNT",650
if root {651
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(", "))652
} else {653
format!("ext file systems are shrunk only while unmounted; unmount {path_s} first (CH-J never unmounts automatically).")654
},655
));656
}657
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.")))?;658
let reserve = jmax(used / 10.0, 128.0 * MIB);659
if new_bytes < used + reserve {660
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())));661
}662
step(ctx, "shrink-fs", vec![path.clone(), fstype.clone(), num(new_bytes)], "filesystem", true)?;663
push_unique(&mut ctx.offline, path_s);664
} else if fs == "btrfs" {665
sim.need_tool("btrfs", "shrinking Btrfs")?;666
if points.is_empty() {667
return Err(fail("BTRFS_NEEDS_MOUNT", format!("Btrfs is resized while mounted; mount {path_s} first.")));668
}669
let devices = if truthy(&node["btrfs"]["devices"]) { number(&node["btrfs"]["devices"]) } else { 1.0 };670
if devices > 1.0 {671
return Err(fail("BTRFS_MULTI_DEVICE", "Shrinking a multi-device Btrfs is not supported."));672
}673
let used = used_of(node["btrfs"].get("allocated").unwrap_or(&Value::Null))674
.or_else(|| used_of(node["usage"].get("used").unwrap_or(&Value::Null)))675
.ok_or_else(|| fail("USAGE_UNKNOWN", format!("The allocation of {path_s} could not be determined; shrinking is refused.")))?;676
let reserve = jmax(used / 10.0, 256.0 * MIB);677
if new_bytes < used + reserve {678
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())));679
}680
step(ctx, "shrink-fs", vec![path, fstype.clone(), num(new_bytes)], "filesystem", true)?;681
} else if fs == "ntfs" {682
sim.need_tool("ntfsresize", "shrinking NTFS")?;683
sim.require_free(Some(index), "NTFS_MOUNTED")?;684
step(ctx, "shrink-fs", vec![path, fstype.clone(), num(new_bytes)], "filesystem", true)?;685
} else if fs == "swap" {686
if truthy(&node["swapActive"]) {687
return Err(fail("SWAP_ACTIVE", format!("{path_s} is active swap; CH-J never disables swap automatically.")));688
}689
sim.need_tool("mkswap", "resizing swap")?;690
} else if fs == "xfs" {691
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)."));692
} else if fs == "vfat" {693
return Err(fail("FAT_RESIZE_UNSUPPORTED", "Resizing FAT is not implemented."));694
} else if fs == "crypto_LUKS" {695
return Err(fail("LUKS_SHRINK_UNSUPPORTED", "Shrinking LUKS (and the layers inside it) is never done automatically by CH-J."));696
} else if fs == "LVM2_member" {697
return Err(fail("PV_SHRINK_UNSUPPORTED", "Shrinking an LVM physical volume is not automated; move extents and use pvresize manually."));698
} else {699
return Err(fail("RESIZE_UNSUPPORTED", format!("Shrinking content of type {fs} is not supported.")));700
}701
Ok(())702
}704
fn auto_lv_index(sim: &Sim, vg: usize) -> Option<usize> {705
let vg_name = sim.lvm["vgs"][vg]["name"].clone();706
let lvs = sim.lvm["lvs"].as_array()?;707
let candidates: Vec<usize> = lvs708
.iter()709
.enumerate()710
.filter(|(_, lv)| lv["vg"] == vg_name && !truthy(&lv["thinPool"]) && !truthy(&lv["thinVolume"]) && !truthy(&lv["snapshot"]) && !truthy(&lv["pveGuest"]))711
.map(|(i, _)| i)712
.collect();713
let with_fs: Vec<usize> = candidates714
.into_iter()715
.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(""))))716
.collect();717
if with_fs.len() == 1 {718
return Some(with_fs[0]);719
}720
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 == "/"))))721
}723
fn lv_for(sim: &Sim, name: Option<&str>) -> Result<usize, CoreError> {724
let raw = name.unwrap_or("");725
let mut parts = raw.split('/');726
let vg = parts.next().unwrap_or("");727
let lv = parts.next();728
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)));729
match found {730
Some(i) if LV_NAME.is_match(raw) => Ok(i),731
_ => Err(fail("DEVICE_NOT_FOUND", format!("Logical volume {} was not found.", name.unwrap_or("undefined")))),732
}733
}735
/// `size`: `None` = "max".736
fn extend_lv(sim: &mut Sim, ctx: &mut Ctx, lv: usize, size: Option<f64>) -> Result<(), CoreError> {737
sim.need_tool("lvextend", "extending logical volumes")?;738
let entry = sim.lvm["lvs"][lv].clone();739
if truthy(&entry["thinPool"]) || truthy(&entry["thinVolume"]) {740
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."));741
}742
if truthy(&entry["snapshot"]) {743
return Err(fail("LV_SNAPSHOT_UNSUPPORTED", "Snapshots are not resized by CH-J."));744
}745
if truthy(&entry["pveGuest"]) {746
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")))));747
}748
let vg = sim.vg_index(&entry["vg"]);749
let node = sim.lv_node(&entry);750
let vg_value = vg.map(|v| sim.lvm["vgs"][v].clone());751
let extent = vg_value.as_ref().map(|v| &v["extentSize"]).filter(|v| truthy(v)).map(number).unwrap_or(4.0 * MIB);752
let free = vg_value.as_ref().map(|v| &v["free"]).filter(|v| truthy(v)).map(number).unwrap_or(0.0);753
let lv_size = number(&entry["size"]);754
let target = match size {755
None => lv_size + (free / extent).floor() * extent,756
Some(s) => (s / extent).floor() * extent,757
};758
if !target.is_finite() || target <= lv_size {759
return Err(if size.is_none() {760
fail("NO_FREE_SPACE", format!("Volume group {} has no free extents.", js::string(entry.get("vg"))))761
} else {762
fail("SIZE_INVALID", "The new size must be larger than the current size.")763
});764
}765
if target - lv_size > free {766
return Err(fail("NO_FREE_SPACE", format!("Volume group {} does not have enough free space.", js::string(entry.get("vg")))));767
}768
if let Some(n) = node {769
verify_device(sim, ctx, n)?;770
}771
let name = json!(format!("{}/{}", js::string(entry.get("vg")), js::string(entry.get("name"))));772
step(ctx, "lv-extend", vec![name, if size.is_none() { json!("max") } else { num(target) }], "lvm", false)?;773
if let Some(v) = vg {774
let current = number(&sim.lvm["vgs"][v]["free"]);775
sim.lvm["vgs"][v]["free"] = num(current - (target - lv_size));776
}777
sim.lvm["lvs"][lv]["size"] = num(target);778
if let Some(n) = node {779
grow_content(sim, ctx, Some(n), target, false)?;780
}781
Ok(())782
}784
// ---------- operation handlers ----------786
fn handle(sim: &mut Sim, ctx: &mut Ctx, op: &Op) -> Result<(), CoreError> {787
match op.kind.as_str() {788
"create-table" => {789
let disk = sim.disk_for(op.s("disk"))?;790
let label = op.s("label").unwrap_or("");791
if label != "gpt" && label != "dos" {792
return Err(fail("ARGUMENTS_INVALID", "The partition table must be gpt or dos (MBR)."));793
}794
if truthy(&sim.disk(&disk)["protected"]) {795
return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage.", js::string(sim.disk(&disk).get("path")))));796
}797
let node = sim.node(&disk);798
sim.require_free(node, "DEVICE_IN_USE")?;799
let parts = sim.disk(&disk)["partitions"].as_array().cloned().unwrap_or_default();800
for part in &parts {801
sim.require_free(sim.part_node(&disk, part), "DEVICE_IN_USE")?;802
}803
sim.need_tool("sfdisk", "writing partition tables")?;804
touch_disk(sim, ctx, &disk, true)?;805
step(ctx, "create-table", vec![sim.disk(&disk)["path"].clone(), json!(label)], "partition", true)?;806
ctx.data_loss = true;807
let d = sim.disk_mut(&disk);808
let lbs = number(&d["logicalSectorSize"]);809
let sectors = number(&d["sectors"]);810
let tail = (16384.0 / lbs).ceil() + 1.0;811
let last = if label == "gpt" { sectors - tail - 1.0 } else { jmin(sectors - 1.0, 4_294_967_295.0) };812
d["partitions"] = json!([]);813
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) });814
d["wholeDevice"] = Value::Null;815
if let Some(n) = node {816
sim.arena[n]["fstype"] = Value::Null;817
}818
sim.refresh_segments(&disk);819
}820
"create-partition" => {821
let disk = sim.disk_for(op.s("disk"))?;822
let d = sim.disk(&disk).clone();823
let table_type = d["table"]["type"].as_str().unwrap_or("").to_string();824
if !truthy(&d["table"]) || (table_type != "gpt" && table_type != "dos") {825
return Err(fail("NO_PARTITION_TABLE", format!("{} has no supported partition table; create one first.", js::string(d.get("path")))));826
}827
if truthy(&d["protected"]) {828
return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage.", js::string(d.get("path")))));829
}830
sim.need_tool("sfdisk", "creating partitions")?;831
let fs = op.s("fs").unwrap_or("none").to_string();832
if fs != "none" && !FILESYSTEMS.contains(&fs.as_str()) {833
return Err(fail("ARGUMENTS_INVALID", "Unsupported file system."));834
}835
if fs != "none" {836
sim.need_tool(mkfs_tool(&fs), &format!("creating {fs}"))?;837
}838
let align = number(&d["alignSectors"]);839
let start = align_up(jmax(op.start.unwrap_or(f64::NAN), number(&d["table"]["firstUsable"])), align);840
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());841
let Some(segment) = segment else { return Err(fail("NO_FREE_SPACE", "The requested start is not inside unallocated space.")) };842
let seg_end = number(&segment["end"]);843
let mut size = if op.is_max() { seg_end - start + 1.0 } else { op.size_number() };844
if !safe_integer(size) || size < align {845
return Err(fail("SIZE_INVALID", "The partition must be at least 1 MiB."));846
}847
if start + size - 1.0 > seg_end {848
return Err(fail("NO_FREE_SPACE", "The partition does not fit into the unallocated space."));849
}850
if start + size - 1.0 != seg_end {851
size = align_down(size, align);852
}853
let lbs = number(&d["logicalSectorSize"]);854
if fs != "none" && size * lbs < min_fs_bytes(&fs) {855
return Err(fail("SIZE_TOO_SMALL", format!("{fs} needs at least {} MiB.", ceil_mib(min_fs_bytes(&fs)))));856
}857
if table_type == "dos" && start + size - 1.0 > 4_294_967_295.0 {858
return Err(fail("MBR_LIMIT", "MBR cannot address space beyond 2 TiB; use GPT."));859
}860
let used: Vec<f64> = d["partitions"].as_array().unwrap().iter().map(|p| number(&p["number"])).collect();861
let max = if table_type == "dos" { 4.0 } else { 128.0 };862
let mut number_value = 1.0;863
while used.contains(&number_value) && number_value <= max {864
number_value += 1.0;865
}866
if number_value > max {867
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." }));868
}869
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" };870
let type_code = partition_type_code(flag, &table_type);871
let name = op.s("name").map(str::to_string).unwrap_or_else(|| "-".into());872
if name != "-" && (table_type != "gpt" || !LABEL_PATTERN.is_match(&name)) {873
return Err(fail("ARGUMENTS_INVALID", "Partition names (GPT only) may contain A-Z, a-z, 0-9, dot, dash and underscore (max. 36)."));874
}875
let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into());876
if label != "-" && fs != "none" {877
validate_label(&fs, &label)?;878
}879
if d["table"]["gptBackupMisplaced"] == true && start + size - 1.0 > number(&d["table"]["lastUsableNow"]) {880
relocate(sim, ctx, &disk)?;881
}882
touch_disk(sim, ctx, &disk, true)?;883
let type_value = type_code.map(Value::from).unwrap_or(Value::Null);884
step(ctx, "create-part", vec![d["path"].clone(), num(number_value), num(start), num(size), type_value.clone(), json!(name)], "partition", false)?;885
let path = part_path(&disk, &num(number_value));886
let kname = path[5..].to_string();887
if fs != "none" {888
let fs_label = if label == "-" { "-".to_string() } else if fs == "vfat" { label.to_uppercase() } else { label.clone() };889
step(ctx, "mkfs", vec![json!(path), json!(fs), json!(fs_label)], "filesystem", true)?;890
}891
let bytes = size * lbs;892
let fstype = if fs == "none" { Value::Null } else { json!(fs) };893
let label_value = if label == "-" { Value::Null } else { json!(label) };894
let part = json!({895
"number": num(number_value), "start": num(start), "size": num(size), "end": num(start + size - 1.0), "bytes": num(bytes), "type": type_value, "typeName": flag,896
"name": if name == "-" { Value::Null } else { json!(name) }, "kernel": kname, "path": path, "fstype": fstype, "label": label_value,897
"mountpoints": [], "usage": null, "inUse": false, "inUseReasons": [], "systemRoles": [], "children": [], "created": true898
});899
sim.disk_mut(&disk)["partitions"].as_array_mut().unwrap().push(part);900
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 }));901
sim.refresh_segments(&disk);902
if let Some(last) = ctx.steps.last_mut() {903
last["created"] = json!(path);904
}905
}906
"delete-partition" => {907
let disk = sim.disk_for(op.s("disk"))?;908
let pi = sim.part_index(&disk, op.number)?;909
let part = sim.part(&disk, pi).clone();910
if truthy(&part["extended"]) || truthy(&part["logical"]) {911
return Err(fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only."));912
}913
assert_not_boot_partition(Some(sim.disk(&disk)), Some(&part))?;914
let node = sim.part_node(&disk, &part);915
sim.require_free(node, "DEVICE_IN_USE")?;916
sim.need_tool("sfdisk", "deleting partitions")?;917
touch_disk(sim, ctx, &disk, true)?;918
if let Some(n) = node {919
step(ctx, "require-free", vec![sim.arena[n]["path"].clone()], "check", false)?;920
}921
step(ctx, "delete-part", vec![sim.disk(&disk)["path"].clone(), part["number"].clone(), part["start"].clone(), part["size"].clone()], "partition", true)?;922
ctx.data_loss = true;923
sim.disk_mut(&disk)["partitions"].as_array_mut().unwrap().remove(pi);924
if let Some(n) = node {925
let kname = js::string(sim.arena[n].get("kname"));926
let path = js::string(sim.arena[n].get("path"));927
sim.nodes.shift_remove(&kname);928
sim.by_path.remove(&path);929
}930
sim.refresh_segments(&disk);931
}932
"resize-partition" => {933
let disk = sim.disk_for(op.s("disk"))?;934
let pi = sim.part_index(&disk, op.number)?;935
let d = sim.disk(&disk).clone();936
let table_type = d["table"]["type"].as_str().unwrap_or("").to_string();937
if !truthy(&d["table"]) || (table_type != "gpt" && table_type != "dos") {938
return Err(fail("NO_PARTITION_TABLE", "Unsupported partition table."));939
}940
let part = sim.part(&disk, pi).clone();941
if truthy(&part["extended"]) || truthy(&part["logical"]) {942
return Err(fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only."));943
}944
sim.need_tool("sfdisk", "resizing partitions")?;945
let tools = &sim.inventory["tools"];946
if !truthy(&tools["partx"]) && !truthy(&tools["blockdev"]) && !truthy(&tools["partprobe"]) {947
return Err(fail("TOOL_MISSING", "partx, blockdev or partprobe is required to inform the kernel."));948
}949
let node = sim.part_node(&disk, &part);950
let boundary = sim.boundary_after(&disk, &part);951
let lbs = number(&d["logicalSectorSize"]);952
let align = number(&d["alignSectors"]);953
let start = number(&part["start"]);954
let old = number(&part["size"]);955
let size = if op.is_max() {956
boundary - start + 1.0957
} else {958
let s = op.size_number();959
if !safe_integer(s) || s < align {960
return Err(fail("SIZE_INVALID", "Invalid partition size."));961
}962
if start + s - 1.0 != boundary { jmax(align, align_down(start + s, align) - start) } else { s }963
};964
if start + size - 1.0 > boundary {965
return Err(fail("NO_FREE_SPACE", "There is not enough unallocated space directly after the partition."));966
}967
if table_type == "dos" && start + size - 1.0 > 4_294_967_295.0 {968
return Err(fail("MBR_LIMIT", "MBR cannot address space beyond 2 TiB."));969
}970
if size == old {971
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.") });972
}973
let new_bytes = size * lbs;974
let set_part = |sim: &mut Sim| {975
let p = sim.part_mut(&disk, pi);976
p["size"] = num(size);977
p["end"] = num(start + size - 1.0);978
p["bytes"] = num(new_bytes);979
};980
if size > old {981
if d["table"]["gptBackupMisplaced"] == true && start + size - 1.0 > number(&d["table"]["lastUsableNow"]) {982
relocate(sim, ctx, &disk)?;983
}984
touch_disk(sim, ctx, &disk, true)?;985
if node.is_some() && !sim.in_use(node).is_empty() {986
ctx.may_reboot = true;987
}988
step(ctx, "resize-part", vec![d["path"].clone(), part["number"].clone(), part["start"].clone(), part["size"].clone(), num(size)], "partition", false)?;989
set_part(sim);990
if let Some(n) = node {991
sim.arena[n]["size"] = num(new_bytes);992
}993
grow_content(sim, ctx, node, new_bytes, op.auto == Some(true))?;994
} else {995
if truthy(&d["protected"]) {996
return Err(fail("PROTECTED_DEVICE", format!("{} holds Proxmox host storage; shrinking is not offered.", js::string(d.get("path")))));997
}998
if node.is_some() {999
shrink_content(sim, ctx, node, new_bytes)?;1000
}1001
touch_disk(sim, ctx, &disk, true)?;1002
step(ctx, "resize-part", vec![d["path"].clone(), part["number"].clone(), part["start"].clone(), part["size"].clone(), num(size)], "partition", true)?;1003
ctx.data_loss = true;1004
set_part(sim);1005
if let Some(n) = node {1006
sim.arena[n]["size"] = num(new_bytes);1007
let fstype = sim.arena[n]["fstype"].clone();1008
let path = sim.arena[n]["path"].clone();1009
if fstype == "swap" {1010
step(ctx, "grow-fs", vec![path.clone(), json!("swap")], "filesystem", false)?;1011
}1012
if truthy(&fstype) {1013
sim.arena[n]["fsSize"] = num(new_bytes);1014
}1015
if is_ext(&fstype) {1016
step(ctx, "verify-fs", vec![path, fstype], "verify", false)?;1017
}1018
}1019
}1020
sim.refresh_segments(&disk);1021
}1022
"format" => {1023
let n = sim.node_for(op.s("device"))?;1024
let fs = op.s("fs").unwrap_or("").to_string();1025
if !FILESYSTEMS.contains(&fs.as_str()) {1026
return Err(fail("ARGUMENTS_INVALID", "Unsupported file system."));1027
}1028
sim.need_tool(mkfs_tool(&fs), &format!("creating {fs}"))?;1029
let node = sim.arena[n].clone();1030
let kind = node["kind"].as_str().unwrap_or("");1031
let kname = js::string(node.get("kname"));1032
if (kind == "disk" || kind == "loop") && sim.disks.get(&kname).is_some_and(|d| truthy(&d["table"])) {1033
return Err(fail("HAS_PARTITION_TABLE", "This disk has a partition table; format a partition instead."));1034
}1035
if !["part", "lvm", "disk", "loop"].contains(&kind) {1036
return Err(fail("FORMAT_UNSUPPORTED", "Only partitions, logical volumes and unpartitioned disks can be formatted."));1037
}1038
if truthy(&node["lvm"]["thinPool"]) {1039
return Err(fail("LV_THIN_UNSUPPORTED", "Thin pools cannot be formatted."));1040
}1041
assert_not_protected(sim, n, "format")?;1042
if kind == "part" {1043
let parent = js::string(node.get("parent"));1044
let disk = sim.disks.get(&parent);1045
let part = disk.and_then(|d| d["partitions"].as_array().and_then(|p| p.iter().find(|e| e["number"] == node["partition"])));1046
assert_not_boot_partition(disk, part)?;1047
}1048
sim.require_free(Some(n), "DEVICE_IN_USE")?;1049
let size = if truthy(&node["size"]) { number(&node["size"]) } else { 0.0 };1050
if size < min_fs_bytes(&fs) {1051
return Err(fail("SIZE_TOO_SMALL", format!("{fs} needs at least {} MiB.", ceil_mib(min_fs_bytes(&fs)))));1052
}1053
let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into());1054
if label != "-" {1055
validate_label(&fs, &label)?;1056
}1057
verify_device(sim, ctx, n)?;1058
let fs_label = if label == "-" { "-".to_string() } else if fs == "vfat" { label.to_uppercase() } else { label.clone() };1059
step(ctx, "mkfs", vec![node["path"].clone(), json!(fs), json!(fs_label)], "filesystem", true)?;1060
ctx.data_loss = true;1061
let target = &mut sim.arena[n];1062
target["fstype"] = json!(fs);1063
target["label"] = if label == "-" { Value::Null } else { json!(label) };1064
target["usage"] = Value::Null;1065
target["fsSize"] = node.get("size").cloned().unwrap_or(Value::Null);1066
target["uuid"] = Value::Null;1067
sim.sync_part(n);1068
}1069
"set-label" => {1070
let n = sim.node_for(op.s("device"))?;1071
let node = sim.arena[n].clone();1072
let fstype = node["fstype"].as_str().unwrap_or("").to_string();1073
let tool = match fstype.as_str() {1074
"ext2" | "ext3" | "ext4" => "e2label",1075
"xfs" => "xfs_admin",1076
"btrfs" => "btrfs",1077
"vfat" => "fatlabel",1078
"ntfs" => "ntfslabel",1079
"swap" => "swaplabel",1080
_ => return Err(fail("LABEL_UNSUPPORTED", format!("Labels of {} cannot be changed.", if fstype.is_empty() { "this device" } else { &fstype }))),1081
};1082
sim.need_tool(tool, "changing labels")?;1083
let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into());1084
if label != "-" {1085
validate_label(&fstype, &label)?;1086
}1087
if label == "-" && fstype == "vfat" {1088
return Err(fail("ARGUMENTS_INVALID", "Enter a FAT label."));1089
}1090
if ["xfs", "vfat", "ntfs"].contains(&fstype.as_str()) {1091
sim.require_free(Some(n), "LABEL_REQUIRES_UNMOUNT")?;1092
}1093
verify_device(sim, ctx, n)?;1094
let fs_label = if label == "-" { "-".to_string() } else if fstype == "vfat" { label.to_uppercase() } else { label.clone() };1095
step(ctx, "set-label", vec![node["path"].clone(), json!(fstype), json!(fs_label)], "filesystem", false)?;1096
sim.arena[n]["label"] = if label == "-" { Value::Null } else { json!(label) };1097
sim.sync_part(n);1098
}1099
"set-name" => {1100
let disk = sim.disk_for(op.s("disk"))?;1101
let pi = sim.part_index(&disk, op.number)?;1102
if sim.disk(&disk)["table"]["type"] != "gpt" {1103
return Err(fail("NAME_UNSUPPORTED", "Partition names exist only in GPT."));1104
}1105
let name = op.s("name").map(str::to_string).unwrap_or_else(|| "-".into());1106
if name != "-" && !LABEL_PATTERN.is_match(&name) {1107
return Err(fail("ARGUMENTS_INVALID", "Partition names may contain A-Z, a-z, 0-9, dot, dash and underscore (max. 36)."));1108
}1109
sim.need_tool("sfdisk", "naming partitions")?;1110
touch_disk(sim, ctx, &disk, true)?;1111
let number_value = sim.part(&disk, pi)["number"].clone();1112
step(ctx, "set-name", vec![sim.disk(&disk)["path"].clone(), number_value, json!(name)], "partition", false)?;1113
sim.part_mut(&disk, pi)["name"] = if name == "-" { Value::Null } else { json!(name) };1114
}1115
"set-flag" => {1116
let disk = sim.disk_for(op.s("disk"))?;1117
let pi = sim.part_index(&disk, op.number)?;1118
let d = sim.disk(&disk).clone();1119
let table_type = d["table"]["type"].as_str().unwrap_or("").to_string();1120
let flag = op.s("flag").unwrap_or("").to_string();1121
if table_type.is_empty() || !flags(&table_type).contains(&flag.as_str()) {1122
return Err(fail("FLAG_UNSUPPORTED", format!("The flag {} is not supported for this partition table.", op.s("flag").unwrap_or("undefined"))));1123
}1124
let part = sim.part(&disk, pi).clone();1125
if truthy(&part["extended"]) || truthy(&part["logical"]) {1126
return Err(fail("EXTENDED_UNSUPPORTED", "Extended and logical MBR partitions are shown read-only."));1127
}1128
let node = sim.part_node(&disk, &part);1129
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!([]));1130
if roles.as_array().is_some_and(|r| !r.is_empty()) && flag != "boot" && flag != "legacy_boot" {1131
return Err(fail("SYSTEM_PARTITION", "The partition is used by the running system; its type is not changed by CH-J."));1132
}1133
sim.need_tool("sfdisk", "changing partition flags")?;1134
touch_disk(sim, ctx, &disk, true)?;1135
let enabled = op.enabled == Some(true);1136
if flag == "boot" {1137
let mut list: Vec<f64> = d["partitions"].as_array().unwrap().iter().filter(|e| truthy(&e["bootable"])).map(|e| number(&e["number"])).collect();1138
let own = number(&part["number"]);1139
list.retain(|n| *n != own);1140
if enabled {1141
list.push(own);1142
}1143
list.sort_by(|a, b| a.partial_cmp(b).unwrap());1144
list.dedup();1145
let value = if list.is_empty() { "-".to_string() } else { list.iter().map(|n| num_string(*n)).collect::<Vec<_>>().join(",") };1146
step(ctx, "set-boot", vec![d["path"].clone(), json!(value)], "partition", false)?;1147
sim.part_mut(&disk, pi)["bootable"] = json!(enabled);1148
} else if flag == "legacy_boot" {1149
let source = if truthy(&part["attrs"]) { js::string(Some(&part["attrs"])) } else { String::new() };1150
let mut attrs: Vec<String> = Vec::new();1151
for a in ATTR_SPLIT.split(&source).filter(|s| !s.is_empty()) {1152
if !attrs.iter().any(|x| x == a) {1153
attrs.push(a.to_string());1154
}1155
}1156
if enabled {1157
if !attrs.iter().any(|a| a == "LegacyBIOSBootable") {1158
attrs.push("LegacyBIOSBootable".into());1159
}1160
} else {1161
attrs.retain(|a| a != "LegacyBIOSBootable");1162
}1163
let value = if attrs.is_empty() { "-".to_string() } else { attrs.join(",") };1164
if !ATTRS.is_match(&value) {1165
return Err(fail("FLAG_UNSUPPORTED", "The existing GPT attributes cannot be represented safely."));1166
}1167
step(ctx, "set-attrs", vec![d["path"].clone(), part["number"].clone(), json!(value)], "partition", false)?;1168
sim.part_mut(&disk, pi)["attrs"] = if value == "-" { Value::Null } else { json!(value) };1169
} else {1170
let target = if enabled { flag.as_str() } else { "linux" };1171
if !enabled && part["typeName"] != flag.as_str() {1172
return Err(fail("NO_CHANGE", "The partition does not have this type."));1173
}1174
let code = partition_type_code(target, &table_type).ok_or_else(|| fail("FLAG_UNSUPPORTED", "This type is not available for the partition table."))?;1175
step(ctx, "set-type", vec![d["path"].clone(), part["number"].clone(), json!(code)], "partition", false)?;1176
let p = sim.part_mut(&disk, pi);1177
p["type"] = json!(code);1178
p["typeName"] = json!(target);1179
}1180
}1181
"check" => {1182
let n = sim.node_for(op.s("device"))?;1183
let node = sim.arena[n].clone();1184
let fstype = node["fstype"].as_str().unwrap_or("").to_string();1185
let repair = op.repair == Some(true);1186
let supported = match fstype.as_str() {1187
"ext2" | "ext3" | "ext4" => Some("e2fsck"),1188
"xfs" => Some("xfs_repair"),1189
"btrfs" if !repair => Some("btrfs"),1190
"vfat" => Some("fsck.vfat"),1191
_ => None,1192
};1193
let Some(tool) = supported else {1194
return Err(fail(1195
"CHECK_UNSUPPORTED",1196
if repair && fstype == "btrfs" {1197
"btrfs check --repair is not offered: it can make damage worse. Use btrfs scrub or expert recovery.".to_string()1198
} else {1199
format!("Checking {} is not supported.", if fstype.is_empty() { "this device" } else { &fstype })1200
},1201
));1202
};1203
sim.need_tool(tool, "file system checks")?;1204
sim.require_free(Some(n), "CHECK_REQUIRES_UNMOUNT")?;1205
verify_device(sim, ctx, n)?;1206
step(ctx, "fsck", vec![node["path"].clone(), json!(fstype), json!(if repair { "repair" } else { "check" })], "check", repair)?;1207
}1208
"grow-fs" => {1209
let n = sim.node_for(op.s("device"))?;1210
let node = sim.arena[n].clone();1211
if !["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap"].contains(&node["fstype"].as_str().unwrap_or("")) {1212
return Err(fail("RESIZE_UNSUPPORTED", "This content cannot be grown."));1213
}1214
if truthy(&node["fsSize"]) && truthy(&node["size"]) && number(&node["size"]) - number(&node["fsSize"]) < MIB {1215
return Err(fail("NO_CHANGE", "The file system already fills the device."));1216
}1217
verify_device(sim, ctx, n)?;1218
grow_content(sim, ctx, Some(n), number(&node["size"]), false)?;1219
}1220
"mount" => {1221
let n = sim.node_for(op.s("device"))?;1222
let node = sim.arena[n].clone();1223
if !MOUNTABLE.contains(&node["fstype"].as_str().unwrap_or("")) {1224
return Err(fail("MOUNT_UNSUPPORTED", "This device does not contain a mountable file system."));1225
}1226
if node["mountpoints"].as_array().is_some_and(|m| !m.is_empty()) {1227
return Err(fail("ALREADY_MOUNTED", format!("{} is already mounted.", js::string(node.get("path")))));1228
}1229
let mountpoint = op.s("mountpoint").unwrap_or("").to_string();1230
if !MOUNTPOINT_PATTERN.is_match(&mountpoint) || mountpoint.contains("..") {1231
return Err(fail("MOUNTPOINT_INVALID", "Mount points must be below /mnt or /media (letters, digits, dot, dash, underscore)."));1232
}1233
for (_, index) in sim.nodes.iter() {1234
if sim.arena[*index]["mountpoints"].as_array().is_some_and(|m| m.iter().any(|p| p == mountpoint.as_str())) {1235
return Err(fail("MOUNTPOINT_INVALID", format!("{mountpoint} is already in use.")));1236
}1237
}1238
step(ctx, "mount", vec![node["path"].clone(), json!(mountpoint)], "mount", false)?;1239
sim.arena[n]["mountpoints"] = json!([mountpoint]);1240
sim.sync_part(n);1241
}1242
"unmount" => {1243
let n = sim.node_for(op.s("device"))?;1244
let node = sim.arena[n].clone();1245
let points: Vec<String> = node["mountpoints"].as_array().map(|a| a.iter().map(|v| js::string(Some(v))).collect()).unwrap_or_default();1246
if points.is_empty() {1247
return Err(fail("NOT_MOUNTED", format!("{} is not mounted.", js::string(node.get("path")))));1248
}1249
if let Some(system) = points.iter().find(|p| SYSTEM_MOUNTS.is_match(p)) {1250
return Err(fail("SYSTEM_MOUNT", format!("{system} is needed by the running system; CH-J never unmounts it.")));1251
}1252
step(ctx, "unmount", vec![node["path"].clone()], "mount", false)?;1253
sim.arena[n]["mountpoints"] = json!([]);1254
if truthy(&node["usage"]) {1255
sim.arena[n]["usage"]["source"] = json!("superblock");1256
}1257
sim.sync_part(n);1258
}1259
"lv-extend" => {1260
let lv = lv_for(sim, op.s("lv"))?;1261
extend_lv(sim, ctx, lv, if op.is_max() { None } else { Some(op.size_number()) })?;1262
}1263
"lv-reduce" => {1264
if op.advanced != Some(true) {1265
return Err(fail("ADVANCED_CONFIRMATION_REQUIRED", "Shrinking a logical volume is an advanced operation and must be confirmed explicitly."));1266
}1267
let lv = lv_for(sim, op.s("lv"))?;1268
sim.need_tool("lvreduce", "reducing logical volumes")?;1269
let entry = sim.lvm["lvs"][lv].clone();1270
if truthy(&entry["thinPool"]) || truthy(&entry["thinVolume"]) || truthy(&entry["snapshot"]) || truthy(&entry["pveGuest"]) {1271
return Err(fail("LV_THIN_UNSUPPORTED", "Thin, snapshot and Proxmox guest volumes are not reduced by CH-J."));1272
}1273
let n = sim.lv_node(&entry).ok_or_else(|| fail("DEVICE_NOT_FOUND", "The logical volume is not active."))?;1274
let fstype = sim.arena[n]["fstype"].clone();1275
if !is_ext(&fstype) {1276
return Err(if fstype == "xfs" {1277
fail("XFS_SHRINK_UNSUPPORTED", "XFS cannot be shrunk; migrate the data to a new, smaller volume instead.")1278
} else {1279
fail("LV_SHRINK_UNSUPPORTED", "Only logical volumes with an unmounted ext file system can be reduced.")1280
});1281
}1282
let vg = sim.vg_index(&entry["vg"]);1283
let extent = vg.map(|v| &sim.lvm["vgs"][v]["extentSize"]).filter(|v| truthy(v)).map(number).unwrap_or(4.0 * MIB);1284
let target = (op.size_number() / extent).floor() * extent;1285
let lv_size = number(&entry["size"]);1286
if !safe_integer(target) || target <= 0.0 || target >= lv_size {1287
return Err(fail("SIZE_INVALID", "The new size must be smaller than the current size."));1288
}1289
verify_device(sim, ctx, n)?;1290
shrink_content(sim, ctx, Some(n), target)?;1291
step(ctx, "lv-reduce", vec![json!(format!("{}/{}", js::string(entry.get("vg")), js::string(entry.get("name")))), num(target)], "lvm", true)?;1292
let path = sim.arena[n]["path"].clone();1293
step(ctx, "verify-fs", vec![path, fstype], "verify", false)?;1294
ctx.data_loss = true;1295
if let Some(v) = vg {1296
let free = number(&sim.lvm["vgs"][v]["free"]);1297
sim.lvm["vgs"][v]["free"] = num(free + (lv_size - target));1298
}1299
sim.lvm["lvs"][lv]["size"] = num(target);1300
sim.arena[n]["size"] = num(target);1301
sim.arena[n]["fsSize"] = num(target);1302
}1303
"pv-resize" => {1304
let n = sim.node_for(op.s("device"))?;1305
let node = sim.arena[n].clone();1306
if node["fstype"] != "LVM2_member" {1307
return Err(fail("NOT_A_PV", format!("{} is not an LVM physical volume.", js::string(node.get("path")))));1308
}1309
if let Some(pv) = sim.pv_index(n) {1310
let entry = &sim.lvm["pvs"][pv];1311
if number(&entry["deviceSize"]) - number(&entry["size"]) < MIB {1312
return Err(fail("NO_CHANGE", "The physical volume already uses the whole device."));1313
}1314
}1315
verify_device(sim, ctx, n)?;1316
grow_content(sim, ctx, Some(n), number(&node["size"]), op.auto == Some(true))?;1317
}1318
"move-partition" => return Err(fail("OPERATION_DISABLED", "Moving partitions is prepared in the architecture but disabled until a reliable, tested mechanism exists.")),1319
"copy-partition" => return Err(fail("OPERATION_DISABLED", "Copying partitions is prepared in the architecture but disabled until a reliable, tested mechanism exists.")),1320
_ => return Err(fail("ARGUMENTS_INVALID", format!("Unknown operation {}.", op.kind))),1321
}1322
Ok(())1323
}1325
fn disk_view(sim: &Sim, disk: &str) -> Value {1326
let d = sim.disk(disk);1327
let segments: Vec<Value> = d["segments"]1328
.as_array()1329
.cloned()1330
.unwrap_or_default()1331
.into_iter()1332
.map(|mut segment| {1333
if segment["kind"] != "partition" {1334
return segment;1335
}1336
let part = d["partitions"].as_array().and_then(|p| p.iter().find(|e| e["number"] == segment["number"]));1337
let node = part.and_then(|p| sim.part_node(disk, p)).map(|n| &sim.arena[n]);1338
let coalesce = |key: &str| {1339
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)1340
};1341
segment["fstype"] = coalesce("fstype");1342
segment["label"] = coalesce("label");1343
segment["path"] = part.map(|p| p["path"].clone()).filter(truthy).unwrap_or(Value::Null);1344
segment["mountpoints"] = node.and_then(|n| n.get("mountpoints")).filter(|v| truthy(v)).cloned().unwrap_or_else(|| json!([]));1345
segment["created"] = json!(part.is_some_and(|p| p["created"] == true));1346
segment1347
})1348
.collect();1349
json!({1350
"name": d["name"], "path": d["path"], "size": d["size"], "sectors": d["sectors"], "logicalSectorSize": d["logicalSectorSize"],1351
"table": if truthy(&d["table"]) { json!({ "type": d["table"]["type"] }) } else { Value::Null },1352
"segments": segments1353
})1354
}1356
/// `plan_operations(inventory, operations, title)`.1357
pub fn plan_operations(inventory: &Value, operations: &Value, title: Option<&Value>) -> Result<Value, CoreError> {1358
if !truthy(&inventory["supported"]) {1359
return Err(fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers."));1360
}1361
if !truthy(&inventory["privileged"]) {1362
return Err(fail("PRIVILEGE_REQUIRED", "Planning changes needs the privileged (root/sudo) inventory."));1363
}1364
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.")))?;1365
let mut sim = Sim::new(inventory);1366
let names: Vec<String> = sim.disks.keys().cloned().collect();1367
let before: HashMap<String, Value> = names.iter().map(|n| (n.clone(), disk_view(&sim, n))).collect();1368
let mut ctx = Ctx::default();1369
let normalized = list.iter().map(normalize_operation).collect::<Result<Vec<_>, _>>()?;1370
for (index, op) in normalized.iter().enumerate() {1371
ctx.current_operation = index;1372
handle(&mut sim, &mut ctx, op).map_err(|e| e.with("operation", index))?;1373
}1374
if ctx.steps.is_empty() {1375
return Err(fail("NO_CHANGE", "The plan has no steps."));1376
}1377
let original_disk = |name: &str| inventory["disks"].as_array().and_then(|d| d.iter().find(|e| e["name"] == name)).cloned().unwrap_or(Value::Null);1378
// Guard steps run first: the job refuses to touch anything if the layout differs from this plan.1379
let mut guards: Vec<Value> = Vec::new();1380
for name in &ctx.verify_disks {1381
let disk = original_disk(name);1382
let hash = if truthy(&disk["table"]["hash"]) { js::string(Some(&disk["table"]["hash"])) } else { "none".to_string() };1383
guards.push(json!({ "name": "verify-disk", "args": [js::arg(&disk["path"]), js::arg(&disk["sectors"]), hash], "operation": null, "kind": "verify", "destructive": false }));1384
}1385
for (path, size) in &ctx.verify_sizes {1386
guards.push(json!({ "name": "verify-size", "args": [path, js::arg(size)], "operation": null, "kind": "verify", "destructive": false }));1387
}1388
for name in &ctx.backup_disks {1389
guards.push(json!({ "name": "backup-table", "args": [js::arg(&original_disk(name)["path"])], "operation": null, "kind": "backup", "destructive": false }));1390
}1391
// Final verification of every partition the plan created or resized.1392
let mut finals: Vec<Value> = Vec::new();1393
for entry in &ctx.steps {1394
let args = entry["args"].as_array().unwrap();1395
if entry["name"] == "resize-part" {1396
finals.push(json!({ "name": "verify-part", "args": [args[0], args[1], args[2], args[4]], "operation": entry["operation"], "kind": "verify", "destructive": false }));1397
}1398
if entry["name"] == "create-part" {1399
finals.push(json!({ "name": "verify-part", "args": [args[0], args[1], args[2], args[3]], "operation": entry["operation"], "kind": "verify", "destructive": false }));1400
}1401
}1402
let steps: Vec<Value> = guards.into_iter().chain(ctx.steps.iter().cloned()).chain(finals).collect();1403
let plan_text = steps1404
.iter()1405
.map(|s| std::iter::once(js::string(s.get("name"))).chain(s["args"].as_array().unwrap().iter().map(|a| js::string(Some(a)))).collect::<Vec<_>>().join(" "))1406
.collect::<Vec<_>>()1407
.join("\n");1408
let title_value = match title {1409
Some(t) if truthy(t) => js::string(Some(t)),1410
_ => normalized.iter().map(|o| o.kind.clone()).collect::<Vec<_>>().join(", "),1411
};1412
Ok(json!({1413
"operations": normalized.iter().map(Op::to_value).collect::<Vec<_>>(),1414
"steps": steps,1415
"planText": plan_text,1416
"before": ctx.verify_disks.iter().filter_map(|n| before.get(n).cloned()).collect::<Vec<_>>(),1417
"after": ctx.verify_disks.iter().map(|n| disk_view(&sim, n)).collect::<Vec<_>>(),1418
"dataLoss": ctx.data_loss,1419
"destructive": steps.iter().any(|s| s["destructive"] == true),1420
"offline": ctx.offline,1421
"mayRequireReboot": ctx.may_reboot,1422
"backupDisks": ctx.backup_disks.iter().map(|n| original_disk(n)["path"].clone()).collect::<Vec<_>>(),1423
"notes": ctx.notes,1424
"title": js::cut(&title_value, 200)1425
}))1426
}1428
// ---------- automatic expansion ----------1430
pub fn suggestions(inventory: &Value) -> Vec<Value> {1431
if !truthy(&inventory["supported"]) {1432
return Vec::new();1433
}1434
let mut result: Vec<Value> = Vec::new();1435
let mut seen: Vec<String> = Vec::new();1436
let mut attempt = |id: String, kind: &str, target: Value, free: f64, operations: Value, result: &mut Vec<Value>| {1437
if seen.contains(&id) {1438
return;1439
}1440
seen.push(id.clone());1441
let mut entry = json!({ "id": id, "kind": kind, "target": target, "freeBytes": num(free), "operations": operations, "available": false, "blocked": null, "steps": [] });1442
if !truthy(&inventory["privileged"]) {1443
entry["blocked"] = json!({ "code": "DISKS_PRIVILEGE_REQUIRED", "message": "Load the privileged inventory (sudo) to evaluate this expansion." });1444
} else {1445
let title = json!(format!("auto-expand {}", js::string(Some(&target))));1446
match plan_operations(inventory, &operations, Some(&title)) {1447
Ok(plan) => {1448
entry["available"] = json!(true);1449
entry["steps"] = Value::Array(plan["steps"].as_array().unwrap().iter().map(|s| s["name"].clone()).collect());1450
entry["mayRequireReboot"] = plan["mayRequireReboot"].clone();1451
entry["notes"] = plan["notes"].clone();1452
}1453
Err(error) => entry["blocked"] = json!({ "code": if error.code.is_empty() { "DISKS_FAILED".to_string() } else { error.code.clone() }, "message": error.message }),1454
}1455
}1456
result.push(entry);1457
};1458
for disk in inventory["disks"].as_array().cloned().unwrap_or_default() {1459
let table_type = disk["table"]["type"].as_str().unwrap_or("");1460
if !truthy(&disk["table"]) || (table_type != "gpt" && table_type != "dos") {1461
continue;1462
}1463
let segments = disk["segments"].as_array().cloned().unwrap_or_default();1464
for part in disk["partitions"].as_array().cloned().unwrap_or_default() {1465
if truthy(&part["extended"]) || truthy(&part["logical"]) {1466
continue;1467
}1468
let position = segments.iter().position(|s| s["kind"] == "partition" && s["number"] == part["number"]).map(|i| i as i64).unwrap_or(-1);1469
let Some(next) = segments.get((position + 1) as usize) else { continue };1470
if next["kind"] != "free" || number(&next["bytes"]) < AUTO_MIN_BYTES {1471
continue;1472
}1473
if truthy(&part["fstype"]) && ["linux_raid_member", "zfs_member", "vfat"].contains(&part["fstype"].as_str().unwrap_or("")) {1474
continue;1475
}1476
let disk_name = js::string(disk.get("name"));1477
let target = if truthy(&part["path"]) { part["path"].clone() } else { json!(part_path(&disk_name, &part["number"])) };1478
attempt(1479
format!("partition:{disk_name}:{}", js::arg(&part["number"])),1480
"partition",1481
target,1482
number(&next["bytes"]),1483
json!([{ "type": "resize-partition", "disk": disk_name, "number": part["number"], "size": "max", "auto": true }]),1484
&mut result,1485
);1486
}1487
}1488
for node in inventory["nodes"].as_array().cloned().unwrap_or_default() {1489
if !truthy(&node["fsSize"]) || !truthy(&node["size"]) || !["ext2", "ext3", "ext4", "xfs", "btrfs"].contains(&node["fstype"].as_str().unwrap_or("")) {1490
continue;1491
}1492
let free = number(&node["size"]) - number(&node["fsSize"]);1493
if free < AUTO_MIN_BYTES {1494
continue;1495
}1496
let target = if truthy(&node["displayPath"]) { node["displayPath"].clone() } else { node["path"].clone() };1497
attempt(format!("filesystem:{}", js::string(node.get("kname"))), "filesystem", target, free, json!([{ "type": "grow-fs", "device": node["path"] }]), &mut result);1498
}1499
for pv in inventory["lvm"]["pvs"].as_array().cloned().unwrap_or_default() {1500
if !truthy(&pv["deviceSize"]) || number(&pv["deviceSize"]) - number(&pv["size"]) < AUTO_MIN_BYTES {1501
continue;1502
}1503
let free = number(&pv["deviceSize"]) - number(&pv["size"]);1504
attempt(format!("pv:{}", js::string(pv.get("path"))), "pv", pv["path"].clone(), free, json!([{ "type": "pv-resize", "device": pv["path"], "auto": true }]), &mut result);1505
}1506
for vg in inventory["lvm"]["vgs"].as_array().cloned().unwrap_or_default() {1507
if !truthy(&vg["free"]) || number(&vg["free"]) < AUTO_MIN_BYTES {1508
continue;1509
}1510
let sim = Sim::new(inventory);1511
let Some(vg_index) = sim.vg_index(&vg["name"]) else { continue };1512
if let Some(lv) = auto_lv_index(&sim, vg_index) {1513
let entry = &sim.lvm["lvs"][lv];1514
let name = format!("{}/{}", js::string(entry.get("vg")), js::string(entry.get("name")));1515
attempt(format!("lv:{name}"), "lv", json!(name), number(&vg["free"]), json!([{ "type": "lv-extend", "lv": name, "size": "max" }]), &mut result);1516
}1517
}1518
result1519
}SHA-256
3fb3fc2010894f8f6771968f6a07cac298c7bb690d67c07f99ddf3ccbb93bc37CH-J Proprietary Software License 1.14 · تُتاح الشيفرة المصدرية بموجب CH-J Proprietary Software License 1.14. وإتاحتها لا تغيّر شروط الترخيص ولا تمنح حقوقًا إضافية.
SHA-256 للأرشيف
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f