feat: first-draft preview-capable zosstorage
- CLI: add topology selection (-t/--topology), preview flags (--show/--report), and removable policy override (--allow-removable) (src/cli/args.rs) - Config: built-in sensible defaults; deterministic overlays for logging, fstab, removable, topology (src/config/loader.rs) - Device: discovery via /proc + /sys with include/exclude regex and removable policy (src/device/discovery.rs) - Idempotency: detection via blkid; safe emptiness checks (src/idempotency/mod.rs) - Partition: topology-driven planning (Single, DualIndependent, BtrfsRaid1, SsdHddBcachefs) (src/partition/plan.rs) - FS: planning + creation (mkfs.vfat, mkfs.btrfs, bcachefs format) and UUID capture via blkid (src/fs/plan.rs) - Orchestrator: pre-flight with preview JSON (disks, partition_plan, filesystems_planned, mount scheme). Skips emptiness in preview; supports stdout+file (src/orchestrator/run.rs) - Util/Logging/Types/Errors: process execution, tracing, shared types (src/util/mod.rs, src/logging/mod.rs, src/types.rs, src/errors.rs) - Docs: add README with exhaustive usage and preview JSON shape (README.md) Builds and unit tests pass: discovery, util, idempotency helpers, and fs parser tests.
This commit is contained in:
308
src/fs/plan.rs
Normal file
308
src/fs/plan.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
// REGION: API
|
||||
// api: fs::FsKind { Vfat, Btrfs, Bcachefs }
|
||||
// api: fs::FsSpec { kind: FsKind, devices: Vec<String>, label: String }
|
||||
// api: fs::FsPlan { specs: Vec<FsSpec> }
|
||||
// api: fs::FsResult { kind: FsKind, devices: Vec<String>, uuid: String, label: String }
|
||||
// api: fs::plan_filesystems(parts: &[crate::partition::PartitionResult], cfg: &crate::config::types::Config) -> crate::Result<FsPlan>
|
||||
// api: fs::make_filesystems(plan: &FsPlan) -> crate::Result<Vec<FsResult>>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Map partition roles to concrete filesystems (vfat for ESP, btrfs for data, bcachefs for SSD+HDD).
|
||||
// - Execute mkfs operations via external tooling wrappers and capture resulting UUIDs/labels.
|
||||
// Non-goals: partition layout decisions, mount orchestration, device discovery.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: support additional filesystems or tuning flags through Config (e.g., more btrfs/bcachefs options).
|
||||
// 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).
|
||||
// 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: implement mapping of topology to FsSpec including bcachefs cache/backing composition.
|
||||
// todo: implement mkfs invocation and UUID capture via util::run_cmd / util::run_cmd_capture.
|
||||
// REGION: TODO-END
|
||||
//! Filesystem planning and creation for zosstorage.
|
||||
//!
|
||||
//! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs)
|
||||
//! and executes mkfs operations via external tooling wrappers.
|
||||
//!
|
||||
//! See [fn plan_filesystems](plan.rs:1) and
|
||||
//! [fn make_filesystems](plan.rs:1).
|
||||
|
||||
use crate::{
|
||||
Result,
|
||||
types::{Config, Topology},
|
||||
partition::{PartitionResult, PartRole},
|
||||
util::{run_cmd, run_cmd_capture, which_tool},
|
||||
Error,
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Filesystem kinds supported by zosstorage.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FsKind {
|
||||
/// FAT32 for the EFI System Partition.
|
||||
Vfat,
|
||||
/// Btrfs for data filesystems in single/dual topologies.
|
||||
Btrfs,
|
||||
/// Bcachefs for SSD+HDD topology (SSD as cache/promote, HDD backing).
|
||||
Bcachefs,
|
||||
}
|
||||
|
||||
/// Declarative specification for creating a filesystem.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsSpec {
|
||||
/// Filesystem kind.
|
||||
pub kind: FsKind,
|
||||
/// Source device(s):
|
||||
/// - single path for vfat and btrfs
|
||||
/// - two paths for bcachefs (cache + backing)
|
||||
pub devices: Vec<String>,
|
||||
/// Filesystem label:
|
||||
/// - "ZOSBOOT" for ESP
|
||||
/// - "ZOSDATA" for all data filesystems
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Plan of filesystem creations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPlan {
|
||||
/// All filesystem creation specs.
|
||||
pub specs: Vec<FsSpec>,
|
||||
}
|
||||
|
||||
/// Result of creating a filesystem.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsResult {
|
||||
/// Filesystem kind.
|
||||
pub kind: FsKind,
|
||||
/// Devices the filesystem was created on.
|
||||
pub devices: Vec<String>,
|
||||
/// Filesystem UUID (string as reported by blkid or related).
|
||||
pub uuid: String,
|
||||
/// Filesystem label ("ZOSBOOT" or "ZOSDATA").
|
||||
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<FsPlan> {
|
||||
let mut specs: Vec<FsSpec> = Vec::new();
|
||||
|
||||
// Always map ESP partitions
|
||||
for p in parts.iter().filter(|p| matches!(p.role, PartRole::Esp)) {
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Vfat,
|
||||
devices: vec![p.device_path.clone()],
|
||||
label: cfg.filesystem.vfat.label.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
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()))?;
|
||||
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Bcachefs,
|
||||
devices: vec![cache.device_path.clone(), data.device_path.clone()],
|
||||
label: cfg.filesystem.bcachefs.label.clone(),
|
||||
});
|
||||
}
|
||||
Topology::BtrfsRaid1 => {
|
||||
// Group all Data partitions into a single Btrfs filesystem across multiple devices.
|
||||
let data_devs: Vec<String> = 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(
|
||||
"BtrfsRaid1 topology requires at least 2 data partitions".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Btrfs,
|
||||
devices: data_devs,
|
||||
label: cfg.filesystem.btrfs.label.clone(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Map each Data partition to individual Btrfs filesystems.
|
||||
for p in parts.iter().filter(|p| matches!(p.role, PartRole::Data)) {
|
||||
specs.push(FsSpec {
|
||||
kind: FsKind::Btrfs,
|
||||
devices: vec![p.device_path.clone()],
|
||||
label: cfg.filesystem.btrfs.label.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if specs.is_empty() {
|
||||
return Err(Error::Filesystem("no filesystems to create from provided partitions".to_string()));
|
||||
}
|
||||
|
||||
Ok(FsPlan { specs })
|
||||
}
|
||||
|
||||
/// Create the filesystems and return identity info (UUIDs, labels).
|
||||
///
|
||||
//// Uses external tooling via util wrappers (mkfs.vfat, mkfs.btrfs, bcachefs format).
|
||||
/// Notes:
|
||||
/// - This initial implementation applies labels and creates filesystems with minimal flags.
|
||||
/// - Btrfs RAID profile (e.g., raid1) will be applied in a follow-up by mapping config to mkfs flags.
|
||||
/// - UUID is captured via blkid -o export on the first device of each spec.
|
||||
pub fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>> {
|
||||
// Discover required tools up-front
|
||||
let vfat_tool = which_tool("mkfs.vfat")?;
|
||||
let btrfs_tool = which_tool("mkfs.btrfs")?;
|
||||
let bcachefs_tool = which_tool("bcachefs")?;
|
||||
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()));
|
||||
}
|
||||
let blkid = blkid_tool.unwrap();
|
||||
|
||||
let mut results: Vec<FsResult> = Vec::new();
|
||||
|
||||
for spec in &plan.specs {
|
||||
match spec.kind {
|
||||
FsKind::Vfat => {
|
||||
let Some(ref mkfs) = vfat_tool else {
|
||||
return Err(Error::Filesystem("mkfs.vfat not found in PATH".into()));
|
||||
};
|
||||
if spec.devices.len() != 1 {
|
||||
return Err(Error::Filesystem("vfat requires exactly one device".into()));
|
||||
}
|
||||
let dev = &spec.devices[0];
|
||||
// mkfs.vfat -n LABEL /dev/...
|
||||
run_cmd(&[mkfs.as_str(), "-n", spec.label.as_str(), dev.as_str()])?;
|
||||
|
||||
// Capture UUID
|
||||
let uuid = capture_uuid(&blkid, dev)?;
|
||||
results.push(FsResult {
|
||||
kind: FsKind::Vfat,
|
||||
devices: vec![dev.clone()],
|
||||
uuid,
|
||||
label: spec.label.clone(),
|
||||
});
|
||||
}
|
||||
FsKind::Btrfs => {
|
||||
let Some(ref mkfs) = btrfs_tool else {
|
||||
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()));
|
||||
}
|
||||
// mkfs.btrfs -L LABEL dev1 [dev2 ...]
|
||||
let mut args: Vec<String> = vec![mkfs.clone(), "-L".into(), spec.label.clone()];
|
||||
args.extend(spec.devices.iter().cloned());
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_cmd(&args_ref)?;
|
||||
|
||||
// Capture UUID from the first device
|
||||
let dev0 = &spec.devices[0];
|
||||
let uuid = capture_uuid(&blkid, dev0)?;
|
||||
results.push(FsResult {
|
||||
kind: FsKind::Btrfs,
|
||||
devices: spec.devices.clone(),
|
||||
uuid,
|
||||
label: spec.label.clone(),
|
||||
});
|
||||
}
|
||||
FsKind::Bcachefs => {
|
||||
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()));
|
||||
}
|
||||
// bcachefs format --label LABEL dev_cache dev_backing ...
|
||||
let mut args: Vec<String> = 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();
|
||||
run_cmd(&args_ref)?;
|
||||
|
||||
// Capture UUID from the first device
|
||||
let dev0 = &spec.devices[0];
|
||||
let uuid = capture_uuid(&blkid, dev0)?;
|
||||
results.push(FsResult {
|
||||
kind: FsKind::Bcachefs,
|
||||
devices: spec.devices.clone(),
|
||||
uuid,
|
||||
label: spec.label.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("make_filesystems: created {} filesystems", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn capture_uuid(blkid: &str, dev: &str) -> Result<String> {
|
||||
// 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<String, String> {
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_parse {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user