CH-J ToolsSSH के ज़रिए सर्वर प्रबंधन
मेनू
प्रकाशित सोर्स

CH-J Tools

किसी विशेष ऐप्लिकेशन रिलीज़ की डायरेक्टरी और फ़ाइलें देखें।

सोर्स ZIP डाउनलोड करें
CH-J / Server Manager 1704 फ़ाइलें
0.1.2-beta जोड़ा गया: · बिल्ड किया गया:
planner.rs जोड़ा गया: तारीख दर्ज नहीं 76.4 KB · 1519 पंक्तियाँ
कोड फ़ाइल डाउनलोड करें
1//! Turns a queue of user operations (GParted-style) into a validated, simulated plan of
2//! whitelisted remote steps (port of `disks/planner.js`). Each operation is checked against the
3//! layout produced by the operations before it. Changing a partition boundary and resizing the
4//! content are separate steps, ordered by the content type and its layers
5//! (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 the
8//! generated plan text (which the remote helper executes) is identical.
10use super::inventory::{build_segments, partition_type_code, MIB, SYSTEM_MOUNTS};
11use super::js::{self, num, num_string, number, truthy};
12use crate::core::error::CoreError;
13use indexmap::IndexMap;
14use once_cell::sync::Lazy;
15use regex::Regex;
16use serde_json::{json, Map, Value};
17use std::collections::HashMap;
19pub const MAX_OPERATIONS: usize = 32;
20const AUTO_MIN_BYTES: f64 = 16.0 * MIB;
21const FILESYSTEMS: [&str; 6] = ["ext4", "xfs", "btrfs", "vfat", "ntfs", "swap"];
22const 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];
25const MOUNTABLE: [&str; 8] = ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs", "exfat"];
26static LABEL_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._-]{1,36}$").unwrap());
27pub 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());
28static STEP_ARG: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._/:+,=@-]{1,160}$").unwrap());
29static EXT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^ext[234]$").unwrap());
30static LV_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._+-]{1,64}/[A-Za-z0-9._+-]{1,64}$").unwrap());
31static ATTRS: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9,:-]{1,120}$").unwrap());
32static ATTR_SPLIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\s,]+").unwrap());
33static KEYRING: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)keyring").unwrap());
35fn 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 }
46fn 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 }
57fn 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 }
69fn 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 }
77fn fail(code: &str, message: impl Into<String>) -> CoreError {
78 CoreError::new(&format!("DISKS_{code}"), message)
81fn is_ext(fstype: &Value) -> bool {
82 fstype.as_str().is_some_and(|f| EXT.is_match(f))
85pub 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))
90/// `Math.max`/`Math.min` propagate NaN.
91fn jmax(a: f64, b: f64) -> f64 {
92 if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) }
94fn jmin(a: f64, b: f64) -> f64 {
95 if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) }
97fn align_up(value: f64, align: f64) -> f64 {
98 (value / align).ceil() * align
100fn align_down(value: f64, align: f64) -> f64 {
101 (value / align).floor() * align
103fn safe_integer(value: f64) -> bool {
104 value.is_finite() && value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0
106fn ceil_mib(value: f64) -> String {
107 num_string((value / MIB).ceil())
110// ---------- operations ----------
112#[derive(Debug, Clone)]
113pub enum Size {
114 Max,
115 Bytes(f64),
118#[derive(Debug, Clone, Default)]
119pub 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>,
131const STRING_KEYS: [&str; 9] = ["disk", "device", "label", "name", "fs", "flag", "mountpoint", "lv", "typeFlag"];
133impl 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 }
180pub 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)
209// ---------- simulation state ----------
211struct 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,
220impl<'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 sim
231 }
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 index
244 }
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> = children
317 .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 reasons
335 }
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 None
393 }
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 }
426// ---------- plan context ----------
428#[derive(Default)]
429struct 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,
442fn push_unique(list: &mut Vec<String>, value: String) {
443 if !list.contains(&value) {
444 list.push(value);
445 }
448fn 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(())
457fn 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(())
469fn 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(())
488/// Boot partitions of the disk that runs the system make the server unbootable when deleted or formatted.
489fn 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(())
499fn 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(())
508fn 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(())
516fn 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(())
530// ---------- content chains (grow outward from the partition, shrink inward) ----------
532fn 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(())
631fn 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(())
704fn 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> = lvs
708 .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> = candidates
714 .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 == "/"))))
723fn 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 }
735/// `size`: `None` = "max".
736fn 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(())
784// ---------- operation handlers ----------
786fn 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": true
898 });
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.0
957 } 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)?;
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)?;
1012 if truthy(&fstype) {
1013 sim.arena[n]["fsSize"] = num(new_bytes);
1015 if is_ext(&fstype) {
1016 step(ctx, "verify-fs", vec![path, fstype], "verify", false)?;
1020 sim.refresh_segments(&disk);
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."));
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."));
1035 if !["part", "lvm", "disk", "loop"].contains(&kind) {
1036 return Err(fail("FORMAT_UNSUPPORTED", "Only partitions, logical volumes and unpartitioned disks can be formatted."));
1038 if truthy(&node["lvm"]["thinPool"]) {
1039 return Err(fail("LV_THIN_UNSUPPORTED", "Thin pools cannot be formatted."));
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)?;
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)))));
1053 let label = op.s("label").map(str::to_string).unwrap_or_else(|| "-".into());
1054 if label != "-" {
1055 validate_label(&fs, &label)?;
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);
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)?;
1087 if label == "-" && fstype == "vfat" {
1088 return Err(fail("ARGUMENTS_INVALID", "Enter a FAT label."));
1090 if ["xfs", "vfat", "ntfs"].contains(&fstype.as_str()) {
1091 sim.require_free(Some(n), "LABEL_REQUIRES_UNMOUNT")?;
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);
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."));
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)."));
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) };
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"))));
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."));
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."));
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);
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());
1156 if enabled {
1157 if !attrs.iter().any(|a| a == "LegacyBIOSBootable") {
1158 attrs.push("LegacyBIOSBootable".into());
1160 } else {
1161 attrs.retain(|a| a != "LegacyBIOSBootable");
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."));
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."));
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);
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)?;
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."));
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."));
1217 verify_device(sim, ctx, n)?;
1218 grow_content(sim, ctx, Some(n), number(&node["size"]), false)?;
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."));
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")))));
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)."));
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.")));
1238 step(ctx, "mount", vec![node["path"].clone(), json!(mountpoint)], "mount", false)?;
1239 sim.arena[n]["mountpoints"] = json!([mountpoint]);
1240 sim.sync_part(n);
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")))));
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.")));
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");
1257 sim.sync_part(n);
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()) })?;
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."));
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."));
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 });
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."));
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));
1299 sim.lvm["lvs"][lv]["size"] = num(target);
1300 sim.arena[n]["size"] = num(target);
1301 sim.arena[n]["fsSize"] = num(target);
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")))));
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."));
1315 verify_device(sim, ctx, n)?;
1316 grow_content(sim, ctx, Some(n), number(&node["size"]), op.auto == Some(true))?;
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))),
1322 Ok(())
1325fn 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;
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 segment
1347 })
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": segments
1353 })
1356/// `plan_operations(inventory, operations, title)`.
1357pub 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."));
1361 if !truthy(&inventory["privileged"]) {
1362 return Err(fail("PRIVILEGE_REQUIRED", "Planning changes needs the privileged (root/sudo) inventory."));
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))?;
1374 if ctx.steps.is_empty() {
1375 return Err(fail("NO_CHANGE", "The plan has no steps."));
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 }));
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 }));
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 }));
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 }));
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 }));
1402 let steps: Vec<Value> = guards.into_iter().chain(ctx.steps.iter().cloned()).chain(finals).collect();
1403 let plan_text = steps
1404 .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 }))
1428// ---------- automatic expansion ----------
1430pub fn suggestions(inventory: &Value) -> Vec<Value> {
1431 if !truthy(&inventory["supported"]) {
1432 return Vec::new();
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;
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();
1453 Err(error) => entry["blocked"] = json!({ "code": if error.code.is_empty() { "DISKS_FAILED".to_string() } else { error.code.clone() }, "message": error.message }),
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;
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;
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;
1473 if truthy(&part["fstype"]) && ["linux_raid_member", "zfs_member", "vfat"].contains(&part["fstype"].as_str().unwrap_or("")) {
1474 continue;
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 );
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;
1492 let free = number(&node["size"]) - number(&node["fsSize"]);
1493 if free < AUTO_MIN_BYTES {
1494 continue;
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);
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;
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);
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;
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);
1518 result
SHA-2563fb3fc2010894f8f6771968f6a07cac298c7bb690d67c07f99ddf3ccbb93bc37

CH-J Proprietary Software License 1.14 · सोर्स CH-J Proprietary Software License 1.14 के तहत उपलब्ध कराया जाता है। इसकी उपलब्धता लाइसेंस की शर्तें नहीं बदलती और न ही अतिरिक्त अधिकार देती है।

आर्काइव SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
फ़ाइलें