Files
zosstorage/src/fs/plan.rs
Jan De Landtsheer 3d14f77516 mount: prefer boot disk ESP and run cargo fmt
* choose ESP matching the primary data disk when multiple ESPs exist,
  falling back gracefully for single-disk layouts
* keep new helper to normalize device names and reuse the idempotent
  mount logic
* apply cargo fmt across the tree
2025-10-10 14:49:39 +02:00

482 lines
18 KiB
Rust

// 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, cfg: &crate::types::Config) -> 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).
// 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
//! 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::{
Error, Result,
partition::{PartRole, PartitionResult},
types::{Config, Topology},
util::{run_cmd, run_cmd_capture, which_tool},
};
use std::fs;
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(),
});
}
Topology::Bcachefs2Copy => {
// Group all Data partitions into a single Bcachefs filesystem across multiple devices (2-copy semantics).
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(
"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,
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, cfg: &Config) -> 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 [ -m raid1 -d raid1 (when multi-device/raid1) ] dev1 [dev2 ...]
let mut args: Vec<String> = vec![mkfs.clone(), "-L".into(), spec.label.clone()];
// If this Btrfs is multi-device (as planned in BtrfsRaid1 topology),
// set metadata/data profiles to raid1. This keeps plan/apply consistent.
if spec.devices.len() >= 2 {
args.push("-m".into());
args.push("raid1".into());
args.push("-d".into());
args.push("raid1".into());
}
// Note: compression is a mount-time option for btrfs; we will apply it in mount phase.
// Leaving mkfs-time compression unset by design.
// Force formatting in apply path to avoid leftover signatures on freshly created partitions.
// Safe because we just created these partitions in this run.
args.push("-f".into());
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.is_empty() {
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<String> = vec![
mkfs.clone(),
"format".into(),
"--label".into(),
spec.label.clone(),
];
if matches!(cfg.topology, Topology::Bcachefs2Copy) {
args.push("--replicas=2".into());
}
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
}
/// Probe existing filesystems on the system and return their identities (kind, uuid, label).
///
/// This inspects /proc/partitions and uses `blkid -o export` on each device to detect:
/// - Data filesystems: Btrfs or Bcachefs with label "ZOSDATA"
/// - ESP filesystems: Vfat with label "ZOSBOOT"
/// Multi-device filesystems (e.g., btrfs) are de-duplicated by UUID.
///
/// Returns:
/// - Vec<FsResult> with at most one entry per filesystem UUID.
pub fn probe_existing_filesystems() -> Result<Vec<FsResult>> {
let Some(blkid) = which_tool("blkid")? else {
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<String, FsResult> =
std::collections::HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("major") {
continue;
}
// Format: major minor #blocks name
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
let name = parts[3];
// Skip pseudo devices commonly not relevant (loop, ram, zram, fd)
if name.starts_with("loop")
|| name.starts_with("ram")
|| name.starts_with("zram")
|| name.starts_with("fd")
{
continue;
}
let dev_path = format!("/dev/{}", name);
// Probe with blkid -o export; ignore non-zero statuses meaning "nothing found"
let out = match run_cmd_capture(&[blkid.as_str(), "-o", "export", dev_path.as_str()]) {
Ok(o) => o,
Err(Error::Tool { status, .. }) if status != 0 => {
// No recognizable signature; skip
continue;
}
Err(_) => {
// Unexpected failure; skip this device
continue;
}
};
let map = parse_blkid_export(&out.stdout);
let ty = map.get("TYPE").cloned().unwrap_or_default();
let label = map
.get("ID_FS_LABEL")
.cloned()
.or_else(|| map.get("LABEL").cloned())
.unwrap_or_default();
let uuid = map
.get("ID_FS_UUID")
.cloned()
.or_else(|| map.get("UUID").cloned());
let (kind_opt, expected_label) = match ty.as_str() {
"btrfs" => (Some(FsKind::Btrfs), "ZOSDATA"),
"bcachefs" => (Some(FsKind::Bcachefs), "ZOSDATA"),
"vfat" => (Some(FsKind::Vfat), "ZOSBOOT"),
_ => (None, ""),
};
if let (Some(kind), Some(u)) = (kind_opt, uuid) {
// Enforce reserved label semantics
if !expected_label.is_empty() && label != expected_label {
continue;
}
// Deduplicate multi-device filesystems by UUID; record first-seen device
results_by_uuid.entry(u.clone()).or_insert(FsResult {
kind,
devices: vec![dev_path.clone()],
uuid: u,
label: label.clone(),
});
}
}
Ok(results_by_uuid.into_values().collect())
}
#[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");
}
}