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:
284
src/idempotency/mod.rs
Normal file
284
src/idempotency/mod.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
// REGION: API
|
||||
// api: idempotency::detect_existing_state() -> crate::Result<Option<crate::report::StateReport>>
|
||||
// api: idempotency::is_empty_disk(disk: &crate::device::Disk) -> crate::Result<bool>
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Detect whether the system is already provisioned by probing GPT names and filesystem labels.
|
||||
// - Provide safe emptiness checks for target disks before any destructive operations.
|
||||
// Non-goals: performing changes; this module only inspects state.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
// REGION: EXTENSION_POINTS
|
||||
// ext: add heuristics for partial provisioning detection with guided remediation (future).
|
||||
// ext: support caching previous successful run state to speed up detection.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: reads only; must not write to devices. Use blkid and partition table reads where possible.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: probing errors -> crate::Error::Device or crate::Error::Other(anyhow) with context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//! Idempotency detection and disk emptiness probes.
|
||||
//!
|
||||
//! Provides helpers to detect whether the system is already provisioned
|
||||
//! (based on GPT names and filesystem labels), and to verify that target
|
||||
//! disks are empty before making any destructive changes.
|
||||
|
||||
use crate::{
|
||||
device::Disk,
|
||||
report::{StateReport, REPORT_VERSION},
|
||||
util::{run_cmd_capture, which_tool},
|
||||
Error, Result,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use humantime::format_rfc3339;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Return existing state if system is already provisioned; otherwise None.
|
||||
///
|
||||
/// Signals for provisioned state:
|
||||
/// - Expected GPT partition names present: "zosboot", "zosdata", and optional "zoscache"
|
||||
/// - Filesystem labels present: "ZOSBOOT" for ESP, "ZOSDATA" for data filesystems
|
||||
///
|
||||
/// Implementation notes:
|
||||
/// - Uses blkid -o export on discovered device nodes from /proc/partitions.
|
||||
/// - Missing blkid results in Ok(None) (cannot detect safely).
|
||||
pub fn detect_existing_state() -> Result<Option<StateReport>> {
|
||||
let Some(blkid) = which_tool("blkid")? else {
|
||||
warn!("blkid not found; skipping idempotency detection (assuming not provisioned)");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let names = read_proc_partitions_names()?;
|
||||
if names.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut partlabel_hits: Vec<serde_json::Value> = Vec::new();
|
||||
let mut fslabel_hits: Vec<serde_json::Value> = Vec::new();
|
||||
let mut saw_partlabel_zosboot = false;
|
||||
let mut saw_partlabel_zosdata = false;
|
||||
let mut saw_partlabel_zoscache = false;
|
||||
let mut saw_label_zosboot = false;
|
||||
let mut saw_label_zosdata = false;
|
||||
|
||||
for name in names {
|
||||
let dev_path = format!("/dev/{}", name);
|
||||
let args = [blkid.as_str(), "-o", "export", dev_path.as_str()];
|
||||
let map_opt = match run_cmd_capture(&args) {
|
||||
Ok(out) => Some(parse_blkid_export(&out.stdout)),
|
||||
Err(Error::Tool { status, .. }) if status != 0 => {
|
||||
// Typical when device has no recognizable signature; ignore.
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
// Unexpected failure; log and continue.
|
||||
warn!("blkid failed on {}: {:?}", dev_path, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(map) = map_opt {
|
||||
if let Some(pl) = map.get("PARTLABEL") {
|
||||
let pl_lc = pl.to_ascii_lowercase();
|
||||
if pl_lc == "zosboot" {
|
||||
saw_partlabel_zosboot = true;
|
||||
partlabel_hits.push(json!({ "device": dev_path, "partlabel": pl }));
|
||||
} else if pl_lc == "zosdata" {
|
||||
saw_partlabel_zosdata = true;
|
||||
partlabel_hits.push(json!({ "device": dev_path, "partlabel": pl }));
|
||||
} else if pl_lc == "zoscache" {
|
||||
saw_partlabel_zoscache = true;
|
||||
partlabel_hits.push(json!({ "device": dev_path, "partlabel": pl }));
|
||||
}
|
||||
}
|
||||
if let Some(lbl) = map.get("LABEL") {
|
||||
if lbl == "ZOSBOOT" {
|
||||
saw_label_zosboot = true;
|
||||
fslabel_hits.push(json!({ "device": dev_path, "label": lbl }));
|
||||
} else if lbl == "ZOSDATA" {
|
||||
saw_label_zosdata = true;
|
||||
fslabel_hits.push(json!({ "device": dev_path, "label": lbl }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consider provisioned when we see both boot and data signals.
|
||||
let boot_ok = saw_partlabel_zosboot || saw_label_zosboot;
|
||||
let data_ok = saw_partlabel_zosdata || saw_label_zosdata;
|
||||
|
||||
if boot_ok && data_ok {
|
||||
let ts = format_rfc3339(std::time::SystemTime::now()).to_string();
|
||||
let report = StateReport {
|
||||
version: REPORT_VERSION.to_string(),
|
||||
timestamp: ts,
|
||||
status: "already_provisioned".to_string(),
|
||||
disks: vec![], // can be enriched later
|
||||
partitions: partlabel_hits,
|
||||
filesystems: fslabel_hits,
|
||||
mounts: vec![],
|
||||
error: None,
|
||||
};
|
||||
debug!(
|
||||
"idempotency: already provisioned (boot_ok={}, data_ok={}, cache={})",
|
||||
boot_ok, data_ok, saw_partlabel_zoscache
|
||||
);
|
||||
return Ok(Some(report));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Determine if a disk is empty (no partitions and no known filesystem signatures).
|
||||
///
|
||||
/// Algorithm:
|
||||
/// - Parse /proc/partitions for any child partitions of the base device name.
|
||||
/// - Probe with blkid -p -o export on the whole-disk node:
|
||||
/// - Exit status 0 => recognized signature (PTTYPE or FS) -> not empty
|
||||
/// - Exit status 2 (typically "nothing found") -> treat as empty
|
||||
/// - Missing blkid -> conservative: not empty (return Ok(false))
|
||||
pub fn is_empty_disk(disk: &Disk) -> Result<bool> {
|
||||
let base = base_name(&disk.path)
|
||||
.ok_or_else(|| Error::Device(format!("invalid disk path: {}", disk.path)))?;
|
||||
|
||||
// Check for any child partitions listed in /proc/partitions.
|
||||
let names = read_proc_partitions_names()?;
|
||||
if names.iter().any(|n| is_partition_of(&base, n)) {
|
||||
debug!("disk {} has child partitions -> not empty", disk.path);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Probe with blkid -p
|
||||
let Some(blkid) = which_tool("blkid")? else {
|
||||
warn!("blkid not found; conservatively treating {} as not empty", disk.path);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let args = [blkid.as_str(), "-p", "-o", "export", disk.path.as_str()];
|
||||
match run_cmd_capture(&args) {
|
||||
Ok(_out) => {
|
||||
// Some signature recognized (filesystem or partition table)
|
||||
debug!("blkid found signatures on {} -> not empty", disk.path);
|
||||
Ok(false)
|
||||
}
|
||||
Err(Error::Tool { status, .. }) => {
|
||||
if status == 2 {
|
||||
// Nothing recognized by blkid
|
||||
debug!("blkid reports no signatures on {} -> empty", disk.path);
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(Error::Device(format!(
|
||||
"blkid unexpected status {} probing {}",
|
||||
status, disk.path
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(Error::Device(format!(
|
||||
"blkid probing error on {}: {}",
|
||||
disk.path, e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Helpers (module-private)
|
||||
// =========================
|
||||
|
||||
fn parse_blkid_export(s: &str) -> HashMap<String, String> {
|
||||
let mut map = 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
|
||||
}
|
||||
|
||||
fn read_proc_partitions_names() -> Result<Vec<String>> {
|
||||
let mut names = 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].to_string();
|
||||
// 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;
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn base_name(path: &str) -> Option<String> {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn is_partition_of(base: &str, name: &str) -> bool {
|
||||
if name == base {
|
||||
return false;
|
||||
}
|
||||
let ends_with_digit = base.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false);
|
||||
if ends_with_digit {
|
||||
// nvme0n1 -> nvme0n1p1
|
||||
if name.starts_with(base) {
|
||||
let rest = &name[base.len()..];
|
||||
return rest.starts_with('p') && rest[1..].chars().all(|c| c.is_ascii_digit());
|
||||
}
|
||||
false
|
||||
} else {
|
||||
// sda -> sda1
|
||||
name.starts_with(base) && name[base.len()..].chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_blkid_export_basic() {
|
||||
let s = "ID_FS_LABEL=ZOSDATA\nPARTLABEL=zosdata\nUUID=1234-ABCD\n";
|
||||
let m = parse_blkid_export(s);
|
||||
assert_eq!(m.get("ID_FS_LABEL").unwrap(), "ZOSDATA");
|
||||
assert_eq!(m.get("PARTLABEL").unwrap(), "zosdata");
|
||||
assert_eq!(m.get("UUID").unwrap(), "1234-ABCD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_partition_of_cases_sda_style() {
|
||||
// sda base: partitions sda1, sda2 are children; sdb is not
|
||||
assert!(is_partition_of("sda", "sda1"));
|
||||
assert!(is_partition_of("sda", "sda10"));
|
||||
assert!(!is_partition_of("sda", "sda"));
|
||||
assert!(!is_partition_of("sda", "sdb1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_partition_of_cases_nvme_style() {
|
||||
// nvme0n1 base: partitions nvme0n1p1, nvme0n1p10 are children; nvme0n2p1 is not
|
||||
assert!(is_partition_of("nvme0n1", "nvme0n1p1"));
|
||||
assert!(is_partition_of("nvme0n1", "nvme0n1p10"));
|
||||
assert!(!is_partition_of("nvme0n1", "nvme0n1"));
|
||||
assert!(!is_partition_of("nvme0n1", "nvme0n2p1"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user