apply mode: wire partition apply + mkfs; btrfs RAID1 flags and -f; UEFI detection and skip bios_boot when UEFI; sgdisk-based partition apply; update TODO and REGION markers
This commit is contained in:
@@ -113,6 +113,10 @@ pub struct Cli {
|
||||
/// Write detection/planning JSON report to the given path (overrides config.report.path)
|
||||
#[arg(long = "report")]
|
||||
pub report: Option<String>,
|
||||
|
||||
/// Execute destructive actions (apply mode). When false, runs preview-only.
|
||||
#[arg(long = "apply", default_value_t = false)]
|
||||
pub apply: bool,
|
||||
}
|
||||
|
||||
/// Parse CLI arguments (non-interactive; suitable for initramfs).
|
||||
|
||||
@@ -18,20 +18,21 @@
|
||||
// ext: dry-run mode to emit mkfs commands without executing (future).
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced.
|
||||
// safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS).
|
||||
// REGION: SAFETY-END
|
||||
// REGION: SAFETY
|
||||
// safety: must not run mkfs on non-empty or unexpected partitions; assume prior validation enforced.
|
||||
// safety: ensure labels follow reserved semantics (ZOSBOOT for ESP, ZOSDATA for all data FS).
|
||||
// safety: mkfs.btrfs uses -f in apply path immediately after partitioning to handle leftover signatures.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: external mkfs/blkid failures -> crate::Error::Tool with captured stderr.
|
||||
// errmap: planning mismatches -> crate::Error::Filesystem with context.
|
||||
// REGION: ERROR_MAPPING-END
|
||||
//
|
||||
// REGION: TODO
|
||||
// todo: implement mapping of topology to FsSpec including bcachefs cache/backing composition.
|
||||
// todo: implement mkfs invocation and UUID capture via util::run_cmd / util::run_cmd_capture.
|
||||
// REGION: TODO-END
|
||||
// REGION: TODO
|
||||
// todo: bcachefs tuning flags mapping from config (compression/checksum/cache_mode) deferred
|
||||
// todo: add UUID consistency checks across multi-device filesystems
|
||||
// REGION: TODO-END
|
||||
//! Filesystem planning and creation for zosstorage.
|
||||
//!
|
||||
//! Maps partition results to concrete filesystems (vfat, btrfs, bcachefs)
|
||||
@@ -220,8 +221,25 @@ pub fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>> {
|
||||
if spec.devices.is_empty() {
|
||||
return Err(Error::Filesystem("btrfs requires at least one device".into()));
|
||||
}
|
||||
// mkfs.btrfs -L LABEL dev1 [dev2 ...]
|
||||
// mkfs.btrfs -L LABEL [ -m raid1 -d raid1 (when multi-device/raid1) ] dev1 [dev2 ...]
|
||||
let mut args: Vec<String> = vec![mkfs.clone(), "-L".into(), spec.label.clone()];
|
||||
|
||||
// If this Btrfs is multi-device (as planned in BtrfsRaid1 topology),
|
||||
// set metadata/data profiles to raid1. This keeps plan/apply consistent.
|
||||
if spec.devices.len() >= 2 {
|
||||
args.push("-m".into());
|
||||
args.push("raid1".into());
|
||||
args.push("-d".into());
|
||||
args.push("raid1".into());
|
||||
}
|
||||
|
||||
// Note: compression is a mount-time option for btrfs; we will apply it in mount phase.
|
||||
// Leaving mkfs-time compression unset by design.
|
||||
|
||||
// Force formatting in apply path to avoid leftover signatures on freshly created partitions.
|
||||
// Safe because we just created these partitions in this run.
|
||||
args.push("-f".into());
|
||||
|
||||
args.extend(spec.devices.iter().cloned());
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_cmd(&args_ref)?;
|
||||
@@ -244,6 +262,8 @@ pub fn make_filesystems(plan: &FsPlan) -> Result<Vec<FsResult>> {
|
||||
return Err(Error::Filesystem("bcachefs requires at least two devices (cache + backing)".into()));
|
||||
}
|
||||
// bcachefs format --label LABEL dev_cache dev_backing ...
|
||||
// TODO(fs): map compression/checksum/cache-mode flags from config in a follow-up.
|
||||
// This is deferred per current scope to focus on btrfs RAID profile wiring.
|
||||
let mut args: Vec<String> = vec![mkfs.clone(), "format".into(), "--label".into(), spec.label.clone()];
|
||||
args.extend(spec.devices.iter().cloned());
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
@@ -51,6 +51,7 @@ fn real_main() -> Result<()> {
|
||||
|
||||
let ctx = orchestrator::Context::new(cfg, log_opts)
|
||||
.with_show(cli.show)
|
||||
.with_apply(cli.apply)
|
||||
.with_report_path(cli.report.clone());
|
||||
orchestrator::run(&ctx)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ use crate::{
|
||||
device::{discover, DeviceFilter, Disk},
|
||||
idempotency,
|
||||
partition,
|
||||
fs as zfs,
|
||||
Error, Result,
|
||||
};
|
||||
use humantime::format_rfc3339;
|
||||
@@ -66,6 +67,8 @@ pub struct Context {
|
||||
pub log: LogOptions,
|
||||
/// When true, print detection and planning summary to stdout (JSON).
|
||||
pub show: bool,
|
||||
/// When true, perform destructive actions (apply mode).
|
||||
pub apply: bool,
|
||||
/// Optional report path override (when provided by CLI --report).
|
||||
pub report_path_override: Option<String>,
|
||||
}
|
||||
@@ -77,6 +80,7 @@ impl Context {
|
||||
cfg,
|
||||
log,
|
||||
show: false,
|
||||
apply: false,
|
||||
report_path_override: None,
|
||||
}
|
||||
}
|
||||
@@ -93,6 +97,16 @@ impl Context {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable apply mode (destructive).
|
||||
///
|
||||
/// When set to true (e.g. via `--apply`), orchestrator:
|
||||
/// - Enforces empty-disk policy (unless disabled in config)
|
||||
/// - Applies partition plan, then (future) mkfs, mounts, and report
|
||||
pub fn with_apply(mut self, apply: bool) -> Self {
|
||||
self.apply = apply;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the report output path used by preview mode.
|
||||
///
|
||||
/// When provided (e.g. via `--report /path/file.json`), orchestrator:
|
||||
@@ -171,11 +185,27 @@ pub fn run(ctx: &Context) -> Result<()> {
|
||||
debug!("plan for {}: {} part(s)", dp.disk.path, dp.parts.len());
|
||||
}
|
||||
|
||||
// Note:
|
||||
// - Applying partitions, creating filesystems, mounting, and reporting
|
||||
// will be wired in subsequent steps. For now this performs pre-flight
|
||||
// checks and planning to exercise real code paths safely.
|
||||
// Apply mode: perform destructive partition application now.
|
||||
if ctx.apply {
|
||||
info!("orchestrator: apply mode enabled; applying partition plan");
|
||||
let part_results = partition::apply_partitions(&plan)?;
|
||||
info!(
|
||||
"orchestrator: applied partitions on {} disk(s), total parts created: {}",
|
||||
plan.disks.len(),
|
||||
part_results.len()
|
||||
);
|
||||
|
||||
// Filesystem planning and creation
|
||||
let fs_plan = zfs::plan_filesystems(&part_results, &ctx.cfg)?;
|
||||
info!("orchestrator: filesystem plan contains {} spec(s)", fs_plan.specs.len());
|
||||
let fs_results = zfs::make_filesystems(&fs_plan)?;
|
||||
info!("orchestrator: created {} filesystem(s)", fs_results.len());
|
||||
|
||||
// Next steps (mounts, optional fstab, state report) will be wired in follow-ups.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Preview-only path
|
||||
info!("orchestrator: pre-flight complete (idempotency checked, devices discovered, plan computed)");
|
||||
|
||||
// Optional: emit JSON summary via --show or write via --report
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
// ext: device-specific alignment or reserved areas configurable via cfg in the future.
|
||||
// REGION: EXTENSION_POINTS-END
|
||||
//
|
||||
// REGION: SAFETY
|
||||
// safety: must verify require_empty_disks before any modification.
|
||||
// safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT).
|
||||
// safety: must call udev settle after partition table writes.
|
||||
// REGION: SAFETY-END
|
||||
// REGION: SAFETY
|
||||
// safety: must verify require_empty_disks before any modification.
|
||||
// safety: when UEFI-booted, suppress creating BIOS boot partition to avoid unnecessary ef02 on UEFI systems.
|
||||
// safety: must ensure unique partition GUIDs; identical labels are allowed when expected (e.g., ESP ZOSBOOT).
|
||||
// safety: must call udev settle after partition table writes.
|
||||
// REGION: SAFETY-END
|
||||
//
|
||||
// REGION: ERROR_MAPPING
|
||||
// errmap: external tool failure -> crate::Error::Tool { tool, status, stderr }.
|
||||
@@ -42,7 +43,14 @@
|
||||
//! See [fn plan_partitions](plan.rs:1) and
|
||||
//! [fn apply_partitions](plan.rs:1).
|
||||
|
||||
use crate::{types::{Config, Topology}, device::Disk, Error, Result};
|
||||
use crate::{
|
||||
types::{Config, Topology},
|
||||
device::Disk,
|
||||
util::{run_cmd, run_cmd_capture, which_tool, udev_settle, is_efi_boot},
|
||||
idempotency,
|
||||
Error, Result,
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Partition roles supported by zosstorage.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -126,6 +134,8 @@ pub struct PartitionResult {
|
||||
pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
let align = cfg.partitioning.alignment_mib;
|
||||
let require_empty = cfg.partitioning.require_empty_disks;
|
||||
// If system booted via UEFI, suppress the BIOS boot partition even if enabled in config.
|
||||
let add_bios = cfg.partitioning.bios_boot.enabled && !is_efi_boot();
|
||||
|
||||
if disks.is_empty() {
|
||||
return Err(Error::Partition("no disks provided to partition planner".into()));
|
||||
@@ -137,7 +147,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
Topology::Single => {
|
||||
let d0 = &disks[0];
|
||||
let mut parts = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
if add_bios {
|
||||
parts.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
@@ -165,7 +175,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
|
||||
// Disk 0: BIOS (opt) + ESP + Data
|
||||
let mut parts0 = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
if add_bios {
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
@@ -202,7 +212,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
|
||||
// Disk 0: BIOS (opt) + ESP + Data
|
||||
let mut parts0 = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
if add_bios {
|
||||
parts0.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
@@ -239,7 +249,7 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
|
||||
// SSD: BIOS (opt) + ESP + Cache remainder
|
||||
let mut parts_ssd = Vec::new();
|
||||
if cfg.partitioning.bios_boot.enabled {
|
||||
if add_bios {
|
||||
parts_ssd.push(PartitionSpec {
|
||||
role: PartRole::BiosBoot,
|
||||
size_mib: Some(cfg.partitioning.bios_boot.size_mib),
|
||||
@@ -276,13 +286,177 @@ pub fn plan_partitions(disks: &[Disk], cfg: &Config) -> Result<PartitionPlan> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the partition plan using system utilities (sgdisk) via util wrappers.
|
||||
///
|
||||
/// Safety:
|
||||
/// - Must verify target disks are empty when required.
|
||||
/// - Must ensure unique partition GUIDs.
|
||||
/// - Should call udev settle after changes.
|
||||
pub fn apply_partitions(_plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
|
||||
// To be implemented: sgdisk orchestration + udev settle + GUID collection
|
||||
todo!("shell out to sgdisk, trigger udev settle, collect partition GUIDs")
|
||||
/**
|
||||
Apply the partition plan using system utilities (sgdisk) via util wrappers.
|
||||
|
||||
Safety:
|
||||
- Verifies target disks are empty when required (defense-in-depth; orchestrator should also enforce).
|
||||
- Ensures unique partition GUIDs by relying on sgdisk defaults.
|
||||
- Calls udev settle after changes to ensure /dev nodes exist.
|
||||
|
||||
Notes:
|
||||
- Uses sgdisk -og to create a new GPT on empty disks.
|
||||
- Adds partitions in declared order using -n (auto-aligned), -t (type code), -c (GPT name).
|
||||
- Derives partition device paths: NVMe uses "pN" suffix; others use trailing "N".
|
||||
- Captures per-partition GUID and geometry via `sgdisk -i <N> <disk>`.
|
||||
*/
|
||||
pub fn apply_partitions(plan: &PartitionPlan) -> Result<Vec<PartitionResult>> {
|
||||
// Locate required tools
|
||||
let Some(sgdisk) = which_tool("sgdisk")? else {
|
||||
return Err(Error::Partition("sgdisk not found in PATH".into()));
|
||||
};
|
||||
|
||||
// Helper: map role to GPT type code (gdisk codes)
|
||||
fn type_code(role: PartRole) -> &'static str {
|
||||
match role {
|
||||
PartRole::BiosBoot => "ef02", // BIOS boot partition (for GRUB BIOS on GPT)
|
||||
PartRole::Esp => "ef00", // EFI System Partition
|
||||
PartRole::Data => "8300", // Linux filesystem
|
||||
PartRole::Cache => "8300", // Treat cache as Linux filesystem (bcachefs)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: build partition device path for a given disk and partition number
|
||||
fn part_dev_path(disk_path: &str, part_number: u32) -> String {
|
||||
if disk_path.starts_with("/dev/nvme") {
|
||||
format!("{disk_path}p{part_number}")
|
||||
} else {
|
||||
format!("{disk_path}{part_number}")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: sector size in bytes for disk (fallback 512 with warning)
|
||||
fn sector_size_bytes(disk_path: &str) -> Result<u64> {
|
||||
if let Some(blockdev) = which_tool("blockdev")? {
|
||||
let out = run_cmd_capture(&[blockdev.as_str(), "--getss", disk_path])?;
|
||||
let s = out.stdout.trim();
|
||||
return s.parse::<u64>()
|
||||
.map_err(|e| Error::Partition(format!("failed to parse sector size from blockdev for {}: {}", disk_path, e)));
|
||||
}
|
||||
warn!("blockdev not found; assuming 512-byte sectors for {}", disk_path);
|
||||
Ok(512)
|
||||
}
|
||||
|
||||
// Helper: parse sgdisk -i output to (unique_guid, first_sector, last_sector)
|
||||
fn parse_sgdisk_info(info: &str) -> Result<(String, u64, u64)> {
|
||||
let mut guid = String::new();
|
||||
let mut first: Option<u64> = None;
|
||||
let mut last: Option<u64> = None;
|
||||
|
||||
for line in info.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("Partition unique GUID:") {
|
||||
guid = rest.trim().to_string();
|
||||
} else if let Some(rest) = line.strip_prefix("First sector:") {
|
||||
// Format: "First sector: 2048 (at 1024.0 KiB)"
|
||||
let val = rest.trim().split_whitespace().next().unwrap_or("");
|
||||
if !val.is_empty() {
|
||||
first = Some(val.parse::<u64>().map_err(|e| Error::Partition(format!("parse first sector: {}", e)))?);
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("Last sector:") {
|
||||
let val = rest.trim().split_whitespace().next().unwrap_or("");
|
||||
if !val.is_empty() {
|
||||
last = Some(val.parse::<u64>().map_err(|e| Error::Partition(format!("parse last sector: {}", e)))?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first = first.ok_or_else(|| Error::Partition("sgdisk -i missing First sector".into()))?;
|
||||
let last = last.ok_or_else(|| Error::Partition("sgdisk -i missing Last sector".into()))?;
|
||||
if guid.is_empty() {
|
||||
return Err(Error::Partition("sgdisk -i missing Partition unique GUID".into()));
|
||||
}
|
||||
Ok((guid, first, last))
|
||||
}
|
||||
|
||||
let mut results: Vec<PartitionResult> = Vec::new();
|
||||
|
||||
for dp in &plan.disks {
|
||||
let disk_path = dp.disk.path.as_str();
|
||||
|
||||
// Defense-in-depth: verify emptiness when required
|
||||
if plan.require_empty_disks {
|
||||
let empty = idempotency::is_empty_disk(&dp.disk)?;
|
||||
if !empty {
|
||||
return Err(Error::Validation(format!(
|
||||
"target disk {} is not empty (partitions or signatures present)",
|
||||
dp.disk.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
debug!("apply_partitions: creating GPT on {}", disk_path);
|
||||
// Initialize (or re-initialize) a new empty GPT; requires truly empty disks per policy
|
||||
run_cmd(&[sgdisk.as_str(), "-og", disk_path])?;
|
||||
|
||||
// Create partitions in order
|
||||
for (idx0, spec) in dp.parts.iter().enumerate() {
|
||||
let part_num = (idx0 as u32) + 1;
|
||||
let size_arg = match spec.size_mib {
|
||||
Some(mib) => format!("+{}M", mib), // rely on sgdisk MiB suffix support
|
||||
None => String::from("0"), // consume remainder
|
||||
};
|
||||
// Use automatic aligned start (0) and specified size
|
||||
let n_arg = format!("{}:0:{}", part_num, size_arg);
|
||||
let t_arg = format!("{}:{}", part_num, type_code(spec.role));
|
||||
let c_arg = format!("{}:{}", part_num, spec.gpt_name);
|
||||
|
||||
debug!(
|
||||
"apply_partitions: {} -n {} -t {} -c {} {}",
|
||||
sgdisk, n_arg, t_arg, c_arg, disk_path
|
||||
);
|
||||
|
||||
run_cmd(&[
|
||||
sgdisk.as_str(),
|
||||
"-n", n_arg.as_str(),
|
||||
"-t", t_arg.as_str(),
|
||||
"-c", c_arg.as_str(),
|
||||
disk_path,
|
||||
])?;
|
||||
}
|
||||
|
||||
// Settle udev so new partitions appear under /dev
|
||||
udev_settle(5_000)?;
|
||||
|
||||
// Gather per-partition details and build results
|
||||
let sector_bytes = sector_size_bytes(disk_path)?;
|
||||
let mib_div: u64 = 1024 * 1024;
|
||||
|
||||
for (idx0, spec) in dp.parts.iter().enumerate() {
|
||||
let part_num = (idx0 as u32) + 1;
|
||||
|
||||
// Query sgdisk for partition info
|
||||
let i_arg = format!("{}", part_num);
|
||||
let info_out = run_cmd_capture(&[
|
||||
sgdisk.as_str(),
|
||||
"-i", i_arg.as_str(),
|
||||
disk_path,
|
||||
])?;
|
||||
|
||||
let (unique_guid, first_sector, last_sector) = parse_sgdisk_info(&info_out.stdout)?;
|
||||
let sectors = if last_sector >= first_sector {
|
||||
last_sector - first_sector + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let start_mib = (first_sector.saturating_mul(sector_bytes)) / mib_div;
|
||||
let size_mib = (sectors.saturating_mul(sector_bytes)) / mib_div;
|
||||
|
||||
let dev_path = part_dev_path(disk_path, part_num);
|
||||
|
||||
results.push(PartitionResult {
|
||||
disk: dp.disk.path.clone(),
|
||||
part_number: part_num,
|
||||
role: spec.role,
|
||||
gpt_name: spec.gpt_name.clone(),
|
||||
uuid: unique_guid,
|
||||
start_mib,
|
||||
size_mib,
|
||||
device_path: dev_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debug!("apply_partitions: created {} partition entries", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
@@ -4,11 +4,13 @@
|
||||
// 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<()>
|
||||
// api: util::is_efi_boot() -> bool
|
||||
// REGION: API-END
|
||||
//
|
||||
// REGION: RESPONSIBILITIES
|
||||
// - Centralize external tool discovery and invocation (sgdisk, blkid, mkfs.*, udevadm).
|
||||
// - Provide capture and error mapping to crate::Error consistently.
|
||||
// - Provide environment helpers (udev settle, boot mode detection).
|
||||
// Non-goals: business logic (planning/validation), direct parsing of complex outputs beyond what callers need.
|
||||
// REGION: RESPONSIBILITIES-END
|
||||
//
|
||||
@@ -39,6 +41,7 @@
|
||||
|
||||
use crate::{Error, Result};
|
||||
use std::process::Command;
|
||||
use std::path::Path;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Captured output from an external tool invocation.
|
||||
@@ -147,6 +150,14 @@ pub fn udev_settle(timeout_ms: u64) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect whether the current system booted via UEFI (initramfs-friendly).
|
||||
///
|
||||
/// Returns true when /sys/firmware/efi exists (standard on UEFI boots).
|
||||
/// Returns false on legacy BIOS boots where that path is absent.
|
||||
pub fn is_efi_boot() -> bool {
|
||||
Path::new("/sys/firmware/efi").exists()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user