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:
366
src/device/discovery.rs
Normal file
366
src/device/discovery.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user