diff --git a/README.md b/README.md index 745809a..a159668 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Key modules - [src/mount/ops.rs](src/mount/ops.rs) Features at a glance -- Topology-driven planning with built-in defaults: Single, DualIndependent, BtrfsRaid1, SsdHddBcachefs +- Topology-driven planning with built-in defaults: BtrfsSingle, BcachefsSingle, DualIndependent, Bcachefs2Copy, BtrfsRaid1, SsdHddBcachefs - Non-destructive preview: --show/--report outputs JSON summary (disks, partition plan, filesystems, planned mountpoints) - Safe discovery: excludes removable media by default (USB sticks) unless explicitly allowed - Config-optional: the tool runs without any YAML; sensible defaults are always present and may be overridden/merged by config @@ -45,7 +45,7 @@ Binary is target/release/zosstorage. CLI usage - Topology selection (config optional): - -t, --topology single|dual-independent|btrfs-raid1|ssd-hdd-bcachefs + -t, --topology btrfs-single|bcachefs-single|dual-independent|bcachefs-2copy|btrfs-raid1|ssd-hdd-bcachefs - Preview (non-destructive): --show Print JSON summary to stdout --report PATH Write JSON summary to a file @@ -61,7 +61,7 @@ CLI usage Examples - Single disk plan with debug logs: - sudo ./zosstorage --show -t single -l debug + sudo ./zosstorage --show -t btrfs-single -l debug - RAID1 btrfs across two disks; print and write summary: sudo ./zosstorage --show --report /run/zosstorage/plan.json -t btrfs-raid1 -l debug -L - SSD+HDD bcachefs plan, include removable devices (for lab cases): diff --git a/src/cli/args.rs b/src/cli/args.rs index 45bd944..195077f 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -55,18 +55,22 @@ impl std::fmt::Display for LogLevelArg { #[derive(Debug, Clone, Copy, ValueEnum)] #[value(rename_all = "kebab_case")] pub enum TopologyArg { - Single, + BtrfsSingle, + BcachefsSingle, DualIndependent, SsdHddBcachefs, + Bcachefs2Copy, BtrfsRaid1, } impl std::fmt::Display for TopologyArg { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { - TopologyArg::Single => "single", + TopologyArg::BtrfsSingle => "btrfs_single", + TopologyArg::BcachefsSingle => "bcachefs_single", TopologyArg::DualIndependent => "dual_independent", TopologyArg::SsdHddBcachefs => "ssd_hdd_bcachefs", + TopologyArg::Bcachefs2Copy => "bcachefs_2copy", TopologyArg::BtrfsRaid1 => "btrfs_raid1", }; f.write_str(s) diff --git a/src/config/loader.rs b/src/config/loader.rs index 1839ced..043eaf9 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -187,9 +187,11 @@ pub fn validate(cfg: &Config) -> Result<()> { // Topology-specific quick checks (basic for now) match cfg.topology { - Topology::Single => {} // nothing special + Topology::BtrfsSingle => {} // nothing special + Topology::BcachefsSingle => {} Topology::DualIndependent => {} Topology::SsdHddBcachefs => {} + 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" { @@ -352,7 +354,7 @@ fn default_config() -> Config { allow_removable: false, min_size_gib: 10, }, - topology: Topology::Single, + topology: Topology::BtrfsSingle, partitioning: Partitioning { alignment_mib: 1, require_empty_disks: true, diff --git a/src/fs/plan.rs b/src/fs/plan.rs index 36c7e19..72b7aa9 100644 --- a/src/fs/plan.rs +++ b/src/fs/plan.rs @@ -152,8 +152,36 @@ pub fn plan_filesystems( label: cfg.filesystem.btrfs.label.clone(), }); } - _ => { - // Map each Data partition to individual Btrfs filesystems. + Topology::Bcachefs2Copy => { + // Group all Data partitions into a single Bcachefs filesystem across multiple devices (2-copy semantics). + let data_devs: Vec = parts + .iter() + .filter(|p| matches!(p.role, PartRole::Data)) + .map(|p| p.device_path.clone()) + .collect(); + if data_devs.len() < 2 { + return Err(Error::Filesystem( + "Bcachefs2Copy topology requires at least 2 data partitions".to_string(), + )); + } + specs.push(FsSpec { + kind: FsKind::Bcachefs, + devices: data_devs, + label: cfg.filesystem.bcachefs.label.clone(), + }); + } + 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()))?; + specs.push(FsSpec { + kind: FsKind::Bcachefs, + devices: vec![data.device_path.clone()], + label: cfg.filesystem.bcachefs.label.clone(), + }); + } + Topology::BtrfsSingle | Topology::DualIndependent => { + // Map Data partition(s) to Btrfs (single device per partition for DualIndependent). for p in parts.iter().filter(|p| matches!(p.role, PartRole::Data)) { specs.push(FsSpec { kind: FsKind::Btrfs, @@ -258,12 +286,11 @@ pub fn make_filesystems(plan: &FsPlan) -> Result> { let Some(ref mkfs) = bcachefs_tool else { return Err(Error::Filesystem("bcachefs not found in PATH".into())); }; - if spec.devices.len() < 2 { - return Err(Error::Filesystem("bcachefs requires at least two devices (cache + backing)".into())); + if spec.devices.is_empty() { + return Err(Error::Filesystem("bcachefs requires at least one device".into())); } - // bcachefs format --label LABEL dev_cache dev_backing ... - // TODO(fs): map compression/checksum/cache-mode flags from config in a follow-up. - // This is deferred per current scope to focus on btrfs RAID profile wiring. + // bcachefs format --label LABEL dev_cache dev_backing ... (single-device also supported) + // TODO(fs): map compression/checksum/cache-mode and data/metadata replica flags in a follow-up. let mut args: Vec = vec![mkfs.clone(), "format".into(), "--label".into(), spec.label.clone()]; args.extend(spec.devices.iter().cloned()); let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); diff --git a/src/orchestrator/run.rs b/src/orchestrator/run.rs index 8869518..9859920 100644 --- a/src/orchestrator/run.rs +++ b/src/orchestrator/run.rs @@ -341,9 +341,11 @@ fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Con // Decide filesystem kinds and planned mountpoints (template) from plan + cfg.topology let topo_str = match cfg.topology { - crate::types::Topology::Single => "single", + crate::types::Topology::BtrfsSingle => "btrfs_single", + crate::types::Topology::BcachefsSingle => "bcachefs_single", crate::types::Topology::DualIndependent => "dual_independent", crate::types::Topology::SsdHddBcachefs => "ssd_hdd_bcachefs", + crate::types::Topology::Bcachefs2Copy => "bcachefs_2copy", crate::types::Topology::BtrfsRaid1 => "btrfs_raid1", }; diff --git a/src/partition/plan.rs b/src/partition/plan.rs index a701dc8..b48b2c9 100644 --- a/src/partition/plan.rs +++ b/src/partition/plan.rs @@ -144,7 +144,29 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { let mut plans: Vec = Vec::new(); match cfg.topology { - Topology::Single => { + Topology::BtrfsSingle => { + let d0 = &disks[0]; + let mut parts = Vec::new(); + if add_bios { + parts.push(PartitionSpec { + role: PartRole::BiosBoot, + size_mib: Some(cfg.partitioning.bios_boot.size_mib), + gpt_name: cfg.partitioning.bios_boot.gpt_name.clone(), + }); + } + parts.push(PartitionSpec { + role: PartRole::Esp, + size_mib: Some(cfg.partitioning.esp.size_mib), + gpt_name: cfg.partitioning.esp.gpt_name.clone(), + }); + parts.push(PartitionSpec { + role: PartRole::Data, + size_mib: None, + gpt_name: cfg.partitioning.data.gpt_name.clone(), + }); + plans.push(DiskPlan { disk: d0.clone(), parts }); + } + Topology::BcachefsSingle => { let d0 = &disks[0]; let mut parts = Vec::new(); if add_bios { @@ -240,6 +262,43 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result { }); 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())); + } + let d0 = &disks[0]; + let d1 = &disks[1]; + + // Disk 0: BIOS (opt) + ESP + Data + let mut parts0 = Vec::new(); + if add_bios { + parts0.push(PartitionSpec { + role: PartRole::BiosBoot, + size_mib: Some(cfg.partitioning.bios_boot.size_mib), + gpt_name: cfg.partitioning.bios_boot.gpt_name.clone(), + }); + } + parts0.push(PartitionSpec { + role: PartRole::Esp, + size_mib: Some(cfg.partitioning.esp.size_mib), + gpt_name: cfg.partitioning.esp.gpt_name.clone(), + }); + parts0.push(PartitionSpec { + role: PartRole::Data, + size_mib: None, + gpt_name: cfg.partitioning.data.gpt_name.clone(), + }); + plans.push(DiskPlan { disk: d0.clone(), parts: parts0 }); + + // Disk 1: Data only + let mut parts1 = Vec::new(); + parts1.push(PartitionSpec { + role: PartRole::Data, + size_mib: None, + gpt_name: cfg.partitioning.data.gpt_name.clone(), + }); + 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) diff --git a/src/types.rs b/src/types.rs index d9ae757..9b89b78 100644 --- a/src/types.rs +++ b/src/types.rs @@ -29,11 +29,15 @@ pub struct DeviceSelection { #[serde(rename_all = "snake_case")] pub enum Topology { /// Single eligible disk; btrfs on remainder. - Single, + BtrfsSingle, + /// Single eligible disk; bcachefs on remainder. + BcachefsSingle, /// Two eligible disks; independent btrfs on each data partition. DualIndependent, /// SSD + HDD; bcachefs with SSD cache/promote and HDD backing. SsdHddBcachefs, + /// Two-disk bcachefs layout using both data partitions (2 copies semantics). + Bcachefs2Copy, /// Optional mirrored btrfs across two disks when explicitly requested. BtrfsRaid1, }