- 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.
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! Configuration loading, merging, and validation (loader).
|
|
//!
|
|
//! Precedence (highest to lowest):
|
|
//! - Kernel cmdline key `zosstorage.config=`
|
|
//! - CLI flags
|
|
//! - On-disk config file at /etc/zosstorage/config.yaml (if present)
|
|
//! - Built-in defaults
|
|
//!
|
|
//! See [docs/SCHEMA.md](../../docs/SCHEMA.md) for the schema details.
|
|
//
|
|
// REGION: API
|
|
// api: config::load_and_merge(cli: &crate::cli::Cli) -> crate::Result<crate::config::types::Config>
|
|
// api: config::validate(cfg: &crate::config::types::Config) -> crate::Result<()>
|
|
// REGION: API-END
|
|
//
|
|
// REGION: RESPONSIBILITIES
|
|
// - Load defaults, merge /etc config, optional CLI-referenced YAML, CLI flag overlays,
|
|
// and kernel cmdline (zosstorage.config=) into a final Config.
|
|
// - Validate structural and semantic correctness early.
|
|
// Non-goals: device probing, partitioning, filesystem operations.
|
|
// REGION: RESPONSIBILITIES-END
|
|
//
|
|
// REGION: EXTENSION_POINTS
|
|
// ext: kernel cmdline URI schemes (e.g., http:, data:) can be added here.
|
|
// ext: alternate default config location via build-time feature or CLI.
|
|
// REGION: EXTENSION_POINTS-END
|
|
//
|
|
// REGION: SAFETY
|
|
// safety: precedence enforced (kernel > CLI flags > CLI --config > /etc file > defaults).
|
|
// safety: reserved GPT names and labels validated to avoid destructive operations later.
|
|
// REGION: SAFETY-END
|
|
//
|
|
// REGION: ERROR_MAPPING
|
|
// errmap: serde_yaml::Error -> Error::Config
|
|
// errmap: std::io::Error (file read) -> Error::Config
|
|
// errmap: serde_json::Error (merge/convert) -> Error::Other(anyhow)
|
|
// REGION: ERROR_MAPPING-END
|
|
//
|
|
// REGION: TODO
|
|
// todo: consider environment variable overlays if required.
|
|
// REGION: TODO-END
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use crate::{cli::Cli, Error, Result};
|
|
use crate::types::*;
|
|
use serde_json::{Map, Value};
|
|
use base64::Engine as _;
|
|
|
|
/// Load defaults, merge on-disk config, overlay CLI, and finally kernel cmdline key.
|
|
/// Returns a validated Config on success.
|
|
///
|
|
/// Behavior:
|
|
/// - Starts from built-in defaults (documented in docs/SCHEMA.md)
|
|
/// - If /etc/zosstorage/config.yaml exists, merge it
|
|
/// - If CLI --config is provided, merge that (overrides file defaults)
|
|
/// - If kernel cmdline provides `zosstorage.config=...`, merge that last (highest precedence)
|
|
/// - 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"));
|
|
}
|
|
|
|
// 1) Start with defaults
|
|
let mut merged = to_value(default_config())?;
|
|
|
|
// 2) Merge default on-disk config if present
|
|
let default_cfg_path = "/etc/zosstorage/config.yaml";
|
|
if Path::new(default_cfg_path).exists() {
|
|
let v = load_yaml_value(default_cfg_path)?;
|
|
merge_value(&mut merged, v);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 4) Overlay CLI flags (non-path flags)
|
|
let cli_overlay = cli_overlay_value(cli);
|
|
merge_value(&mut merged, cli_overlay);
|
|
|
|
// 5) Merge kernel cmdline referenced config (if any)
|
|
if let Some(src) = kernel_cmdline_config_source()? {
|
|
match src {
|
|
KernelConfigSource::Path(kpath) => {
|
|
let v = load_yaml_value(&kpath)?;
|
|
merge_value(&mut merged, v);
|
|
}
|
|
KernelConfigSource::Data(yaml) => {
|
|
let v: serde_json::Value = serde_yaml::from_str(&yaml)
|
|
.map_err(|e| Error::Config(format!("failed to parse YAML from data: URL: {}", e)))?;
|
|
merge_value(&mut merged, v);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finalize
|
|
let cfg: Config = serde_json::from_value(merged).map_err(|e| Error::Other(e.into()))?;
|
|
validate(&cfg)?;
|
|
Ok(cfg)
|
|
}
|
|
|
|
/// Validate semantic correctness of the configuration.
|
|
pub fn validate(cfg: &Config) -> Result<()> {
|
|
// Logging
|
|
match cfg.logging.level.as_str() {
|
|
"error" | "warn" | "info" | "debug" => {}
|
|
other => return Err(Error::Validation(format!("invalid logging.level: {other}"))),
|
|
}
|
|
|
|
// Device selection
|
|
if cfg.device_selection.include_patterns.is_empty() {
|
|
return Err(Error::Validation(
|
|
"device_selection.include_patterns must not be empty".into(),
|
|
));
|
|
}
|
|
if cfg.device_selection.min_size_gib == 0 {
|
|
return Err(Error::Validation(
|
|
"device_selection.min_size_gib must be >= 1".into(),
|
|
));
|
|
}
|
|
|
|
// Partitioning
|
|
if cfg.partitioning.alignment_mib < 1 {
|
|
return Err(Error::Validation(
|
|
"partitioning.alignment_mib must be >= 1".into(),
|
|
));
|
|
}
|
|
if cfg.partitioning.bios_boot.enabled && cfg.partitioning.bios_boot.size_mib < 1 {
|
|
return Err(Error::Validation(
|
|
"partitioning.bios_boot.size_mib must be >= 1 when enabled".into(),
|
|
));
|
|
}
|
|
if cfg.partitioning.esp.size_mib < 1 {
|
|
return Err(Error::Validation(
|
|
"partitioning.esp.size_mib must be >= 1".into(),
|
|
));
|
|
}
|
|
|
|
// Reserved GPT names
|
|
if cfg.partitioning.esp.gpt_name != "zosboot" {
|
|
return Err(Error::Validation(
|
|
"partitioning.esp.gpt_name must be 'zosboot'".into(),
|
|
));
|
|
}
|
|
if cfg.partitioning.data.gpt_name != "zosdata" {
|
|
return Err(Error::Validation(
|
|
"partitioning.data.gpt_name must be 'zosdata'".into(),
|
|
));
|
|
}
|
|
if cfg.partitioning.cache.gpt_name != "zoscache" {
|
|
return Err(Error::Validation(
|
|
"partitioning.cache.gpt_name must be 'zoscache'".into(),
|
|
));
|
|
}
|
|
// BIOS boot name is also 'zosboot' per current assumption
|
|
if cfg.partitioning.bios_boot.gpt_name != "zosboot" {
|
|
return Err(Error::Validation(
|
|
"partitioning.bios_boot.gpt_name must be 'zosboot'".into(),
|
|
));
|
|
}
|
|
|
|
// Reserved filesystem labels
|
|
if cfg.filesystem.vfat.label != "ZOSBOOT" {
|
|
return Err(Error::Validation(
|
|
"filesystem.vfat.label must be 'ZOSBOOT'".into(),
|
|
));
|
|
}
|
|
if cfg.filesystem.btrfs.label != "ZOSDATA" {
|
|
return Err(Error::Validation(
|
|
"filesystem.btrfs.label must be 'ZOSDATA'".into(),
|
|
));
|
|
}
|
|
if cfg.filesystem.bcachefs.label != "ZOSDATA" {
|
|
return Err(Error::Validation(
|
|
"filesystem.bcachefs.label must be 'ZOSDATA'".into(),
|
|
));
|
|
}
|
|
|
|
// Mount scheme
|
|
if cfg.mount.base_dir.trim().is_empty() {
|
|
return Err(Error::Validation("mount.base_dir must not be empty".into()));
|
|
}
|
|
|
|
// Topology-specific quick checks (basic for now)
|
|
match cfg.topology {
|
|
Topology::Single => {} // nothing special
|
|
Topology::DualIndependent => {}
|
|
Topology::SsdHddBcachefs => {}
|
|
Topology::BtrfsRaid1 => {
|
|
// No enforced requirement here beyond presence of two disks at runtime.
|
|
if cfg.filesystem.btrfs.raid_profile != "raid1" && cfg.filesystem.btrfs.raid_profile != "none" {
|
|
return Err(Error::Validation(
|
|
"filesystem.btrfs.raid_profile must be 'none' or 'raid1'".into(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Report path
|
|
if cfg.report.path.trim().is_empty() {
|
|
return Err(Error::Validation("report.path must not be empty".into()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ----------------------- helpers -----------------------
|
|
|
|
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)
|
|
/// - Arrays and scalars replace
|
|
fn merge_value(a: &mut Value, b: Value) {
|
|
match (a, b) {
|
|
(Value::Object(a_map), Value::Object(b_map)) => {
|
|
for (k, v) in b_map {
|
|
match a_map.get_mut(&k) {
|
|
Some(a_sub) => merge_value(a_sub, v),
|
|
None => {
|
|
a_map.insert(k, v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(a_slot, b_other) => {
|
|
*a_slot = b_other;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Produce a JSON overlay from CLI flags.
|
|
/// Only sets fields that should override defaults when present.
|
|
fn cli_overlay_value(cli: &Cli) -> Value {
|
|
let mut root = Map::new();
|
|
|
|
// logging overrides (always overlay CLI values for determinism)
|
|
let mut logging = Map::new();
|
|
logging.insert("level".into(), Value::String(cli.log_level.to_string()));
|
|
logging.insert("to_file".into(), Value::Bool(cli.log_to_file));
|
|
root.insert("logging".into(), Value::Object(logging));
|
|
|
|
// mount.fstab_enabled via --fstab
|
|
if cli.fstab {
|
|
let mut mount = Map::new();
|
|
mount.insert("fstab_enabled".into(), Value::Bool(true));
|
|
root.insert("mount".into(), Value::Object(mount));
|
|
}
|
|
|
|
// device_selection.allow_removable via --allow-removable
|
|
if cli.allow_removable {
|
|
let mut device_selection = Map::new();
|
|
device_selection.insert("allow_removable".into(), Value::Bool(true));
|
|
root.insert("device_selection".into(), Value::Object(device_selection));
|
|
}
|
|
|
|
// topology override via --topology
|
|
if let Some(t) = cli.topology {
|
|
root.insert("topology".into(), Value::String(t.to_string()));
|
|
}
|
|
|
|
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>> {
|
|
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)));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Built-in defaults for the entire configuration (schema version 1).
|
|
fn default_config() -> Config {
|
|
Config {
|
|
version: 1,
|
|
logging: LoggingConfig {
|
|
level: "info".into(),
|
|
to_file: false,
|
|
},
|
|
device_selection: DeviceSelection {
|
|
include_patterns: vec![
|
|
String::from(r"^/dev/sd\w+$"),
|
|
String::from(r"^/dev/nvme\w+n\d+$"),
|
|
String::from(r"^/dev/vd\w+$"),
|
|
],
|
|
exclude_patterns: vec![
|
|
String::from(r"^/dev/ram\d+$"),
|
|
String::from(r"^/dev/zram\d+$"),
|
|
String::from(r"^/dev/loop\d+$"),
|
|
String::from(r"^/dev/fd\d+$"),
|
|
],
|
|
allow_removable: false,
|
|
min_size_gib: 10,
|
|
},
|
|
topology: Topology::Single,
|
|
partitioning: Partitioning {
|
|
alignment_mib: 1,
|
|
require_empty_disks: true,
|
|
bios_boot: BiosBootSpec {
|
|
enabled: true,
|
|
size_mib: 1,
|
|
gpt_name: "zosboot".into(),
|
|
},
|
|
esp: EspSpec {
|
|
size_mib: 512,
|
|
label: "ZOSBOOT".into(),
|
|
gpt_name: "zosboot".into(),
|
|
},
|
|
data: DataSpec {
|
|
gpt_name: "zosdata".into(),
|
|
},
|
|
cache: CacheSpec {
|
|
gpt_name: "zoscache".into(),
|
|
},
|
|
},
|
|
filesystem: FsOptions {
|
|
btrfs: BtrfsOptions {
|
|
label: "ZOSDATA".into(),
|
|
compression: "zstd:3".into(),
|
|
raid_profile: "none".into(),
|
|
},
|
|
bcachefs: BcachefsOptions {
|
|
label: "ZOSDATA".into(),
|
|
cache_mode: "promote".into(),
|
|
compression: "zstd".into(),
|
|
checksum: "crc32c".into(),
|
|
},
|
|
vfat: VfatOptions {
|
|
label: "ZOSBOOT".into(),
|
|
},
|
|
},
|
|
mount: MountScheme {
|
|
base_dir: "/var/cache".into(),
|
|
scheme: MountSchemeKind::PerUuid,
|
|
fstab_enabled: false,
|
|
},
|
|
report: ReportOptions {
|
|
path: "/run/zosstorage/state.json".into(),
|
|
},
|
|
}
|
|
} |