Files
zosstorage/src/util/mod.rs
Jan De Landtsheer 507bc172c2 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.
2025-09-29 11:37:07 +02:00

197 lines
6.9 KiB
Rust

// REGION: API
// api: util::CmdOutput { status: i32, stdout: String, stderr: String }
// api: util::which_tool(name: &str) -> crate::Result<Option<String>>
// api: util::run_cmd(args: &[&str]) -> crate::Result<()>
// api: util::run_cmd_capture(args: &[&str]) -> crate::Result<CmdOutput>
// api: util::udev_settle(timeout_ms: u64) -> crate::Result<()>
// REGION: API-END
//
// REGION: RESPONSIBILITIES
// - Centralize external tool discovery and invocation (sgdisk, blkid, mkfs.*, udevadm).
// - Provide capture and error mapping to crate::Error consistently.
// Non-goals: business logic (planning/validation), direct parsing of complex outputs beyond what callers need.
// REGION: RESPONSIBILITIES-END
//
// REGION: EXTENSION_POINTS
// ext: pluggable command runner for tests/dry-run; inject via cfg(test) or trait in future.
// ext: backoff/retry policies for transient tool failures (feature-gated).
// REGION: EXTENSION_POINTS-END
//
// REGION: SAFETY
// safety: never mutate state here except invoking intended external tools; callers enforce preconditions.
// safety: capture stderr/stdout to aid diagnostics without leaking sensitive data.
// REGION: SAFETY-END
//
// REGION: ERROR_MAPPING
// errmap: process::ExitStatus non-zero -> crate::Error::Tool { tool, status, stderr }.
// errmap: IO/spawn errors -> crate::Error::Other(anyhow) with context.
// REGION: ERROR_MAPPING-END
//
// REGION: TODO
// todo: implement which_tool via 'which' crate; consider PATH overrides in initramfs.
// todo: implement run_cmd and run_cmd_capture with tracing spans.
// todo: implement udev_settle to no-op if udevadm missing, with warn-level log.
// REGION: TODO-END
//! Utility helpers for external tool invocation and system integration.
//!
//! All shell-outs are centralized here to enable testing, structured logging,
//! and consistent error handling.
use crate::{Error, Result};
use std::process::Command;
use tracing::{debug, warn};
/// Captured output from an external tool invocation.
#[derive(Debug, Clone)]
pub struct CmdOutput {
/// Process exit status code.
pub status: i32,
/// Captured stdout as UTF-8 (lossy if needed).
pub stdout: String,
/// Captured stderr as UTF-8 (lossy if needed).
pub stderr: String,
}
/// Locate the absolute path to a required tool if available in PATH.
///
/// Returns Ok(Some(path)) when found, Ok(None) when missing.
pub fn which_tool(name: &str) -> Result<Option<String>> {
match which::which(name) {
Ok(path) => Ok(Some(path.to_string_lossy().into_owned())),
Err(which::Error::CannotFindBinaryPath) => Ok(None),
Err(e) => Err(Error::Other(anyhow::anyhow!("which({name}) failed: {e}"))),
}
}
/// Run a command and return Ok if the exit status is zero.
///
/// args[0] must be the program binary path; the rest are arguments.
/// On non-zero exit, returns Error::Tool with captured stderr.
pub fn run_cmd(args: &[&str]) -> Result<()> {
if args.is_empty() {
return Err(Error::Other(anyhow::anyhow!(
"run_cmd requires at least one arg (the program)"
)));
}
debug!(target: "util.run_cmd", "exec: {:?}", args);
let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| {
Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e))
})?;
let status_code = output.status.code().unwrap_or(-1);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(Error::Tool {
tool: args[0].to_string(),
status: status_code,
stderr,
});
}
Ok(())
}
/// Run a command and capture stdout/stderr for parsing (e.g., blkid).
///
/// On non-zero exit, returns Error::Tool with captured stderr and status.
pub fn run_cmd_capture(args: &[&str]) -> Result<CmdOutput> {
if args.is_empty() {
return Err(Error::Other(anyhow::anyhow!(
"run_cmd_capture requires at least one arg (the program)"
)));
}
debug!(target: "util.run_cmd_capture", "exec: {:?}", args);
let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| {
Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e))
})?;
let status_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(Error::Tool {
tool: args[0].to_string(),
status: status_code,
stderr,
});
}
Ok(CmdOutput {
status: status_code,
stdout,
stderr,
})
}
/// Call udevadm settle with a timeout; warn if unavailable, then no-op.
///
/// Ensures the system has settled after partition table changes.
pub fn udev_settle(timeout_ms: u64) -> Result<()> {
// Locate udevadm
let Some(udevadm) = which_tool("udevadm")? else {
warn!("udevadm not found; skipping udev settle");
return Ok(());
};
let timeout_arg = format!("--timeout={}", timeout_ms / 1000); // udevadm takes seconds; floor
// Some implementations accept milliseconds if provided without units; prefer seconds for portability.
let args = [udevadm.as_str(), "settle", timeout_arg.as_str()];
// We intentionally ignore non-zero exit as some initramfs may not have udev running.
match Command::new(args[0]).args(&args[1..]).status() {
Ok(_status) => {
debug!("udevadm settle invoked");
Ok(())
}
Err(e) => {
warn!("failed to invoke udevadm settle: {}", e);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn which_tool_finds_sh() {
// 'sh' should exist on virtually all Linux systems
let p = which_tool("sh").expect("which_tool failed");
assert!(p.is_some(), "expected to find 'sh' in PATH");
}
#[test]
fn which_tool_missing() {
let p = which_tool("definitely-not-a-cmd-xyz").expect("which_tool failed");
assert!(p.is_none());
}
#[test]
fn run_cmd_true_ok() {
// Use sh -c true to ensure availability
run_cmd(&["sh", "-c", "true"]).expect("true should succeed");
}
#[test]
fn run_cmd_false_err() {
let err = run_cmd(&["sh", "-c", "false"]).unwrap_err();
match err {
Error::Tool { tool, status, .. } => {
assert_eq!(tool, "sh");
assert_ne!(status, 0);
}
other => panic!("expected Error::Tool, got: {:?}", other),
}
}
#[test]
fn run_cmd_capture_echo_stdout() {
let out = run_cmd_capture(&["sh", "-c", "printf hello"]).expect("capture ok");
assert_eq!(out.stdout, "hello");
assert_eq!(out.status, 0);
}
#[test]
fn udev_settle_ok_even_if_missing() {
// Should never fail even if udevadm is missing.
udev_settle(1000).expect("udev_settle should be non-fatal");
}
}