diff --git a/Cargo.lock b/Cargo.lock index c58d84a..c40be05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,7 @@ dependencies = [ "indexmap", "inotify", "instability", + "json-patch", "lazy_static", "linkme", "once_cell", @@ -761,6 +762,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 01766e0..ac91a5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,14 @@ uds_windows = "=1.1.0" uuid = "=1.20.0" # latest versions that support Rust 1.84 +json-patch = { version = "3", default-features = false } +# libvirt Rust bindings, gated behind the `qemu` feature. The +# `qemu` sub-feature of the crate exposes +# `virDomainQemuMonitorCommand`, which the backend uses for +# iothread lifecycle and vq-mapping passthrough (native +# `add_iothread` / `del_iothread` are not exposed by the crate +# yet -- see backends/qemu/libvirt.rs). + virt = { version = "0.4", features = ["qemu"], optional = true } once_cell = { version = "1.21.4", optional = true } rustix = { version = "1.1.4", features = ["param"] } diff --git a/README.md b/README.md index ca56d95..4b32fc1 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,12 @@ managed and unmanaged classifications across daemon restarts. +------------------------------------------+ ``` +## Configuration includes + +Controller, engine, and backend JSON files may contain an `include` field. Its +path is resolved relative to the including file, loaded recursively, and merged +before the including file's keys are applied. + ## Scale-up state machine ```text diff --git a/src/backends/qemu/mod.rs b/src/backends/qemu/mod.rs index c6892c2..51265dc 100644 --- a/src/backends/qemu/mod.rs +++ b/src/backends/qemu/mod.rs @@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize}; use crate::{ backends::{Backend, BackendClientError, BackendRegistration}, - config::{ConfigError, load_config}, + config::{ConfigError, load_json_with_includes}, instance::{Instance, InstanceClient}, util::Path, }; @@ -84,7 +84,7 @@ impl QemuConfig { pub fn from_dir(dir: &Path) -> Result { let path = Path::new(&dir.join("qemu.json")); if path.exists() { - load_config(path) + load_json_with_includes(path) } else { Ok(Self::default()) } diff --git a/src/config.rs b/src/config.rs index 5f4f796..2ee7da7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,12 +136,56 @@ impl Default for Config { } } -/// Load a JSON config file. -pub fn load_config Deserialize<'de>>(path: impl AsRef) -> Result { +/// Envelope carrying the `include` key: a relative path to +/// another JSON file merged first, then overridden by keys in +/// the outer document. +#[derive(Debug, Deserialize)] +struct ConfigEnvelope { + #[serde(default)] + include: Option, + #[serde(flatten)] + rest: serde_json::Value, +} + +/// Read a JSON configuration file, resolving nested `include` +/// directives (relative to the including file), and apply the +/// merged result on top of the built-in defaults. +pub fn load_config(path: impl AsRef) -> Result { + let path = path.as_ref(); + let value = load_config_value(path)?; + let cfg: Config = serde_json::from_value(value)?; + Ok(cfg) +} + +/// Load any JSON document that opts into the `include` +/// mechanism. Engines call this on their own config file so +/// site-specific overrides can live in a sibling file. +pub fn load_json_with_includes Deserialize<'de>>( + path: impl AsRef, +) -> Result { let path = path.as_ref(); + let value = load_config_value(path)?; + let out: T = serde_json::from_value(value)?; + Ok(out) +} + +fn load_config_value(path: &Path) -> Result { let data = std::fs::read_to_string(path)?; - let val: T = serde_json::from_str(&data)?; - Ok(val) + let env: ConfigEnvelope = serde_json::from_str(&data)?; + let mut merged = if let Some(rel) = env.include { + let path = Path::new("."); + let inc_path = Path::new(&path.parent().unwrap_or_else(|| &path).join(&rel)); + load_config_value(&inc_path)? + } else { + serde_json::Value::Object(Default::default()) + }; + // RFC 7396 merge-patch; delegated to the `json_patch` crate so + // we do not carry our own recursive merge helper. Semantic + // note: a `null` value in the outer patch would *delete* the + // key rather than overwriting to null. Our config schema has + // no null sentinels today so this is a no-op distinction. + json_patch::merge(&mut merged, &env.rest); + Ok(merged) } // TODO add a RawConfig or ValidatedConfig to ensure an unvalidated config @@ -268,7 +312,7 @@ mod tests { ) .unwrap(); - let err = load_config::(Path::new(path.to_str().unwrap())).unwrap_err(); + let err = load_config(Path::new(path.to_str().unwrap())).unwrap_err(); assert!(matches!(err, ConfigError::SerdeJson(_))); } diff --git a/src/engines/threshold.rs b/src/engines/threshold.rs index d529dfe..24dbcd5 100644 --- a/src/engines/threshold.rs +++ b/src/engines/threshold.rs @@ -214,7 +214,7 @@ impl ThresholdEngine { let path = Path::new(&dir.join(format!("{ENGINE_NAME}.json"))); // TODO TOCTOU, blindly load and return default if ENOENT let cfg: ThresholdConfig = if path.exists() { - crate::config::load_config(path)? + crate::config::load_json_with_includes(path)? } else { ThresholdConfig::default() };