diff --git a/src/cli/args.rs b/src/cli/args.rs index 12a8649..680dabe 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -85,24 +85,24 @@ pub struct Cli { /// Include removable devices (e.g., USB sticks) during discovery (default: false) #[arg(long = "allow-removable", default_value_t = false)] pub allow_removable: bool, - + /// Attempt to mount existing filesystems based on on-disk headers; no partitioning or mkfs. /// Non-destructive mounting flow; uses UUID= sources and policy from config. #[arg(long = "mount-existing", default_value_t = false)] pub mount_existing: bool, - + /// Report current initialized filesystems and mounts without performing changes. #[arg(long = "report-current", default_value_t = false)] pub report_current: bool, - + /// Print detection and planning summary as JSON to stdout (non-default) #[arg(long = "show", default_value_t = false)] pub show: bool, - + /// Write detection/planning JSON report to the given path #[arg(long = "report")] pub report: Option, - + /// Execute destructive actions (apply mode). When false, runs preview-only. #[arg(long = "apply", default_value_t = false)] pub apply: bool, @@ -111,4 +111,4 @@ pub struct Cli { /// Parse CLI arguments (non-interactive; suitable for initramfs). pub fn from_args() -> Cli { Cli::parse() -} \ No newline at end of file +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 70013f6..0d5e60b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -10,4 +10,4 @@ pub mod args; -pub use args::*; \ No newline at end of file +pub use args::*; diff --git a/src/config/loader.rs b/src/config/loader.rs index 3168da2..005e8e5 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -42,8 +42,8 @@ use std::fs; -use crate::{cli::Cli, Error, Result}; use crate::types::*; +use crate::{Error, Result, cli::Cli}; use serde_json::{Map, Value, json}; use tracing::warn; @@ -77,17 +77,17 @@ pub fn load_and_merge(cli: &Cli) -> Result { let cli_overlay = cli_overlay_value(cli); merge_value(&mut merged, cli_overlay); - // 5) Kernel cmdline topology override only when CLI did not provide topology - if cli.topology.is_none() { - if let Some(topo) = kernel_cmdline_topology() { - merge_value(&mut merged, json!({"topology": topo.to_string()})); - } - } + // 5) Kernel cmdline topology override only when CLI did not provide topology + if cli.topology.is_none() { + if let Some(topo) = kernel_cmdline_topology() { + merge_value(&mut merged, json!({"topology": topo.to_string()})); + } + } - // Finalize - let cfg: Config = serde_json::from_value(merged).map_err(|e| Error::Other(e.into()))?; - validate(&cfg)?; - Ok(cfg) + // Finalize + let cfg: Config = serde_json::from_value(merged).map_err(|e| Error::Other(e.into()))?; + validate(&cfg)?; + Ok(cfg) } /// Validate semantic correctness of the configuration. @@ -181,7 +181,9 @@ pub fn validate(cfg: &Config) -> Result<()> { Topology::Bcachefs2Copy => {} Topology::BtrfsRaid1 => { // No enforced requirement here beyond presence of two disks at runtime. - if cfg.filesystem.btrfs.raid_profile != "raid1" && cfg.filesystem.btrfs.raid_profile != "none" { + if cfg.filesystem.btrfs.raid_profile != "raid1" + && cfg.filesystem.btrfs.raid_profile != "none" + { return Err(Error::Validation( "filesystem.btrfs.raid_profile must be 'none' or 'raid1'".into(), )); @@ -203,7 +205,6 @@ fn to_value(t: T) -> Result { serde_json::to_value(t).map_err(|e| Error::Other(e.into())) } - /// Merge b into a in-place: /// - Objects are merged key-by-key (recursively) /// - Arrays and scalars replace @@ -254,7 +255,7 @@ fn cli_overlay_value(cli: &Cli) -> Value { if let Some(t) = cli.topology.as_ref() { root.insert("topology".into(), Value::String(t.to_string())); } - + Value::Object(root) } @@ -270,7 +271,9 @@ pub fn kernel_cmdline_topology() -> Option { val_opt = Some(v); } if let Some(mut val) = val_opt { - if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) { + if (val.starts_with('"') && val.ends_with('"')) + || (val.starts_with('\'') && val.ends_with('\'')) + { val = &val[1..val.len() - 1]; } let val_norm = val.trim(); @@ -365,4 +368,4 @@ fn default_config() -> Config { path: "/run/zosstorage/state.json".into(), }, } -} \ No newline at end of file +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 90617f9..13338e3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,5 +11,5 @@ pub mod loader; -pub use loader::{load_and_merge, validate}; pub use crate::types::*; +pub use loader::{load_and_merge, validate}; diff --git a/src/device/discovery.rs b/src/device/discovery.rs index ed482f7..9466a2e 100644 --- a/src/device/discovery.rs +++ b/src/device/discovery.rs @@ -186,7 +186,10 @@ pub fn discover(filter: &DeviceFilter) -> Result> { discover_with_provider(&provider, filter) } -fn discover_with_provider(provider: &P, filter: &DeviceFilter) -> Result> { +fn discover_with_provider( + provider: &P, + filter: &DeviceFilter, +) -> Result> { let mut candidates = provider.list_block_devices()?; // Probe properties if provider needs to enrich for d in &mut candidates { @@ -210,10 +213,15 @@ fn discover_with_provider(provider: &P, filter: &DeviceFilter .collect(); if filtered.is_empty() { - return Err(Error::Device("no eligible disks found after applying filters".to_string())); + return Err(Error::Device( + "no eligible disks found after applying filters".to_string(), + )); } - debug!("eligible disks: {:?}", filtered.iter().map(|d| &d.path).collect::>()); + debug!( + "eligible disks: {:?}", + filtered.iter().map(|d| &d.path).collect::>() + ); Ok(filtered) } @@ -259,9 +267,10 @@ fn read_disk_size_bytes(name: &str) -> Result { let p = sys_block_path(name).join("size"); let sectors = fs::read_to_string(&p) .map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?; - let sectors: u64 = sectors.trim().parse().map_err(|e| { - Error::Device(format!("parse sectors for {} failed: {}", name, e)) - })?; + let sectors: u64 = sectors + .trim() + .parse() + .map_err(|e| Error::Device(format!("parse sectors for {} failed: {}", name, e)))?; Ok(sectors.saturating_mul(512)) } @@ -287,11 +296,7 @@ fn read_optional_string(p: PathBuf) -> Option { while s.ends_with('\n') || s.ends_with('\r') { s.pop(); } - if s.is_empty() { - None - } else { - Some(s) - } + if s.is_empty() { None } else { Some(s) } } Err(_) => None, } @@ -324,9 +329,27 @@ mod tests { fn filter_by_size_and_include_exclude() { let provider = MockProvider { disks: vec![ - Disk { path: "/dev/sda".into(), size_bytes: 500 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None }, // 500 GiB - Disk { path: "/dev/nvme0n1".into(), size_bytes: 128 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None }, // 128 GiB - Disk { path: "/dev/loop0".into(), size_bytes: 8 * 1024 * 1024 * 1024, rotational: false, model: None, serial: None }, // 8 GiB pseudo (but mock provider supplies it) + Disk { + path: "/dev/sda".into(), + size_bytes: 500 * 1024 * 1024 * 1024, + rotational: true, + model: None, + serial: None, + }, // 500 GiB + Disk { + path: "/dev/nvme0n1".into(), + size_bytes: 128 * 1024 * 1024 * 1024, + rotational: false, + model: None, + serial: None, + }, // 128 GiB + Disk { + path: "/dev/loop0".into(), + size_bytes: 8 * 1024 * 1024 * 1024, + rotational: false, + model: None, + serial: None, + }, // 8 GiB pseudo (but mock provider supplies it) ], }; @@ -346,7 +369,13 @@ mod tests { fn no_match_returns_error() { let provider = MockProvider { disks: vec![ - Disk { path: "/dev/sdb".into(), size_bytes: 50 * 1024 * 1024 * 1024, rotational: true, model: None, serial: None }, // 50 GiB + Disk { + path: "/dev/sdb".into(), + size_bytes: 50 * 1024 * 1024 * 1024, + rotational: true, + model: None, + serial: None, + }, // 50 GiB ], }; @@ -363,4 +392,4 @@ mod tests { other => panic!("unexpected error: {:?}", other), } } -} \ No newline at end of file +} diff --git a/src/device/mod.rs b/src/device/mod.rs index da575e3..e9a8baa 100644 --- a/src/device/mod.rs +++ b/src/device/mod.rs @@ -9,4 +9,4 @@ pub mod discovery; -pub use discovery::*; \ No newline at end of file +pub use discovery::*; diff --git a/src/errors.rs b/src/errors.rs index 07d50ed..fb90683 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -53,4 +53,4 @@ pub enum Error { } /// Crate-wide result alias. -pub type Result = std::result::Result; \ No newline at end of file +pub type Result = std::result::Result; diff --git a/src/fs/mod.rs b/src/fs/mod.rs index 03726e1..c3c526b 100644 --- a/src/fs/mod.rs +++ b/src/fs/mod.rs @@ -9,4 +9,4 @@ pub mod plan; -pub use plan::*; \ No newline at end of file +pub use plan::*; diff --git a/src/fs/plan.rs b/src/fs/plan.rs index 80e6b16..4b67e28 100644 --- a/src/fs/plan.rs +++ b/src/fs/plan.rs @@ -18,21 +18,21 @@ // ext: dry-run mode to emit mkfs commands without executing (future). // REGION: EXTENSION_POINTS-END // - // REGION: SAFETY - // safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced. - // safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS). - // safety: mkfs.btrfs uses -f in apply path immediately after partitioning to handle leftover signatures. - // REGION: SAFETY-END +// REGION: SAFETY +// safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced. +// safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS). +// safety: mkfs.btrfs uses -f in apply path immediately after partitioning to handle leftover signatures. +// REGION: SAFETY-END // // REGION: ERROR_MAPPING // errmap: external mkfs/blkid failures -> crate::Error::Tool with captured stderr. // errmap: planning mismatches -> crate::Error::Filesystem with context. // REGION: ERROR_MAPPING-END // - // REGION: TODO - // todo: bcachefs tuning flags mapping from config (compression/checksum/cache_mode) deferred - // todo: add UUID consistency checks across multi-device filesystems - // REGION: TODO-END +// REGION: TODO +// todo: bcachefs tuning flags mapping from config (compression/checksum/cache_mode) deferred +// todo: add UUID consistency checks across multi-device filesystems +// REGION: TODO-END //! Filesystem planning and creation for zosstorage. //! //! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs) @@ -42,14 +42,13 @@ //! [fn make_filesystems](plan.rs:1). use crate::{ - Result, + Error, Result, + partition::{PartRole, PartitionResult}, types::{Config, Topology}, - partition::{PartitionResult, PartRole}, util::{run_cmd, run_cmd_capture, which_tool}, - Error, }; -use tracing::{debug, warn}; use std::fs; +use tracing::{debug, warn}; /// Filesystem kinds supported by zosstorage. #[derive(Debug, Clone, Copy)] @@ -97,17 +96,14 @@ pub struct FsResult { pub label: String, } - /// Determine which partitions get which filesystem based on topology. - /// - /// Rules: - /// - ESP partitions => Vfat with label from cfg.filesystem.vfat.label (reserved "ZOSBOOT") - /// - Data partitions => Btrfs with label cfg.filesystem.btrfs.label ("ZOSDATA"), unless topology SsdHddBcachefs - /// - SsdHddBcachefs => pair one Cache partition (SSD) with one Data partition (HDD) into one Bcachefs FsSpec with devices [cache, data] and label cfg.filesystem.bcachefs.label ("ZOSDATA") - /// - DualIndependent/BtrfsRaid1 => map each Data partition to its own Btrfs FsSpec (raid profile concerns are handled later during mkfs) -pub fn plan_filesystems( - parts: &[PartitionResult], - cfg: &Config, -) -> Result { +/// Determine which partitions get which filesystem based on topology. +/// +/// Rules: +/// - ESP partitions => Vfat with label from cfg.filesystem.vfat.label (reserved "ZOSBOOT") +/// - Data partitions => Btrfs with label cfg.filesystem.btrfs.label ("ZOSDATA"), unless topology SsdHddBcachefs +/// - SsdHddBcachefs => pair one Cache partition (SSD) with one Data partition (HDD) into one Bcachefs FsSpec with devices [cache, data] and label cfg.filesystem.bcachefs.label ("ZOSDATA") +/// - DualIndependent/BtrfsRaid1 => map each Data partition to its own Btrfs FsSpec (raid profile concerns are handled later during mkfs) +pub fn plan_filesystems(parts: &[PartitionResult], cfg: &Config) -> Result { let mut specs: Vec = Vec::new(); // Always map ESP partitions @@ -122,10 +118,22 @@ pub fn plan_filesystems( match cfg.topology { Topology::SsdHddBcachefs => { // Expect exactly one cache (SSD) and at least one data (HDD). Use the first data for pairing. - let cache = parts.iter().find(|p| matches!(p.role, PartRole::Cache)) - .ok_or_else(|| Error::Filesystem("expected a Cache partition for SsdHddBcachefs topology".to_string()))?; - let data = parts.iter().find(|p| matches!(p.role, PartRole::Data)) - .ok_or_else(|| Error::Filesystem("expected a Data partition for SsdHddBcachefs topology".to_string()))?; + let cache = parts + .iter() + .find(|p| matches!(p.role, PartRole::Cache)) + .ok_or_else(|| { + Error::Filesystem( + "expected a Cache partition for SsdHddBcachefs topology".to_string(), + ) + })?; + let data = parts + .iter() + .find(|p| matches!(p.role, PartRole::Data)) + .ok_or_else(|| { + Error::Filesystem( + "expected a Data partition for SsdHddBcachefs topology".to_string(), + ) + })?; specs.push(FsSpec { kind: FsKind::Bcachefs, @@ -173,8 +181,14 @@ pub fn plan_filesystems( } Topology::BcachefsSingle => { // Single-device bcachefs on the sole Data partition. - let data = parts.iter().find(|p| matches!(p.role, PartRole::Data)) - .ok_or_else(|| Error::Filesystem("expected a Data partition for BcachefsSingle topology".to_string()))?; + let data = parts + .iter() + .find(|p| matches!(p.role, PartRole::Data)) + .ok_or_else(|| { + Error::Filesystem( + "expected a Data partition for BcachefsSingle topology".to_string(), + ) + })?; specs.push(FsSpec { kind: FsKind::Bcachefs, devices: vec![data.device_path.clone()], @@ -194,7 +208,9 @@ pub fn plan_filesystems( } if specs.is_empty() { - return Err(Error::Filesystem("no filesystems to create from provided partitions".to_string())); + return Err(Error::Filesystem( + "no filesystems to create from provided partitions".to_string(), + )); } Ok(FsPlan { specs }) @@ -215,7 +231,9 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result> { let blkid_tool = which_tool("blkid")?; if blkid_tool.is_none() { - return Err(Error::Filesystem("blkid not found in PATH; cannot capture filesystem UUIDs".into())); + return Err(Error::Filesystem( + "blkid not found in PATH; cannot capture filesystem UUIDs".into(), + )); } let blkid = blkid_tool.unwrap(); @@ -248,7 +266,9 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result> { return Err(Error::Filesystem("mkfs.btrfs not found in PATH".into())); }; if spec.devices.is_empty() { - return Err(Error::Filesystem("btrfs requires at least one device".into())); + return Err(Error::Filesystem( + "btrfs requires at least one device".into(), + )); } // mkfs.btrfs -L LABEL [ -m raid1 -d raid1 (when multi-device/raid1) ] dev1 [dev2 ...] let mut args: Vec = vec![mkfs.clone(), "-L".into(), spec.label.clone()]; @@ -288,11 +308,18 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result> { return Err(Error::Filesystem("bcachefs not found in PATH".into())); }; if spec.devices.is_empty() { - return Err(Error::Filesystem("bcachefs requires at least one device".into())); + return Err(Error::Filesystem( + "bcachefs requires at least one device".into(), + )); } // bcachefs format --label LABEL [--replicas=2] dev1 [dev2 ...] // Apply replicas policy for Bcachefs2Copy topology (data+metadata replicas = 2) - let mut args: Vec = vec![mkfs.clone(), "format".into(), "--label".into(), spec.label.clone()]; + let mut args: Vec = vec![ + mkfs.clone(), + "format".into(), + "--label".into(), + spec.label.clone(), + ]; if matches!(cfg.topology, Topology::Bcachefs2Copy) { args.push("--replicas=2".into()); } @@ -318,29 +345,32 @@ pub fn make_filesystems(plan: &FsPlan, cfg: &Config) -> Result> { } fn capture_uuid(blkid: &str, dev: &str) -> Result { - // blkid -o export /dev/... - let out = run_cmd_capture(&[blkid, "-o", "export", dev])?; - let map = parse_blkid_export(&out.stdout); - // Prefer ID_FS_UUID if present, fall back to UUID - if let Some(u) = map.get("ID_FS_UUID") { - return Ok(u.clone()); - } - if let Some(u) = map.get("UUID") { - return Ok(u.clone()); - } - warn!("blkid did not report UUID for {}", dev); - Err(Error::Filesystem(format!("missing UUID in blkid output for {}", dev))) + // blkid -o export /dev/... + let out = run_cmd_capture(&[blkid, "-o", "export", dev])?; + let map = parse_blkid_export(&out.stdout); + // Prefer ID_FS_UUID if present, fall back to UUID + if let Some(u) = map.get("ID_FS_UUID") { + return Ok(u.clone()); + } + if let Some(u) = map.get("UUID") { + return Ok(u.clone()); + } + warn!("blkid did not report UUID for {}", dev); + Err(Error::Filesystem(format!( + "missing UUID in blkid output for {}", + dev + ))) } /// Minimal parser for blkid -o export KEY=VAL lines. fn parse_blkid_export(s: &str) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - for line in s.lines() { - if let Some((k, v)) = line.split_once('=') { - map.insert(k.trim().to_string(), v.trim().to_string()); - } - } - map + let mut map = std::collections::HashMap::new(); + for line in s.lines() { + if let Some((k, v)) = line.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + map } /// Probe existing filesystems on the system and return their identities (kind, uuid, label). @@ -354,13 +384,16 @@ fn parse_blkid_export(s: &str) -> std::collections::HashMap { /// - Vec with at most one entry per filesystem UUID. pub fn probe_existing_filesystems() -> Result> { let Some(blkid) = which_tool("blkid")? else { - return Err(Error::Filesystem("blkid not found in PATH; cannot probe existing filesystems".into())); + return Err(Error::Filesystem( + "blkid not found in PATH; cannot probe existing filesystems".into(), + )); }; let content = fs::read_to_string("/proc/partitions") .map_err(|e| Error::Filesystem(format!("/proc/partitions read error: {}", e)))?; - let mut results_by_uuid: std::collections::HashMap = std::collections::HashMap::new(); + let mut results_by_uuid: std::collections::HashMap = + std::collections::HashMap::new(); for line in content.lines() { let line = line.trim(); @@ -399,11 +432,13 @@ pub fn probe_existing_filesystems() -> Result> { let map = parse_blkid_export(&out.stdout); let ty = map.get("TYPE").cloned().unwrap_or_default(); let label = map - .get("ID_FS_LABEL").cloned() + .get("ID_FS_LABEL") + .cloned() .or_else(|| map.get("LABEL").cloned()) .unwrap_or_default(); let uuid = map - .get("ID_FS_UUID").cloned() + .get("ID_FS_UUID") + .cloned() .or_else(|| map.get("UUID").cloned()); let (kind_opt, expected_label) = match ty.as_str() { @@ -434,13 +469,13 @@ pub fn probe_existing_filesystems() -> Result> { #[cfg(test)] mod tests_parse { - use super::parse_blkid_export; + use super::parse_blkid_export; - #[test] - fn parse_export_ok() { - let s = "ID_FS_UUID=abcd-1234\nUUID=abcd-1234\nTYPE=btrfs\n"; - let m = parse_blkid_export(s); - assert_eq!(m.get("ID_FS_UUID").unwrap(), "abcd-1234"); - assert_eq!(m.get("TYPE").unwrap(), "btrfs"); - } -} \ No newline at end of file + #[test] + fn parse_export_ok() { + let s = "ID_FS_UUID=abcd-1234\nUUID=abcd-1234\nTYPE=btrfs\n"; + let m = parse_blkid_export(s); + assert_eq!(m.get("ID_FS_UUID").unwrap(), "abcd-1234"); + assert_eq!(m.get("TYPE").unwrap(), "btrfs"); + } +} diff --git a/src/idempotency/mod.rs b/src/idempotency/mod.rs index 7696e9c..7dabea2 100644 --- a/src/idempotency/mod.rs +++ b/src/idempotency/mod.rs @@ -28,14 +28,14 @@ //! disks are empty before making any destructive changes. use crate::{ - device::Disk, - report::{StateReport, REPORT_VERSION}, - util::{run_cmd_capture, which_tool}, Error, Result, + device::Disk, + report::{REPORT_VERSION, StateReport}, + util::{run_cmd_capture, which_tool}, }; +use humantime::format_rfc3339; use serde_json::json; use std::{collections::HashMap, fs, path::Path}; -use humantime::format_rfc3339; use tracing::{debug, warn}; /// Return existing state if system is already provisioned; otherwise None. @@ -155,7 +155,10 @@ pub fn is_empty_disk(disk: &Disk) -> Result { // Probe with blkid -p let Some(blkid) = which_tool("blkid")? else { - warn!("blkid not found; conservatively treating {} as not empty", disk.path); + warn!( + "blkid not found; conservatively treating {} as not empty", + disk.path + ); return Ok(false); }; @@ -237,7 +240,11 @@ fn is_partition_of(base: &str, name: &str) -> bool { if name == base { return false; } - let ends_with_digit = base.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false); + let ends_with_digit = base + .chars() + .last() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false); if ends_with_digit { // nvme0n1 -> nvme0n1p1 if name.starts_with(base) { @@ -281,4 +288,4 @@ mod tests { assert!(!is_partition_of("nvme0n1", "nvme0n1")); assert!(!is_partition_of("nvme0n1", "nvme0n2p1")); } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 654552d..e27de18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,20 +1,20 @@ //! Crate root for zosstorage: one-shot disk provisioning utility for initramfs. pub mod cli; -pub mod logging; pub mod config; pub mod device; -pub mod partition; -pub mod fs; -pub mod mount; -pub mod report; -pub mod orchestrator; -pub mod idempotency; -pub mod util; pub mod errors; -pub mod types; // top-level types (moved from config/types.rs for visibility) +pub mod fs; +pub mod idempotency; +pub mod logging; +pub mod mount; +pub mod orchestrator; +pub mod partition; +pub mod report; +pub mod types; +pub mod util; // top-level types (moved from config/types.rs for visibility) pub use errors::{Error, Result}; /// Crate version string from Cargo. -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); \ No newline at end of file +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/logging/mod.rs b/src/logging/mod.rs index 8b031bf..1b7a5d6 100644 --- a/src/logging/mod.rs +++ b/src/logging/mod.rs @@ -36,10 +36,10 @@ use std::fs::OpenOptions; use std::io::{self}; use std::sync::OnceLock; use tracing::Level; +use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::fmt; use tracing_subscriber::prelude::*; use tracing_subscriber::registry::Registry; -use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::util::SubscriberInitExt; /// Logging options resolved from CLI and/or config. @@ -116,21 +116,27 @@ pub fn init_logging(opts: &LogOptions) -> Result<()> { .with(stderr_layer) .with(file_layer) .try_init() - .map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?; + .map_err(|e| { + crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)) + })?; } else { // Fall back to stderr-only if file cannot be opened Registry::default() .with(stderr_layer) .try_init() - .map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?; + .map_err(|e| { + crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)) + })?; } } else { Registry::default() .with(stderr_layer) .try_init() - .map_err(|e| crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)))?; + .map_err(|e| { + crate::Error::Other(anyhow::anyhow!("failed to set global logger: {}", e)) + })?; } let _ = INIT_GUARD.set(()); Ok(()) -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 82dd4c7..23bb279 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,8 @@ fn real_main() -> Result<()> { .with_report_current(cli.report_current) .with_report_path(cli.report.clone()) .with_topology_from_cli(cli.topology.is_some()) - .with_topology_from_cmdline(config::loader::kernel_cmdline_topology().is_some() && cli.topology.is_none()); + .with_topology_from_cmdline( + config::loader::kernel_cmdline_topology().is_some() && cli.topology.is_none(), + ); orchestrator::run(&ctx) } diff --git a/src/mount/mod.rs b/src/mount/mod.rs index 7447a54..935ea06 100644 --- a/src/mount/mod.rs +++ b/src/mount/mod.rs @@ -9,4 +9,4 @@ pub mod ops; -pub use ops::*; \ No newline at end of file +pub use ops::*; diff --git a/src/mount/ops.rs b/src/mount/ops.rs index ff9c926..6f3c691 100644 --- a/src/mount/ops.rs +++ b/src/mount/ops.rs @@ -36,13 +36,13 @@ #![allow(dead_code)] use crate::{ + Error, Result, fs::{FsKind, FsResult}, types::Config, util::{run_cmd, run_cmd_capture, which_tool}, - Error, Result, }; use std::collections::HashMap; -use std::fs::{create_dir_all, File}; +use std::fs::{File, create_dir_all}; use std::io::Write; use std::path::Path; use tracing::info; @@ -120,21 +120,39 @@ fn source_matches_uuid(existing_source: &str, uuid: &str) -> bool { false } +fn disk_of_device(dev: &str) -> Option { + let path = Path::new(dev); + let name = path.file_name()?.to_str()?; + let mut cutoff = name.len(); + while cutoff > 0 && name.as_bytes()[cutoff - 1].is_ascii_digit() { + cutoff -= 1; + } + if cutoff == name.len() { + return Some(dev.to_string()); + } + let mut disk = name[..cutoff].to_string(); + if disk.ends_with('p') { + disk.pop(); + } + let parent = path.parent()?.to_str().unwrap_or("/dev"); + Some(format!("{}/{}", parent, disk)) +} + #[derive(Debug, Clone)] pub struct PlannedMount { - pub uuid: String, // UUID string without prefix - pub target: String, // absolute path - pub fstype: String, // "btrfs" | "bcachefs" - pub options: String, // e.g., "rw,noatime,subvolid=5" + pub uuid: String, // UUID string without prefix + pub target: String, // absolute path + pub fstype: String, // "btrfs" | "bcachefs" + pub options: String, // e.g., "rw,noatime,subvolid=5" } #[derive(Debug, Clone)] pub struct PlannedSubvolMount { - pub uuid: String, // UUID of primary FS - pub name: String, // subvol name (system/etc/modules/vm-meta) - pub target: String, // absolute final target - pub fstype: String, // "btrfs" | "bcachefs" - pub options: String, // e.g., "rw,noatime,subvol=system" + pub uuid: String, // UUID of primary FS + pub name: String, // subvol name (system/etc/modules/vm-meta) + pub target: String, // absolute final target + pub fstype: String, // "btrfs" | "bcachefs" + pub options: String, // e.g., "rw,noatime,subvol=system" } /// Mount plan per policy. @@ -202,11 +220,27 @@ pub fn plan_mounts(fs_results: &[FsResult], _cfg: &Config) -> Result }); } - // Determine primary UUID - let primary_uuid = Some(data[0].uuid.clone()); + let primary = data[0]; + let primary_uuid = Some(primary.uuid.clone()); + let primary_disk = primary.devices.first().and_then(|dev| disk_of_device(dev)); - // Optional ESP (VFAT) mount at /boot - if let Some(esp) = fs_results.iter().find(|r| matches!(r.kind, FsKind::Vfat)) { + let mut chosen_esp: Option<&FsResult> = None; + let mut fallback_esp: Option<&FsResult> = None; + for esp in fs_results.iter().filter(|r| matches!(r.kind, FsKind::Vfat)) { + if fallback_esp.is_none() { + fallback_esp = Some(esp); + } + if let (Some(ref disk), Some(esp_disk)) = ( + primary_disk.as_ref(), + esp.devices.first().and_then(|dev| disk_of_device(dev)), + ) { + if esp_disk == **disk { + chosen_esp = Some(esp); + break; + } + } + } + if let Some(esp) = chosen_esp.or(fallback_esp) { root_mounts.push(PlannedMount { uuid: esp.uuid.clone(), target: BOOT_TARGET.to_string(), @@ -216,7 +250,6 @@ pub fn plan_mounts(fs_results: &[FsResult], _cfg: &Config) -> Result } // Subvol mounts only from primary FS - let primary = data[0]; let mut subvol_mounts: Vec = Vec::new(); let fstype = fstype_str(primary.kind).to_string(); // Option key differs per filesystem: btrfs uses subvol=, bcachefs uses X-mount.subdir= @@ -360,14 +393,18 @@ pub fn apply_mounts(plan: &MountPlan) -> Result> { if !exists { // Create subvolume let subvol_path = format!("{}/{}", root, sm.name); - let args = [btrfs_tool.as_str(), "subvolume", "create", subvol_path.as_str()]; + let args = [ + btrfs_tool.as_str(), + "subvolume", + "create", + subvol_path.as_str(), + ]; run_cmd(&args)?; } } } else if primary_kind == "bcachefs" { - let bcachefs_tool = which_tool("bcachefs")?.ok_or_else(|| { - Error::Mount("required tool 'bcachefs' not found in PATH".into()) - })?; + let bcachefs_tool = which_tool("bcachefs")? + .ok_or_else(|| Error::Mount("required tool 'bcachefs' not found in PATH".into()))?; for sm in &plan.subvol_mounts { if &sm.uuid != primary_uuid { continue; @@ -479,10 +516,7 @@ pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> { let mut lines: Vec = Vec::new(); for m in root_entries.into_iter().chain(subvol_entries.into_iter()) { // m.source already "UUID=..." - let line = format!( - "{} {} {} {} 0 0", - m.source, m.target, m.fstype, m.options - ); + let line = format!("{} {} {} {} 0 0", m.source, m.target, m.fstype, m.options); lines.push(line); } @@ -511,4 +545,4 @@ pub fn maybe_write_fstab(mounts: &[MountResult], cfg: &Config) -> Result<()> { })?; Ok(()) -} \ No newline at end of file +} diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs index e6d7209..e58670f 100644 --- a/src/orchestrator/mod.rs +++ b/src/orchestrator/mod.rs @@ -3,4 +3,4 @@ //! Re-exports the concrete implementation from run.rs to avoid duplicating types/functions. pub mod run; -pub use run::*; \ No newline at end of file +pub use run::*; diff --git a/src/orchestrator/run.rs b/src/orchestrator/run.rs index 59f0a9f..2505113 100644 --- a/src/orchestrator/run.rs +++ b/src/orchestrator/run.rs @@ -43,14 +43,13 @@ //! - Report generation and write use crate::{ - types::{Config, Topology}, + Error, Result, + device::{DeviceFilter, Disk, discover}, + fs as zfs, idempotency, logging::LogOptions, - device::{discover, DeviceFilter, Disk}, - idempotency, partition, report::StateReport, - fs as zfs, - Error, Result, + types::{Config, Topology}, }; use humantime::format_rfc3339; use regex::Regex; @@ -191,9 +190,7 @@ pub fn run(ctx: &Context) -> Result<()> { info!("orchestrator: starting run()"); let selected_modes = - (ctx.mount_existing as u8) + - (ctx.report_current as u8) + - (ctx.apply as u8); + (ctx.mount_existing as u8) + (ctx.report_current as u8) + (ctx.apply as u8); if selected_modes > 1 { return Err(Error::Validation( "choose only one mode: --mount-existing | --report-current | --apply".into(), @@ -242,7 +239,11 @@ fn auto_select_mode(ctx: &Context) -> Result { info!("orchestrator: provisioned state detected; attempting mount-existing flow"); return Ok(AutoSelection { decision: AutoDecision::MountExisting, - fs_results: if fs_results.is_empty() { None } else { Some(fs_results) }, + fs_results: if fs_results.is_empty() { + None + } else { + Some(fs_results) + }, state: Some(state), }); } @@ -330,9 +331,8 @@ fn run_report_current(ctx: &Context) -> Result<()> { println!("{}", summary); if let Some(path) = &ctx.report_path_override { - fs::write(path, summary.to_string()).map_err(|e| { - Error::Report(format!("failed to write report to {}: {}", path, e)) - })?; + fs::write(path, summary.to_string()) + .map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?; info!("orchestrator: wrote report-current to {}", path); } Ok(()) @@ -409,9 +409,8 @@ fn run_mount_existing( println!("{}", summary); } if let Some(path) = &ctx.report_path_override { - fs::write(path, summary.to_string()).map_err(|e| { - Error::Report(format!("failed to write report to {}: {}", path, e)) - })?; + fs::write(path, summary.to_string()) + .map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?; info!("orchestrator: wrote mount-existing report to {}", path); } } @@ -445,7 +444,9 @@ fn run_provisioning( enforce_empty_disks(&disks)?; info!("orchestrator: all target disks verified empty"); } else { - warn!("orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement"); + warn!( + "orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement" + ); } } else if matches!(mode, ProvisioningMode::Apply) { warn!("orchestrator: require_empty_disks=false; proceeding without emptiness enforcement"); @@ -506,7 +507,9 @@ fn run_provisioning( return Ok(()); } - info!("orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)"); + info!( + "orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)" + ); if preview_outputs { let summary = build_summary_json(&disks, &plan, &effective_cfg)?; @@ -514,9 +517,8 @@ fn run_provisioning( println!("{}", summary); } if let Some(path) = &ctx.report_path_override { - fs::write(path, summary.to_string()).map_err(|e| { - Error::Report(format!("failed to write report to {}: {}", path, e)) - })?; + fs::write(path, summary.to_string()) + .map_err(|e| Error::Report(format!("failed to write report to {}: {}", path, e)))?; info!("orchestrator: wrote summary report to {}", path); } } @@ -536,15 +538,13 @@ fn build_device_filter(cfg: &Config) -> Result { let mut exclude = Vec::new(); for pat in &cfg.device_selection.include_patterns { - let re = Regex::new(pat).map_err(|e| { - Error::Validation(format!("invalid include regex '{}': {}", pat, e)) - })?; + let re = Regex::new(pat) + .map_err(|e| Error::Validation(format!("invalid include regex '{}': {}", pat, e)))?; include.push(re); } for pat in &cfg.device_selection.exclude_patterns { - let re = Regex::new(pat).map_err(|e| { - Error::Validation(format!("invalid exclude regex '{}': {}", pat, e)) - })?; + let re = Regex::new(pat) + .map_err(|e| Error::Validation(format!("invalid exclude regex '{}': {}", pat, e)))?; exclude.push(re); } @@ -598,7 +598,11 @@ fn role_str(role: partition::PartRole) -> &'static str { /// - mount: scheme summary and target template (e.g., "/var/cache/{UUID}") /// /// This function is non-destructive and performs no probing beyond the provided inputs. -fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Config) -> Result { +fn build_summary_json( + disks: &[Disk], + plan: &partition::PartitionPlan, + cfg: &Config, +) -> Result { // Disks summary let disks_json: Vec = disks .iter() @@ -730,4 +734,4 @@ fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Con }); Ok(summary) -} \ No newline at end of file +} diff --git a/src/partition/mod.rs b/src/partition/mod.rs index 6b081fd..ae2ff56 100644 --- a/src/partition/mod.rs +++ b/src/partition/mod.rs @@ -9,4 +9,4 @@ pub mod plan; -pub use plan::*; \ No newline at end of file +pub use plan::*; diff --git a/src/partition/plan.rs b/src/partition/plan.rs index b48b2c9..7338b7c 100644 --- a/src/partition/plan.rs +++ b/src/partition/plan.rs @@ -19,12 +19,12 @@ // ext: device-specific alignment or reserved areas configurable via cfg in the future. // REGION: EXTENSION_POINTS-END // - // REGION: SAFETY - // safety: must verify require_empty_disks before any modification. - // safety: when UEFI-booted, suppress creating BIOS boot partition to avoid unnecessary ef02 on UEFI systems. - // safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT). - // safety: must call udev settle after partition table writes. - // REGION: SAFETY-END +// REGION: SAFETY +// safety: must verify require_empty_disks before any modification. +// safety: when UEFI-booted, suppress creating BIOS boot partition to avoid unnecessary ef02 on UEFI systems. +// safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT). +// safety: must call udev settle after partition table writes. +// REGION: SAFETY-END // // REGION: ERROR_MAPPING // errmap: external tool failure -> crate::Error::Tool { tool, status, stderr }. @@ -44,11 +44,11 @@ //! [fn apply_partitions](plan.rs:1). use crate::{ - types::{Config, Topology}, - device::Disk, - util::{run_cmd, run_cmd_capture, which_tool, udev_settle, is_efi_boot}, - idempotency, Error, Result, + device::Disk, + idempotency, + types::{Config, Topology}, + util::{is_efi_boot, run_cmd, run_cmd_capture, udev_settle, which_tool}, }; use tracing::{debug, warn}; @@ -117,20 +117,20 @@ pub struct PartitionResult { pub device_path: String, } - /// Compute GPT-only plan per topology and constraints. - /// - /// Layout defaults: - /// - BIOS boot: cfg.partitioning.bios_boot if enabled (size_mib) - /// - ESP: cfg.partitioning.esp.size_mib, GPT name cfg.partitioning.esp.gpt_name (typically "zosboot") - /// - Data: remainder, GPT name cfg.partitioning.data.gpt_name ("zosdata") - /// - Cache (only for SSD/HDD topology): remainder on SSD after boot/ESP, GPT name cfg.partitioning.cache.gpt_name ("zoscache") - /// - /// Topology mapping: - /// - Single: use first eligible disk; create BIOS (opt) + ESP + Data - /// - DualIndependent: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data - /// - BtrfsRaid1: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data - /// - SsdHddBcachefs: need >=1 SSD (rotational=false) and >=1 HDD (rotational=true); - /// SSD: BIOS (opt) + ESP + Cache; HDD: Data +/// Compute GPT-only plan per topology and constraints. +/// +/// Layout defaults: +/// - BIOS boot: cfg.partitioning.bios_boot if enabled (size_mib) +/// - ESP: cfg.partitioning.esp.size_mib, GPT name cfg.partitioning.esp.gpt_name (typically "zosboot") +/// - Data: remainder, GPT name cfg.partitioning.data.gpt_name ("zosdata") +/// - Cache (only for SSD/HDD topology): remainder on SSD after boot/ESP, GPT name cfg.partitioning.cache.gpt_name ("zoscache") +/// +/// Topology mapping: +/// - Single: use first eligible disk; create BIOS (opt) + ESP + Data +/// - DualIndependent: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data +/// - BtrfsRaid1: need at least 2 disks; disk0: BIOS (opt) + ESP + Data, disk1: Data +/// - SsdHddBcachefs: need >=1 SSD (rotational=false) and >=1 HDD (rotational=true); +/// SSD: BIOS (opt) + ESP + Cache; HDD: Data pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { let align = cfg.partitioning.alignment_mib; let require_empty = cfg.partitioning.require_empty_disks; @@ -138,7 +138,9 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { let add_bios = cfg.partitioning.bios_boot.enabled && !is_efi_boot(); if disks.is_empty() { - return Err(Error::Partition("no disks provided to partition planner".into())); + return Err(Error::Partition( + "no disks provided to partition planner".into(), + )); } let mut plans: Vec = Vec::new(); @@ -164,7 +166,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d0.clone(), parts }); + plans.push(DiskPlan { + disk: d0.clone(), + parts, + }); } Topology::BcachefsSingle => { let d0 = &disks[0]; @@ -186,11 +191,16 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d0.clone(), parts }); + plans.push(DiskPlan { + disk: d0.clone(), + parts, + }); } Topology::DualIndependent => { if disks.len() < 2 { - return Err(Error::Partition("DualIndependent topology requires at least 2 disks".into())); + return Err(Error::Partition( + "DualIndependent topology requires at least 2 disks".into(), + )); } let d0 = &disks[0]; let d1 = &disks[1]; @@ -214,7 +224,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); + plans.push(DiskPlan { + disk: d0.clone(), + parts: parts0, + }); // Disk 1: Data only let mut parts1 = Vec::new(); @@ -223,11 +236,16 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d1.clone(), parts: parts1 }); + plans.push(DiskPlan { + disk: d1.clone(), + parts: parts1, + }); } Topology::BtrfsRaid1 => { if disks.len() < 2 { - return Err(Error::Partition("BtrfsRaid1 topology requires at least 2 disks".into())); + return Err(Error::Partition( + "BtrfsRaid1 topology requires at least 2 disks".into(), + )); } let d0 = &disks[0]; let d1 = &disks[1]; @@ -251,7 +269,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); + plans.push(DiskPlan { + disk: d0.clone(), + parts: parts0, + }); // Disk 1: Data only (for RAID1) let mut parts1 = Vec::new(); @@ -260,11 +281,16 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d1.clone(), parts: parts1 }); + plans.push(DiskPlan { + disk: d1.clone(), + parts: parts1, + }); } Topology::Bcachefs2Copy => { if disks.len() < 2 { - return Err(Error::Partition("Bcachefs2Copy topology requires at least 2 disks".into())); + return Err(Error::Partition( + "Bcachefs2Copy topology requires at least 2 disks".into(), + )); } let d0 = &disks[0]; let d1 = &disks[1]; @@ -288,7 +314,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); + plans.push(DiskPlan { + disk: d0.clone(), + parts: parts0, + }); // Disk 1: Data only let mut parts1 = Vec::new(); @@ -297,14 +326,19 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: d1.clone(), parts: parts1 }); + plans.push(DiskPlan { + disk: d1.clone(), + parts: parts1, + }); } Topology::SsdHddBcachefs => { // Choose SSD (rotational=false) and HDD (rotational=true) - let ssd = disks.iter().find(|d| !d.rotational) - .ok_or_else(|| Error::Partition("SsdHddBcachefs requires an SSD (non-rotational) disk".into()))?; - let hdd = disks.iter().find(|d| d.rotational) - .ok_or_else(|| Error::Partition("SsdHddBcachefs requires an HDD (rotational) disk".into()))?; + let ssd = disks.iter().find(|d| !d.rotational).ok_or_else(|| { + Error::Partition("SsdHddBcachefs requires an SSD (non-rotational) disk".into()) + })?; + let hdd = disks.iter().find(|d| d.rotational).ok_or_else(|| { + Error::Partition("SsdHddBcachefs requires an HDD (rotational) disk".into()) + })?; // SSD: BIOS (opt) + ESP + Cache remainder let mut parts_ssd = Vec::new(); @@ -325,7 +359,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.cache.gpt_name.clone(), }); - plans.push(DiskPlan { disk: ssd.clone(), parts: parts_ssd }); + plans.push(DiskPlan { + disk: ssd.clone(), + parts: parts_ssd, + }); // HDD: Data remainder let mut parts_hdd = Vec::new(); @@ -334,7 +371,10 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { size_mib: None, gpt_name: cfg.partitioning.data.gpt_name.clone(), }); - plans.push(DiskPlan { disk: hdd.clone(), parts: parts_hdd }); + plans.push(DiskPlan { + disk: hdd.clone(), + parts: parts_hdd, + }); } } @@ -389,10 +429,17 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result> { if let Some(blockdev) = which_tool("blockdev")? { let out = run_cmd_capture(&[blockdev.as_str(), "--getss", disk_path])?; let s = out.stdout.trim(); - return s.parse::() - .map_err(|e| Error::Partition(format!("failed to parse sector size from blockdev for {}: {}", disk_path, e))); + return s.parse::().map_err(|e| { + Error::Partition(format!( + "failed to parse sector size from blockdev for {}: {}", + disk_path, e + )) + }); } - warn!("blockdev not found; assuming 512-byte sectors for {}", disk_path); + warn!( + "blockdev not found; assuming 512-byte sectors for {}", + disk_path + ); Ok(512) } @@ -410,20 +457,29 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result> { // Format: "First sector: 2048 (at 1024.0 KiB)" let val = rest.trim().split_whitespace().next().unwrap_or(""); if !val.is_empty() { - first = Some(val.parse::().map_err(|e| Error::Partition(format!("parse first sector: {}", e)))?); + first = Some( + val.parse::() + .map_err(|e| Error::Partition(format!("parse first sector: {}", e)))?, + ); } } else if let Some(rest) = line.strip_prefix("Last sector:") { let val = rest.trim().split_whitespace().next().unwrap_or(""); if !val.is_empty() { - last = Some(val.parse::().map_err(|e| Error::Partition(format!("parse last sector: {}", e)))?); + last = Some( + val.parse::() + .map_err(|e| Error::Partition(format!("parse last sector: {}", e)))?, + ); } } } - let first = first.ok_or_else(|| Error::Partition("sgdisk -i missing First sector".into()))?; + let first = + first.ok_or_else(|| Error::Partition("sgdisk -i missing First sector".into()))?; let last = last.ok_or_else(|| Error::Partition("sgdisk -i missing Last sector".into()))?; if guid.is_empty() { - return Err(Error::Partition("sgdisk -i missing Partition unique GUID".into())); + return Err(Error::Partition( + "sgdisk -i missing Partition unique GUID".into(), + )); } Ok((guid, first, last)) } @@ -467,9 +523,12 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result> { run_cmd(&[ sgdisk.as_str(), - "-n", n_arg.as_str(), - "-t", t_arg.as_str(), - "-c", c_arg.as_str(), + "-n", + n_arg.as_str(), + "-t", + t_arg.as_str(), + "-c", + c_arg.as_str(), disk_path, ])?; } @@ -486,11 +545,7 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result> { // Query sgdisk for partition info let i_arg = format!("{}", part_num); - let info_out = run_cmd_capture(&[ - sgdisk.as_str(), - "-i", i_arg.as_str(), - disk_path, - ])?; + let info_out = run_cmd_capture(&[sgdisk.as_str(), "-i", i_arg.as_str(), disk_path])?; let (unique_guid, first_sector, last_sector) = parse_sgdisk_info(&info_out.stdout)?; let sectors = if last_sector >= first_sector { @@ -516,6 +571,9 @@ pub fn apply_partitions(plan: &PartitionPlan) -> Result> { } } - debug!("apply_partitions: created {} partition entries", results.len()); + debug!( + "apply_partitions: created {} partition entries", + results.len() + ); Ok(results) -} \ No newline at end of file +} diff --git a/src/report/mod.rs b/src/report/mod.rs index 3d12ef3..a99a943 100644 --- a/src/report/mod.rs +++ b/src/report/mod.rs @@ -9,4 +9,4 @@ pub mod state; -pub use state::*; \ No newline at end of file +pub use state::*; diff --git a/src/report/state.rs b/src/report/state.rs index b60012b..854208f 100644 --- a/src/report/state.rs +++ b/src/report/state.rs @@ -77,4 +77,4 @@ pub fn build_report( /// Write the state report JSON to disk (default path in config: /run/zosstorage/state.json). pub fn write_report(_report: &StateReport, _path: &str) -> Result<()> { todo!("serialize to JSON and persist atomically via tempfile and rename") -} \ No newline at end of file +} diff --git a/src/types.rs b/src/types.rs index c8f3d28..d01632e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -15,15 +15,15 @@ // - Keep field names and enums stable; update docs/SCHEMA.md when public surface changes. // REGION: RESPONSIBILITIES-END -use serde::{Deserialize, Serialize}; use clap::ValueEnum; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoggingConfig { /// Log level: "error" | "warn" | "info" | "debug" - pub level: String, // default "info" + pub level: String, // default "info" /// When true, also log to /run/zosstorage/zosstorage.log - pub to_file: bool, // default false + pub to_file: bool, // default false } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -55,7 +55,11 @@ pub enum Topology { #[value(alias = "ssd-hdd-bcachefs")] SsdHddBcachefs, /// Multi-device bcachefs with two replicas (data+metadata). - #[value(alias = "bcachefs2-copy", alias = "bcachefs-2copy", alias = "bcachefs-2-copy")] + #[value( + alias = "bcachefs2-copy", + alias = "bcachefs-2copy", + alias = "bcachefs-2-copy" + )] Bcachefs2Copy, /// Optional mirrored btrfs across two disks when explicitly requested. #[value(alias = "btrfs-raid1")] @@ -205,4 +209,4 @@ pub struct Config { pub mount: MountScheme, /// Report output configuration. pub report: ReportOptions, -} \ No newline at end of file +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 3fc0eb4..a05068c 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -40,8 +40,8 @@ //! and consistent error handling. use crate::{Error, Result}; -use std::process::Command; use std::path::Path; +use std::process::Command; use tracing::{debug, warn}; /// Captured output from an external tool invocation. @@ -77,9 +77,10 @@ pub fn run_cmd(args: &[&str]) -> Result<()> { ))); } debug!(target: "util.run_cmd", "exec: {:?}", args); - let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| { - Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)) - })?; + let output = Command::new(args[0]) + .args(&args[1..]) + .output() + .map_err(|e| Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)))?; let status_code = output.status.code().unwrap_or(-1); if !output.status.success() { @@ -103,9 +104,10 @@ pub fn run_cmd_capture(args: &[&str]) -> Result { ))); } debug!(target: "util.run_cmd_capture", "exec: {:?}", args); - let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| { - Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)) - })?; + let output = Command::new(args[0]) + .args(&args[1..]) + .output() + .map_err(|e| Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)))?; let status_code = output.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -205,4 +207,4 @@ mod tests { // Should never fail even if udevadm is missing. udev_settle(1000).expect("udev_settle should be non-fatal"); } -} \ No newline at end of file +}