Refine default orchestration flow and documentation

- Document defaults-only configuration, kernel topology override, and deprecated CLI flags in README
- Mark schema doc as deprecated per ADR-0002
- Warn that --topology/--config are ignored; adjust loader/main/context flow
- Refactor orchestrator run() to auto-select mount/apply, reuse state when already provisioned, and serialize topology via Display
- Add Callgraph/FUNCTION_LIST/ADR docs tracking the new behavior
- Derive Eq for Topology to satisfy updated CLI handling
This commit is contained in:
2025-10-09 16:51:12 +02:00
parent d374176c0b
commit c8b76a2a3d
11 changed files with 5405 additions and 297 deletions

View File

@@ -43,11 +43,12 @@
//! - Report generation and write
use crate::{
types::Config,
types::{Config, Topology},
logging::LogOptions,
device::{discover, DeviceFilter, Disk},
idempotency,
partition,
report::StateReport,
fs as zfs,
Error, Result,
};
@@ -75,6 +76,10 @@ pub struct Context {
pub report_current: bool,
/// Optional report path override (when provided by CLI --report).
pub report_path_override: Option<String>,
/// True when topology was provided via CLI (--topology), giving it precedence.
pub topo_from_cli: bool,
/// True when topology was provided via kernel cmdline, giving it precedence if CLI omitted it.
pub topo_from_cmdline: bool,
}
impl Context {
@@ -88,6 +93,8 @@ impl Context {
mount_existing: false,
report_current: false,
report_path_override: None,
topo_from_cli: false,
topo_from_cmdline: false,
}
}
@@ -136,6 +143,44 @@ impl Context {
self.report_current = report_current;
self
}
/// Mark that topology was provided via CLI (--topology).
pub fn with_topology_from_cli(mut self, v: bool) -> Self {
self.topo_from_cli = v;
self
}
/// Mark that topology was provided via kernel cmdline (zosstorage.topology=).
pub fn with_topology_from_cmdline(mut self, v: bool) -> Self {
self.topo_from_cmdline = v;
self
}
}
#[derive(Debug, Clone, Copy)]
enum ProvisioningMode {
Apply,
Preview,
}
#[derive(Debug, Clone, Copy)]
enum AutoDecision {
Apply,
MountExisting,
}
#[derive(Debug)]
struct AutoSelection {
decision: AutoDecision,
fs_results: Option<Vec<zfs::FsResult>>,
state: Option<StateReport>,
}
#[derive(Debug, Clone, Copy)]
enum ExecutionMode {
ReportCurrent,
MountExisting,
Apply,
Preview,
Auto,
}
/// Run the one-shot provisioning flow.
@@ -143,9 +188,8 @@ impl Context {
/// Returns Ok(()) on success and also on success-noop when already provisioned.
/// Any validation or execution failure aborts with an error.
pub fn run(ctx: &Context) -> Result<()> {
info!("orchestrator: starting run() with topology {:?}", ctx.cfg.topology);
info!("orchestrator: starting run()");
// Enforce mutually exclusive execution modes among: --mount-existing, --report-current, --apply
let selected_modes =
(ctx.mount_existing as u8) +
(ctx.report_current as u8) +
@@ -156,106 +200,166 @@ pub fn run(ctx: &Context) -> Result<()> {
));
}
// Mode 1: Mount existing filesystems (non-destructive), based on on-disk headers.
if ctx.mount_existing {
info!("orchestrator: mount-existing mode");
let fs_results = zfs::probe_existing_filesystems()?;
if fs_results.is_empty() {
return Err(Error::Mount(
"no existing filesystems with reserved labels (ZOSBOOT/ZOSDATA) were found".into(),
));
}
let mplan = crate::mount::plan_mounts(&fs_results, &ctx.cfg)?;
let mres = crate::mount::apply_mounts(&mplan)?;
crate::mount::maybe_write_fstab(&mres, &ctx.cfg)?;
let preview_requested = ctx.show || ctx.report_path_override.is_some();
// Optional JSON summary for mount-existing
if ctx.show || ctx.report_path_override.is_some() || ctx.report_current {
let now = format_rfc3339(SystemTime::now()).to_string();
let fs_json: Vec<serde_json::Value> = fs_results
.iter()
.map(|r| {
let kind_str = match r.kind {
zfs::FsKind::Vfat => "vfat",
zfs::FsKind::Btrfs => "btrfs",
zfs::FsKind::Bcachefs => "bcachefs",
};
json!({
"kind": kind_str,
"uuid": r.uuid,
"label": r.label,
"devices": r.devices,
})
})
.collect();
let initial_mode = if ctx.report_current {
ExecutionMode::ReportCurrent
} else if ctx.mount_existing {
ExecutionMode::MountExisting
} else if ctx.apply {
ExecutionMode::Apply
} else if preview_requested {
ExecutionMode::Preview
} else {
ExecutionMode::Auto
};
let mounts_json: Vec<serde_json::Value> = mres
.iter()
.map(|m| {
json!({
"source": m.source,
"target": m.target,
"fstype": m.fstype,
"options": m.options,
})
})
.collect();
let summary = json!({
"version": "v1",
"timestamp": now,
"status": "mounted_existing",
"filesystems": fs_json,
"mounts": mounts_json,
});
if ctx.show || ctx.report_current {
println!("{}", summary);
}
if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| {
Error::Report(format!("failed to write report to {}: {}", path, e))
})?;
info!("orchestrator: wrote mount-existing report to {}", path);
match initial_mode {
ExecutionMode::ReportCurrent => run_report_current(ctx),
ExecutionMode::MountExisting => run_mount_existing(ctx, None, None),
ExecutionMode::Apply => run_provisioning(ctx, ProvisioningMode::Apply, None),
ExecutionMode::Preview => run_provisioning(ctx, ProvisioningMode::Preview, None),
ExecutionMode::Auto => {
let selection = auto_select_mode(ctx)?;
match selection.decision {
AutoDecision::MountExisting => {
run_mount_existing(ctx, selection.fs_results, selection.state)
}
AutoDecision::Apply => {
run_provisioning(ctx, ProvisioningMode::Apply, selection.state)
}
}
}
}
}
return Ok(());
fn auto_select_mode(ctx: &Context) -> Result<AutoSelection> {
info!("orchestrator: auto-selecting execution mode");
let state = idempotency::detect_existing_state()?;
let fs_results = zfs::probe_existing_filesystems()?;
if let Some(state) = state {
info!("orchestrator: provisioned state detected; attempting mount-existing flow");
return Ok(AutoSelection {
decision: AutoDecision::MountExisting,
fs_results: if fs_results.is_empty() { None } else { Some(fs_results) },
state: Some(state),
});
}
// Mode 3: Report current initialized filesystems and mounts (non-destructive).
if ctx.report_current {
info!("orchestrator: report-current mode");
let fs_results = zfs::probe_existing_filesystems()?;
if !fs_results.is_empty() {
info!(
"orchestrator: detected {} filesystem(s) with reserved labels; selecting mount-existing",
fs_results.len()
);
return Ok(AutoSelection {
decision: AutoDecision::MountExisting,
fs_results: Some(fs_results),
state: None,
});
}
// Parse /proc/mounts and include only our relevant targets.
let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default();
let mounts_json: Vec<serde_json::Value> = mounts_content
.lines()
.filter_map(|line| {
let mut it = line.split_whitespace();
let source = it.next()?;
let target = it.next()?;
let fstype = it.next()?;
let options = it.next().unwrap_or("");
if target.starts_with("/var/mounts/")
|| target == "/var/cache/system"
|| target == "/var/cache/etc"
|| target == "/var/cache/modules"
|| target == "/var/cache/vm-meta"
{
Some(json!({
"source": source,
"target": target,
"fstype": fstype,
"options": options
}))
} else {
None
}
info!(
"orchestrator: no provisioned state or labeled filesystems detected; selecting apply mode (topology={:?})",
ctx.cfg.topology
);
Ok(AutoSelection {
decision: AutoDecision::Apply,
fs_results: None,
state: None,
})
}
fn run_report_current(ctx: &Context) -> Result<()> {
info!("orchestrator: report-current mode");
let fs_results = zfs::probe_existing_filesystems()?;
let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default();
let mounts_json: Vec<serde_json::Value> = mounts_content
.lines()
.filter_map(|line| {
let mut it = line.split_whitespace();
let source = it.next()?;
let target = it.next()?;
let fstype = it.next()?;
let options = it.next().unwrap_or("");
if target.starts_with("/var/mounts/")
|| target == "/var/cache/system"
|| target == "/var/cache/etc"
|| target == "/var/cache/modules"
|| target == "/var/cache/vm-meta"
{
Some(json!({
"source": source,
"target": target,
"fstype": fstype,
"options": options
}))
} else {
None
}
})
.collect();
let fs_json: Vec<serde_json::Value> = fs_results
.iter()
.map(|r| {
let kind_str = match r.kind {
zfs::FsKind::Vfat => "vfat",
zfs::FsKind::Btrfs => "btrfs",
zfs::FsKind::Bcachefs => "bcachefs",
};
json!({
"kind": kind_str,
"uuid": r.uuid,
"label": r.label,
"devices": r.devices
})
.collect();
})
.collect();
let now = format_rfc3339(SystemTime::now()).to_string();
let summary = json!({
"version": "v1",
"timestamp": now,
"status": "observed",
"filesystems": fs_json,
"mounts": mounts_json
});
println!("{}", summary);
if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| {
Error::Report(format!("failed to write report to {}: {}", path, e))
})?;
info!("orchestrator: wrote report-current to {}", path);
}
Ok(())
}
fn run_mount_existing(
ctx: &Context,
fs_results_override: Option<Vec<zfs::FsResult>>,
state_hint: Option<StateReport>,
) -> Result<()> {
info!("orchestrator: mount-existing mode");
let fs_results = match fs_results_override {
Some(results) => results,
None => zfs::probe_existing_filesystems()?,
};
if fs_results.is_empty() {
return Err(Error::Mount(
"no existing filesystems with reserved labels (ZOSBOOT/ZOSDATA) were found".into(),
));
}
let mplan = crate::mount::plan_mounts(&fs_results, &ctx.cfg)?;
let mres = crate::mount::apply_mounts(&mplan)?;
crate::mount::maybe_write_fstab(&mres, &ctx.cfg)?;
if ctx.show || ctx.report_path_override.is_some() || ctx.report_current {
let now = format_rfc3339(SystemTime::now()).to_string();
let fs_json: Vec<serde_json::Value> = fs_results
.iter()
.map(|r| {
@@ -268,82 +372,108 @@ pub fn run(ctx: &Context) -> Result<()> {
"kind": kind_str,
"uuid": r.uuid,
"label": r.label,
"devices": r.devices
"devices": r.devices,
})
})
.collect();
let now = format_rfc3339(SystemTime::now()).to_string();
let summary = json!({
let mounts_json: Vec<serde_json::Value> = mres
.iter()
.map(|m| {
json!({
"source": m.source,
"target": m.target,
"fstype": m.fstype,
"options": m.options,
})
})
.collect();
let mut summary = json!({
"version": "v1",
"timestamp": now,
"status": "observed",
"status": "mounted_existing",
"filesystems": fs_json,
"mounts": mounts_json
"mounts": mounts_json,
});
// In report-current mode, default to stdout; also honor --report path when provided.
println!("{}", summary);
if let Some(state) = state_hint {
if let Ok(state_json) = to_value(&state) {
if let Some(obj) = summary.as_object_mut() {
obj.insert("state".to_string(), state_json);
}
}
}
if ctx.show || ctx.report_current {
println!("{}", summary);
}
if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| {
Error::Report(format!("failed to write report to {}: {}", path, e))
})?;
info!("orchestrator: wrote report-current to {}", path);
}
return Ok(());
}
// Default path: plan (and optionally apply) for empty-disk initialization workflow.
// 1) Idempotency pre-flight: if already provisioned, optionally emit summary then exit success.
match idempotency::detect_existing_state()? {
Some(state) => {
info!("orchestrator: already provisioned");
if ctx.show || ctx.report_path_override.is_some() {
let now = format_rfc3339(SystemTime::now()).to_string();
let state_json = to_value(&state)
.map_err(|e| Error::Report(format!("failed to serialize StateReport: {}", e)))?;
let summary = json!({
"version": "v1",
"timestamp": now,
"status": "already_provisioned",
"state": state_json
});
if ctx.show {
println!("{}", summary);
}
if let Some(path) = &ctx.report_path_override {
fs::write(path, summary.to_string()).map_err(|e| {
Error::Report(format!("failed to write report to {}: {}", path, e))
})?;
info!("orchestrator: wrote idempotency report to {}", path);
}
}
return Ok(());
}
None => {
debug!("orchestrator: not provisioned; continuing");
info!("orchestrator: wrote mount-existing report to {}", path);
}
}
// 2) Device discovery using compiled filter from config.
Ok(())
}
fn run_provisioning(
ctx: &Context,
mode: ProvisioningMode,
state_hint: Option<StateReport>,
) -> Result<()> {
let preview_outputs = ctx.show || ctx.report_path_override.is_some();
let mut state_opt = state_hint;
if state_opt.is_none() {
state_opt = idempotency::detect_existing_state()?;
}
if let Some(state) = state_opt {
info!("orchestrator: already provisioned; ensuring mounts are active");
return run_mount_existing(ctx, None, Some(state));
}
let filter = build_device_filter(&ctx.cfg)?;
let disks = discover(&filter)?;
info!("orchestrator: discovered {} eligible disk(s)", disks.len());
// 3) Emptiness enforcement: skip in preview mode (--show/--report) to allow planning output.
let preview = ctx.show || ctx.report_path_override.is_some();
if ctx.cfg.partitioning.require_empty_disks && !preview {
enforce_empty_disks(&disks)?;
info!("orchestrator: all target disks verified empty");
} else if ctx.cfg.partitioning.require_empty_disks && preview {
warn!("orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement");
} else {
if ctx.cfg.partitioning.require_empty_disks {
if matches!(mode, ProvisioningMode::Apply) {
enforce_empty_disks(&disks)?;
info!("orchestrator: all target disks verified empty");
} else {
warn!("orchestrator: preview mode detected (--show/--report); skipping empty-disk enforcement");
}
} else if matches!(mode, ProvisioningMode::Apply) {
warn!("orchestrator: require_empty_disks=false; proceeding without emptiness enforcement");
}
// 4) Partition planning (declarative).
let plan = partition::plan_partitions(&disks, &ctx.cfg)?;
let effective_cfg = {
let mut c = ctx.cfg.clone();
if !(ctx.topo_from_cli || ctx.topo_from_cmdline) {
let auto_topo = if disks.len() == 1 {
Topology::BtrfsSingle
} else if disks.len() == 2 {
Topology::DualIndependent
} else {
Topology::BtrfsRaid1
};
if c.topology != auto_topo {
info!("orchestrator: topology auto-selected {:?}", auto_topo);
c.topology = auto_topo;
} else {
info!("orchestrator: using configured topology {:?}", c.topology);
}
} else {
info!("orchestrator: using overridden topology {:?}", c.topology);
}
c
};
let plan = partition::plan_partitions(&disks, &effective_cfg)?;
debug!(
"orchestrator: partition plan ready (alignment={} MiB, disks={})",
plan.alignment_mib,
@@ -353,8 +483,7 @@ pub fn run(ctx: &Context) -> Result<()> {
debug!("plan for {}: {} part(s)", dp.disk.path, dp.parts.len());
}
// Apply mode: perform destructive partition application now.
if ctx.apply {
if matches!(mode, ProvisioningMode::Apply) {
info!("orchestrator: apply mode enabled; applying partition plan");
let part_results = partition::apply_partitions(&plan)?;
info!(
@@ -363,34 +492,28 @@ pub fn run(ctx: &Context) -> Result<()> {
part_results.len()
);
// Filesystem planning and creation
let fs_plan = zfs::plan_filesystems(&part_results, &ctx.cfg)?;
let fs_plan = zfs::plan_filesystems(&part_results, &effective_cfg)?;
info!(
"orchestrator: filesystem plan contains {} spec(s)",
fs_plan.specs.len()
);
let fs_results = zfs::make_filesystems(&fs_plan, &ctx.cfg)?;
let fs_results = zfs::make_filesystems(&fs_plan, &effective_cfg)?;
info!("orchestrator: created {} filesystem(s)", fs_results.len());
// Mount planning and application
let mplan = crate::mount::plan_mounts(&fs_results, &ctx.cfg)?;
let mplan = crate::mount::plan_mounts(&fs_results, &effective_cfg)?;
let mres = crate::mount::apply_mounts(&mplan)?;
crate::mount::maybe_write_fstab(&mres, &ctx.cfg)?;
crate::mount::maybe_write_fstab(&mres, &effective_cfg)?;
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
if ctx.show || ctx.report_path_override.is_some() {
let summary = build_summary_json(&disks, &plan, &ctx.cfg)?;
if preview_outputs {
let summary = build_summary_json(&disks, &plan, &effective_cfg)?;
if ctx.show {
// Print compact JSON to stdout
println!("{}", summary);
}
if let Some(path) = &ctx.report_path_override {
// Best-effort write (non-atomic for now, pending report::write_report implementation)
fs::write(path, summary.to_string()).map_err(|e| {
Error::Report(format!("failed to write report to {}: {}", path, e))
})?;
@@ -511,14 +634,7 @@ fn build_summary_json(disks: &[Disk], plan: &partition::PartitionPlan, cfg: &Con
}
// Decide filesystem kinds and planned mountpoints (template) from plan + cfg.topology
let topo_str = match cfg.topology {
crate::types::Topology::BtrfsSingle => "btrfs_single",
crate::types::Topology::BcachefsSingle => "bcachefs_single",
crate::types::Topology::DualIndependent => "dual_independent",
crate::types::Topology::SsdHddBcachefs => "ssd_hdd_bcachefs",
crate::types::Topology::Bcachefs2Copy => "bcachefs2_copy",
crate::types::Topology::BtrfsRaid1 => "btrfs_raid1",
};
let topo_str = cfg.topology.to_string();
// Count roles across plan to infer filesystems
let mut esp_count = 0usize;