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:
2025-09-29 11:37:07 +02:00
commit 507bc172c2
38 changed files with 6558 additions and 0 deletions

366
src/device/discovery.rs Normal file
View File

@@ -0,0 +1,366 @@
// REGION: API
// api: device::Disk { path: String, size_bytes: u64, rotational: bool, model: Option<String>, serial: Option<String> }
// api: device::DeviceFilter { include: Vec<regex::Regex>, exclude: Vec<regex::Regex>, min_size_gib: u64 }
// api: device::DeviceProvider::list_block_devices(&self) -> crate::Result<Vec<Disk>>
// api: device::DeviceProvider::probe_properties(&self, disk: &mut Disk) -> crate::Result<()>
// api: device::discover(filter: &DeviceFilter) -> crate::Result<Vec<Disk>>
// REGION: API-END
//
// REGION: RESPONSIBILITIES
// - Enumerate candidate block devices under /dev.
// - Filter using include/exclude regex and minimum size threshold.
// - Probe device properties (size, rotational, model, serial).
// Non-goals: partitioning, mkfs, or mounting.
// REGION: RESPONSIBILITIES-END
//
// REGION: EXTENSION_POINTS
// ext: pluggable DeviceProvider to allow mocking in tests and alternative discovery backends.
// ext: future allowlist policies for removable media, device classes, or path patterns.
// REGION: EXTENSION_POINTS-END
//
// REGION: SAFETY
// safety: must not modify devices; read-only probing only.
// safety: ensure pseudodevices (/dev/ram*, /dev/zram*, /dev/loop*, /dev/fd*, /dev/dm-*, /dev/md*) are excluded by default.
// REGION: SAFETY-END
//
// REGION: ERROR_MAPPING
// errmap: IO and parsing errors -> crate::Error::Device with context.
// REGION: ERROR_MAPPING-END
//! Device discovery and filtering for zosstorage.
//!
//! Exposes abstractions to enumerate and filter block devices under /dev,
//! with compiled include/exclude regexes and size thresholds.
//!
//! See device::Disk and device::discover.
#![allow(dead_code)]
use crate::{Error, Result};
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, trace, warn};
/// Eligible block device discovered on the system.
#[derive(Debug, Clone)]
pub struct Disk {
/// Absolute device path (e.g., "/dev/nvme0n1").
pub path: String,
/// Device size in bytes.
pub size_bytes: u64,
/// True for spinning disks; false for SSD/NVMe when detectable.
pub rotational: bool,
/// Optional model string (if available).
pub model: Option<String>,
/// Optional serial string (if available).
pub serial: Option<String>,
}
/// Compiled device filters derived from configuration patterns.
#[derive(Debug, Clone)]
pub struct DeviceFilter {
/// Inclusion regexes (any match qualifies). If empty, default include any.
pub include: Vec<Regex>,
/// Exclusion regexes (any match disqualifies).
pub exclude: Vec<Regex>,
/// Minimum size in GiB to consider eligible.
pub min_size_gib: u64,
/// Allow removable devices (e.g., USB sticks). Default false.
pub allow_removable: bool,
}
impl DeviceFilter {
fn matches(&self, dev_path: &str, size_bytes: u64) -> bool {
// size filter
let size_gib = size_bytes as f64 / 1073741824.0;
if size_gib < self.min_size_gib as f64 {
return false;
}
// include filter
if !self.include.is_empty() {
if !self.include.iter().any(|re| re.is_match(dev_path)) {
return false;
}
}
// exclude filter
if self.exclude.iter().any(|re| re.is_match(dev_path)) {
return false;
}
true
}
}
/// Abstract provider to enable testing without real /dev access.
pub trait DeviceProvider {
/// List candidate block devices (whole disks only; not partitions).
fn list_block_devices(&self) -> Result<Vec<Disk>>;
/// Probe and update additional properties for a disk.
fn probe_properties(&self, _disk: &mut Disk) -> Result<()> {
Ok(())
}
}
/// System-backed provider using /proc and /sys for discovery.
struct SysProvider;
impl SysProvider {
fn new() -> Self {
SysProvider
}
}
impl DeviceProvider for SysProvider {
fn list_block_devices(&self) -> Result<Vec<Disk>> {
let mut disks = Vec::new();
let content = fs::read_to_string("/proc/partitions")
.map_err(|e| Error::Device(format!("/proc/partitions read error: {}", e)))?;
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];
// Exclude common pseudo and virtual device names
if is_ignored_name(name) {
trace!("skipping pseudo/ignored device: {}", name);
continue;
}
// Skip partitions; we want whole-disk devices only
if is_partition_sysfs(name) {
trace!("skipping partition device: {}", name);
continue;
}
// Ensure /dev node exists
let dev_path = format!("/dev/{}", name);
if !Path::new(&dev_path).exists() {
trace!("skipping: missing device node {}", dev_path);
continue;
}
// Read size in 512-byte sectors from sysfs, then convert to bytes
let size_bytes = match read_disk_size_bytes(name) {
Ok(sz) => sz,
Err(e) => {
warn!("failed to read size for {}: {}", name, e);
continue;
}
};
let rotational = read_rotational(name).unwrap_or(false);
let (model, serial) = read_model_serial(name);
let disk = Disk {
path: dev_path,
size_bytes,
rotational,
model,
serial,
};
disks.push(disk);
}
Ok(disks)
}
fn probe_properties(&self, _disk: &mut Disk) -> Result<()> {
// Properties are filled during enumeration above.
Ok(())
}
}
/// Discover eligible disks according to the filter policy.
///
/// Returns Error::Device when no eligible disks are found.
pub fn discover(filter: &DeviceFilter) -> Result<Vec<Disk>> {
let provider = SysProvider::new();
discover_with_provider(&provider, filter)
}
fn discover_with_provider<P: DeviceProvider>(provider: &P, filter: &DeviceFilter) -> Result<Vec<Disk>> {
let mut candidates = provider.list_block_devices()?;
// Probe properties if provider needs to enrich
for d in &mut candidates {
provider.probe_properties(d)?;
}
// Apply filters (including removable policy)
let filtered: Vec<Disk> = candidates
.into_iter()
.filter(|d| {
if !filter.allow_removable {
if let Some(name) = base_name(&d.path) {
if is_removable_sysfs(&name).unwrap_or(false) {
trace!("excluding removable device by policy: {}", d.path);
return false;
}
}
}
filter.matches(&d.path, d.size_bytes)
})
.collect();
if filtered.is_empty() {
return Err(Error::Device("no eligible disks found after applying filters".to_string()));
}
debug!("eligible disks: {:?}", filtered.iter().map(|d| &d.path).collect::<Vec<_>>());
Ok(filtered)
}
// =========================
// Sysfs helper functions
// =========================
fn is_ignored_name(name: &str) -> bool {
// Pseudo and virtual device common patterns
name.starts_with("loop")
|| name.starts_with("ram")
|| name.starts_with("zram")
|| name.starts_with("fd")
|| name.starts_with("dm-")
|| name.starts_with("md")
|| name.starts_with("sr")
}
fn sys_block_path(name: &str) -> PathBuf {
PathBuf::from(format!("/sys/class/block/{}", name))
}
fn base_name(dev_path: &str) -> Option<String> {
Path::new(dev_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
}
/// Returns Ok(true) if /sys/class/block/<name>/removable == "1"
fn is_removable_sysfs(name: &str) -> Result<bool> {
let p = sys_block_path(name).join("removable");
let s = fs::read_to_string(&p)
.map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?;
Ok(s.trim() == "1")
}
fn is_partition_sysfs(name: &str) -> bool {
let p = sys_block_path(name).join("partition");
p.exists()
}
fn read_disk_size_bytes(name: &str) -> Result<u64> {
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))
})?;
Ok(sectors.saturating_mul(512))
}
fn read_rotational(name: &str) -> Result<bool> {
let p = sys_block_path(name).join("queue/rotational");
let s = fs::read_to_string(&p)
.map_err(|e| Error::Device(format!("read {} failed: {}", p.display(), e)))?;
Ok(s.trim() == "1")
}
fn read_model_serial(name: &str) -> (Option<String>, Option<String>) {
let base = sys_block_path(name).join("device");
let model = read_optional_string(base.join("model"));
// Some devices expose "vendor" + "model"; if model missing, try "device/model" anyway
let serial = read_optional_string(base.join("serial"));
(model, serial)
}
fn read_optional_string(p: PathBuf) -> Option<String> {
match fs::read_to_string(&p) {
Ok(mut s) => {
// Trim trailing newline/spaces
while s.ends_with('\n') || s.ends_with('\r') {
s.pop();
}
if s.is_empty() {
None
} else {
Some(s)
}
}
Err(_) => None,
}
}
// =========================
// Tests (mock provider)
// =========================
#[cfg(test)]
mod tests {
use super::*;
use regex::Regex;
struct MockProvider {
disks: Vec<Disk>,
}
impl DeviceProvider for MockProvider {
fn list_block_devices(&self) -> Result<Vec<Disk>> {
Ok(self.disks.clone())
}
}
fn re(s: &str) -> Regex {
Regex::new(s).unwrap()
}
#[test]
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)
],
};
let filter = DeviceFilter {
include: vec![re(r"^/dev/(sd|nvme)")],
exclude: vec![re(r"/dev/loop")],
min_size_gib: 200, // >= 200 GiB
allow_removable: true,
};
let out = discover_with_provider(&provider, &filter).expect("discover ok");
assert_eq!(out.len(), 1);
assert_eq!(out[0].path, "/dev/sda");
}
#[test]
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
],
};
let filter = DeviceFilter {
include: vec![re(r"^/dev/nvme")],
exclude: vec![],
min_size_gib: 200,
allow_removable: true,
};
let err = discover_with_provider(&provider, &filter).unwrap_err();
match err {
Error::Device(msg) => assert!(msg.contains("no eligible disks")),
other => panic!("unexpected error: {:?}", other),
}
}
}