// REGION: API // api: idempotency::detect_existing_state() -> crate::Result> // api: idempotency::is_empty_disk(disk: &crate::device::Disk) -> crate::Result // 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> { 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 = Vec::new(); let mut fslabel_hits: Vec = 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 { 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 { 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> { 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 { 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")); } }