Files
zosstorage/src/config/loader.rs

403 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::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: 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 (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<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::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(),
},
}
}