//! 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 // 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 { 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::BtrfsSingle => {} // nothing special Topology::BcachefsSingle => {} Topology::DualIndependent => {} Topology::SsdHddBcachefs => {} Topology::Bcachefs2Copy => {} 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: T) -> Result { serde_json::to_value(t).map_err(|e| Error::Other(e.into())) } fn load_yaml_value(path: &str) -> Result { 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 (avoid moving out of borrowed field) if let Some(t) = cli.topology.as_ref() { 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> { 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:[][;base64], // 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::BtrfsSingle, 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(), }, } }