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:
@@ -58,8 +58,8 @@ impl std::fmt::Display for LogLevelArg {
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "zosstorage", disable_help_subcommand = true)]
|
||||
pub struct Cli {
|
||||
/// Path to YAML configuration (mirrors kernel cmdline key 'zosstorage.config=')
|
||||
#[arg(short = 'c', long = "config")]
|
||||
/// DEPRECATED: external YAML configuration is not used at runtime (ADR-0002). Ignored with a warning.
|
||||
#[arg(short = 'c', long = "config", hide = true)]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// Log level: error, warn, info, debug
|
||||
@@ -74,7 +74,7 @@ pub struct Cli {
|
||||
#[arg(short = 's', long = "fstab", default_value_t = false)]
|
||||
pub fstab: bool,
|
||||
|
||||
/// Select topology (overrides config topology)
|
||||
/// Select topology (CLI has precedence over kernel cmdline)
|
||||
#[arg(short = 't', long = "topology", value_enum)]
|
||||
pub topology: Option<crate::types::Topology>,
|
||||
|
||||
@@ -82,8 +82,7 @@ pub struct Cli {
|
||||
#[arg(short = 'f', long = "force")]
|
||||
pub force: bool,
|
||||
|
||||
/// Allow removable devices (e.g., USB sticks) to be considered during discovery
|
||||
/// Overrides config.device_selection.allow_removable when provided
|
||||
/// Include removable devices (e.g., USB sticks) during discovery (default: false)
|
||||
#[arg(long = "allow-removable", default_value_t = false)]
|
||||
pub allow_removable: bool,
|
||||
|
||||
@@ -100,7 +99,7 @@ pub struct Cli {
|
||||
#[arg(long = "show", default_value_t = false)]
|
||||
pub show: bool,
|
||||
|
||||
/// Write detection/planning JSON report to the given path (overrides config.report.path)
|
||||
/// Write detection/planning JSON report to the given path
|
||||
#[arg(long = "report")]
|
||||
pub report: Option<String>,
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Configuration loading, merging, and validation (loader).
|
||||
//!
|
||||
//! Precedence (highest to lowest):
|
||||
//! - CLI flags (and optional `--config PATH` when provided)
|
||||
//! - Kernel cmdline key `zosstorage.topo=`
|
||||
//! - Built-in defaults
|
||||
//!
|
||||
//! See [docs/SCHEMA.md](../../docs/SCHEMA.md) for the schema details.
|
||||
//// Precedence and policy (ADR-0002):
|
||||
//// - Built-in sane defaults for all settings.
|
||||
//// - Kernel cmdline key `zosstorage.topology=` (legacy alias `zosstorage.topo=`) may override topology only.
|
||||
//// - CLI flags control operational toggles only (logging, fstab, allow-removable).
|
||||
//// - `--config` and `--topology` are deprecated and ignored (warnings emitted).
|
||||
////
|
||||
//// Note: [docs/SCHEMA.md](../../docs/SCHEMA.md) is deprecated for runtime configuration; defaults are code-defined.
|
||||
//
|
||||
// REGION: API
|
||||
// api: config::load_and_merge(cli: &crate::cli::Cli) -> crate::Result<crate::config::types::Config>
|
||||
@@ -40,23 +41,21 @@
|
||||
// REGION: TODO-END
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{cli::Cli, Error, Result};
|
||||
use crate::types::*;
|
||||
use serde_json::{Map, Value, json};
|
||||
use base64::Engine as _;
|
||||
use tracing::warn;
|
||||
|
||||
//// Load defaults, merge optional CLI --config, overlay CLI flags (highest precedence),
|
||||
//// then consider kernel cmdline topology only if CLI omitted it.
|
||||
//// Build configuration from built-in defaults and minimal operational CLI overlays.
|
||||
/// Returns a validated Config on success.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Starts from built-in defaults (documented in docs/SCHEMA.md)
|
||||
/// - Skips implicit /etc reads in initramfs
|
||||
/// - If CLI --config is provided, merge that (overrides defaults)
|
||||
/// - If kernel cmdline provides `zosstorage.topo=...` and CLI did NOT specify `--topology`, apply it
|
||||
/// - Returns Error::Unimplemented when --force is used
|
||||
/// Behavior (ADR-0002):
|
||||
/// - Start from built-in defaults (code-defined).
|
||||
/// - Ignore on-disk YAML and `--config` (deprecated); emit a warning if provided.
|
||||
/// - CLI `--topology` is supported and has precedence when provided.
|
||||
/// - If CLI does not provide topology, apply kernel cmdline `zosstorage.topology=` (or legacy `zosstorage.topo=`).
|
||||
/// - Returns Error::Unimplemented when --force is used.
|
||||
pub fn load_and_merge(cli: &Cli) -> Result<Config> {
|
||||
if cli.force {
|
||||
return Err(Error::Unimplemented("--force flag is not implemented"));
|
||||
@@ -68,17 +67,17 @@ pub fn load_and_merge(cli: &Cli) -> Result<Config> {
|
||||
// 2) (initramfs) Skipped reading default on-disk config to avoid dependency on /etc.
|
||||
// If a config is needed, pass it via --config PATH or kernel cmdline `zosstorage.config=...`.
|
||||
|
||||
// 3) Merge CLI referenced config (if any)
|
||||
if let Some(cfg_path) = &cli.config {
|
||||
let v = load_yaml_value(cfg_path)?;
|
||||
merge_value(&mut merged, v);
|
||||
// 3) Deprecated config file flag: warn and ignore
|
||||
if cli.config.is_some() {
|
||||
warn!("--config is deprecated and ignored (ADR-0002: defaults-only)");
|
||||
}
|
||||
// (no file merge)
|
||||
|
||||
// 4) Overlay CLI flags (non-path flags)
|
||||
let cli_overlay = cli_overlay_value(cli);
|
||||
merge_value(&mut merged, cli_overlay);
|
||||
|
||||
// 5) Kernel cmdline topology (only if CLI did not specify topology), e.g., `zosstorage.topo=dual-independent`
|
||||
// 5) Kernel cmdline topology override only when CLI did not provide topology
|
||||
if cli.topology.is_none() {
|
||||
if let Some(topo) = kernel_cmdline_topology() {
|
||||
merge_value(&mut merged, json!({"topology": topo.to_string()}));
|
||||
@@ -204,14 +203,6 @@ fn to_value<T: serde::Serialize>(t: T) -> Result<Value> {
|
||||
serde_json::to_value(t).map_err(|e| Error::Other(e.into()))
|
||||
}
|
||||
|
||||
fn load_yaml_value(path: &str) -> Result<Value> {
|
||||
let s = fs::read_to_string(path)
|
||||
.map_err(|e| Error::Config(format!("failed to read config file {}: {}", path, e)))?;
|
||||
// Load as generic serde_json::Value for merging flexibility
|
||||
let v: serde_json::Value = serde_yaml::from_str(&s)
|
||||
.map_err(|e| Error::Config(format!("failed to parse YAML {}: {}", path, e)))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Merge b into a in-place:
|
||||
/// - Objects are merged key-by-key (recursively)
|
||||
@@ -259,7 +250,7 @@ fn cli_overlay_value(cli: &Cli) -> Value {
|
||||
root.insert("device_selection".into(), Value::Object(device_selection));
|
||||
}
|
||||
|
||||
// topology override via --topology (avoid moving out of borrowed field)
|
||||
// topology override via --topology (takes precedence over kernel cmdline)
|
||||
if let Some(t) = cli.topology.as_ref() {
|
||||
root.insert("topology".into(), Value::String(t.to_string()));
|
||||
}
|
||||
@@ -267,63 +258,18 @@ fn cli_overlay_value(cli: &Cli) -> Value {
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
enum KernelConfigSource {
|
||||
Path(String),
|
||||
/// Raw YAML from a data: URL payload after decoding (if base64-encoded).
|
||||
Data(String),
|
||||
}
|
||||
|
||||
/// Resolve a config from kernel cmdline key `zosstorage.config=`.
|
||||
/// Supports:
|
||||
/// - absolute paths (e.g., /run/zos.yaml)
|
||||
/// - file:/absolute/path
|
||||
/// - data:application/x-yaml;base64,BASE64CONTENT
|
||||
/// Returns Ok(None) when key absent.
|
||||
fn kernel_cmdline_config_source() -> Result<Option<KernelConfigSource>> {
|
||||
//// Parse kernel cmdline for topology override.
|
||||
//// Accepts `zosstorage.topology=` and legacy alias `zosstorage.topo=`.
|
||||
pub fn kernel_cmdline_topology() -> Option<Topology> {
|
||||
let cmdline = fs::read_to_string("/proc/cmdline").unwrap_or_default();
|
||||
for token in cmdline.split_whitespace() {
|
||||
if let Some(rest) = token.strip_prefix("zosstorage.config=") {
|
||||
let mut val = rest.to_string();
|
||||
// Trim surrounding quotes if any
|
||||
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
|
||||
val = val[1..val.len() - 1].to_string();
|
||||
}
|
||||
if let Some(path) = val.strip_prefix("file:") {
|
||||
return Ok(Some(KernelConfigSource::Path(path.to_string())));
|
||||
}
|
||||
if let Some(data_url) = val.strip_prefix("data:") {
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
// Find comma separating the header and payload
|
||||
if let Some(idx) = data_url.find(',') {
|
||||
let (header, payload) = data_url.split_at(idx);
|
||||
let payload = &payload[1..]; // skip the comma
|
||||
let is_base64 = header.split(';').any(|seg| seg.eq_ignore_ascii_case("base64"));
|
||||
let yaml = if is_base64 {
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(payload.as_bytes())
|
||||
.map_err(|e| Error::Config(format!("invalid base64 in data: URL: {}", e)))?;
|
||||
String::from_utf8(decoded)
|
||||
.map_err(|e| Error::Config(format!("data: URL payload not UTF-8: {}", e)))?
|
||||
} else {
|
||||
payload.to_string()
|
||||
};
|
||||
return Ok(Some(KernelConfigSource::Data(yaml)));
|
||||
} else {
|
||||
return Err(Error::Config("malformed data: URL (missing comma)".into()));
|
||||
}
|
||||
}
|
||||
// Treat as direct path
|
||||
return Ok(Some(KernelConfigSource::Path(val)));
|
||||
let mut val_opt = None;
|
||||
if let Some(v) = token.strip_prefix("zosstorage.topology=") {
|
||||
val_opt = Some(v);
|
||||
} else if let Some(v) = token.strip_prefix("zosstorage.topo=") {
|
||||
val_opt = Some(v);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn kernel_cmdline_topology() -> Option<Topology> {
|
||||
let cmdline = fs::read_to_string("/proc/cmdline").unwrap_or_default();
|
||||
for token in cmdline.split_whitespace() {
|
||||
if let Some(mut val) = token.strip_prefix("zosstorage.topo=") {
|
||||
// Trim surrounding quotes if any
|
||||
if let Some(mut val) = val_opt {
|
||||
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
|
||||
val = &val[1..val.len() - 1];
|
||||
}
|
||||
@@ -338,7 +284,6 @@ fn kernel_cmdline_topology() -> Option<Topology> {
|
||||
|
||||
/// Helper to parse known topology tokens in kebab- or snake-case.
|
||||
fn parse_topology_token(s: &str) -> Option<Topology> {
|
||||
// Normalize underscores to hyphens for simpler matching.
|
||||
let k = s.trim().to_ascii_lowercase().replace('_', "-");
|
||||
match k.as_str() {
|
||||
"btrfs-single" => Some(Topology::BtrfsSingle),
|
||||
|
||||
@@ -54,6 +54,8 @@ fn real_main() -> Result<()> {
|
||||
.with_apply(cli.apply)
|
||||
.with_mount_existing(cli.mount_existing)
|
||||
.with_report_current(cli.report_current)
|
||||
.with_report_path(cli.report.clone());
|
||||
.with_report_path(cli.report.clone())
|
||||
.with_topology_from_cli(cli.topology.is_some())
|
||||
.with_topology_from_cmdline(config::loader::kernel_cmdline_topology().is_some() && cli.topology.is_none());
|
||||
orchestrator::run(&ctx)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct DeviceSelection {
|
||||
pub min_size_gib: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[value(rename_all = "snake_case")]
|
||||
pub enum Topology {
|
||||
|
||||
Reference in New Issue
Block a user