feat: first-draft preview-capable zosstorage
- 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.
This commit is contained in:
401
src/config/loader.rs
Normal file
401
src/config/loader.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
//! 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user