diff --git a/Cargo.lock b/Cargo.lock index 74a0671..f1bde07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "config" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7328b20597b53c2454f0b1919720c25c7339051c02b72b7e05409e00b14132be" +dependencies = [ + "async-trait", + "lazy_static", + "nom", + "pathdiff", + "serde", + "toml", +] + [[package]] name = "cpufeatures" version = "0.2.12" @@ -591,6 +605,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.2" @@ -624,6 +644,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -665,6 +695,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -894,7 +930,9 @@ name = "steamos-manager" version = "24.5.1" dependencies = [ "anyhow", + "async-trait", "clap", + "config", "inotify", "itertools", "libc", diff --git a/Cargo.toml b/Cargo.toml index b9d25ed..5b93ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,9 @@ strip="symbols" [dependencies] anyhow = "1" +async-trait = "0.1" clap = { version = "4.5", default-features = false, features = ["derive", "help", "std", "usage"] } +config = { version = "0.14", default-features = false, features = ["async", "toml"] } inotify = { version = "0.10", default-features = false, features = ["stream"] } libc = "0.2" itertools = "0.12" diff --git a/src/daemon/config.rs b/src/daemon/config.rs index bc87820..b614651 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -6,12 +6,53 @@ */ use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use config::builder::AsyncState; +use config::{AsyncSource, ConfigBuilder, ConfigError, FileFormat, Format, Map, Value}; +use std::ffi::OsStr; +use std::fmt::Debug; use std::io::ErrorKind; -use tokio::fs::{create_dir_all, read_to_string, write}; -use tracing::{error, info}; +use std::path::Path; +use tokio::fs::{create_dir_all, read_dir, read_to_string, write}; +use tracing::{debug, error, info}; use crate::daemon::DaemonContext; +#[derive(Debug)] +struct AsyncFileSource + Sized + Send + Sync> { + path: P, + format: F, +} + +impl + Sized + Send + Sync + Debug> AsyncFileSource { + fn from(path: P, format: F) -> AsyncFileSource { + AsyncFileSource { path, format } + } +} + +#[async_trait] +impl + Sized + Send + Sync + Debug> AsyncSource + for AsyncFileSource +{ + async fn collect(&self) -> Result, ConfigError> { + let path = self.path.as_ref(); + let text = match read_to_string(&path).await { + Ok(text) => text, + Err(e) => { + if e.kind() == ErrorKind::NotFound { + info!("No config file {} found", path.to_string_lossy()); + return Ok(Map::new()); + } + return Err(ConfigError::Foreign(Box::new(e))); + } + }; + let path = path.to_string_lossy().to_string(); + self.format + .parse(Some(&path), &text) + .map_err(ConfigError::Foreign) + } +} + pub(in crate::daemon) async fn read_state(context: &C) -> Result { let path = context.state_path()?; let state = match read_to_string(path).await { @@ -39,6 +80,466 @@ pub(in crate::daemon) async fn write_state(context: &C) -> Res Ok(write(path, state.as_bytes()).await?) } -pub(in crate::daemon) async fn read_config(_context: &C) -> Result { - todo!(); +pub(in crate::daemon) async fn read_config(context: &C) -> Result { + let builder = ConfigBuilder::::default(); + let system_config_path = context.system_config_path()?; + let user_config_path = context.user_config_path()?; + + let builder = builder.add_async_source(AsyncFileSource::from( + system_config_path.join("config.toml"), + FileFormat::Toml, + )); + let builder = read_config_directory(builder, system_config_path.join("config.toml.d")).await?; + + let builder = builder.add_async_source(AsyncFileSource::from( + user_config_path.join("config.toml"), + FileFormat::Toml, + )); + let builder = read_config_directory(builder, user_config_path.join("config.toml.d")).await?; + let config = builder.build().await?; + Ok(config.try_deserialize()?) +} + +async fn read_config_directory + Sync + Send>( + builder: ConfigBuilder, + path: P, +) -> Result> { + let mut dir = match read_dir(&path).await { + Ok(dir) => dir, + Err(e) => { + if e.kind() == ErrorKind::NotFound { + debug!( + "No config fragment directory {} found", + path.as_ref().to_string_lossy() + ); + return Ok(builder); + } + error!("Error reading config fragment directory: {e}"); + return Err(e.into()); + } + }; + let mut entries = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + match path.extension() { + Some(ext) if ext == OsStr::new("toml") => entries.push(path), + _ => continue, + } + } + entries.sort(); + Ok(entries.into_iter().fold(builder, |builder, path| { + builder.add_async_source(AsyncFileSource::from(path, FileFormat::Toml)) + })) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::daemon::Daemon; + use crate::{path, testing, write_synced}; + + use serde::{Deserialize, Serialize}; + use std::path::PathBuf; + + #[derive(Deserialize, Serialize, Copy, Clone, Default, PartialEq, Debug)] + struct TestSubstate { + subvalue: i32, + } + + #[derive(Deserialize, Serialize, Copy, Clone, Default, PartialEq, Debug)] + #[serde(default)] + struct TestState { + substate: TestSubstate, + value: i32, + } + + #[derive(Default)] + struct TestContext { + state: TestState, + config: TestState, + } + + impl DaemonContext for TestContext { + type State = TestState; + type Config = TestState; + type Command = (); + + fn user_config_path(&self) -> Result { + Ok(path("user")) + } + + fn system_config_path(&self) -> Result { + Ok(path("system")) + } + + fn state(&self) -> TestState { + self.state + } + + async fn start( + &mut self, + state: Self::State, + config: Self::Config, + _daemon: &mut Daemon, + ) -> Result<()> { + self.state = state; + self.config = config; + Ok(()) + } + + async fn reload(&mut self, config: Self::Config, _daemon: &mut Daemon) -> Result<()> { + self.config = config; + Ok(()) + } + + async fn handle_command( + &mut self, + _cmd: Self::Command, + _daemon: &mut Daemon, + ) -> Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn test_read_state() { + let _h = testing::start(); + + let context = TestContext::default(); + let state = read_state(&context).await.expect("read_state"); + + assert_eq!(state, TestState::default()); + + let state_path = context.state_path().expect("state_path"); + create_dir_all(state_path.parent().unwrap()) + .await + .expect("create_dir_all"); + + write_synced( + state_path, + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let state = read_state(&context).await.expect("read_state"); + assert_eq!( + state, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + } + + #[tokio::test] + async fn test_read_extra_state() { + let _h = testing::start(); + + let context = TestContext::default(); + let state_path = context.state_path().expect("state_path"); + create_dir_all(state_path.parent().unwrap()) + .await + .expect("create_dir_all"); + + write_synced( + state_path, + "value = 1\nvalue2 = 3\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let state = read_state(&context).await.expect("read_state"); + assert_eq!( + state, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + } + + #[tokio::test] + async fn test_read_missing_state() { + let _h = testing::start(); + + let context = TestContext::default(); + let state_path = context.state_path().expect("state_path"); + create_dir_all(state_path.parent().unwrap()) + .await + .expect("create_dir_all"); + + write_synced(state_path, "[substate]\nsubvalue = 2\n".as_bytes()) + .await + .expect("write"); + + let state = read_state(&context).await.expect("read_state"); + assert_eq!( + state, + TestState { + value: 0, + substate: TestSubstate { subvalue: 2 } + } + ); + } + + #[tokio::test] + async fn test_write_state() { + let _h = testing::start(); + + let mut context = TestContext::default(); + let state_path = context.state_path().expect("state_path"); + + write_state(&context).await.expect("write_state"); + let config = read_to_string(&state_path).await.expect("read_to_string"); + assert_eq!(config, "value = 0\n\n[substate]\nsubvalue = 0\n"); + + context.state.value = 1; + write_state(&context).await.expect("write_state"); + let config = read_to_string(&state_path).await.expect("read_to_string"); + assert_eq!(config, "value = 1\n\n[substate]\nsubvalue = 0\n"); + } + + #[tokio::test] + async fn test_read_system_config() { + let _h = testing::start(); + + let context = TestContext::default(); + let config = read_config(&context).await.expect("read_config"); + assert_eq!(config, TestState::default()); + + let system_config_path = context.system_config_path().expect("system_config_path"); + create_dir_all(&system_config_path) + .await + .expect("create_dir_all"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!(config, TestState::default()); + + write_synced( + system_config_path.join("config.toml"), + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + } + + #[tokio::test] + async fn test_read_user_config() { + let _h = testing::start(); + + let context = TestContext::default(); + let config = read_config(&context).await.expect("read_config"); + assert_eq!(config, TestState::default()); + + let user_config_path = context.user_config_path().expect("user_config_path"); + create_dir_all(&user_config_path) + .await + .expect("create_dir_all"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!(config, TestState::default()); + + write_synced( + user_config_path.join("config.toml"), + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + } + + #[tokio::test] + async fn test_config_ordering() { + let _h = testing::start(); + + let context = TestContext::default(); + + let system_config_path = context.user_config_path().expect("system_config_path"); + create_dir_all(&system_config_path) + .await + .expect("create_dir_all"); + + let user_config_path = context.user_config_path().expect("user_config_path"); + create_dir_all(&user_config_path) + .await + .expect("create_dir_all"); + + write_synced( + system_config_path.join("config.toml"), + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + write_synced( + user_config_path.join("config.toml"), + "value = 3\n\n[substate]\nsubvalue = 4\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 3, + substate: TestSubstate { subvalue: 4 } + } + ); + } + + #[tokio::test] + async fn test_config_partial_ordering() { + let _h = testing::start(); + + let context = TestContext::default(); + + let system_config_path = context.system_config_path().expect("system_config_path"); + create_dir_all(&system_config_path) + .await + .expect("create_dir_all"); + + let user_config_path = context.user_config_path().expect("user_config_path"); + create_dir_all(&user_config_path) + .await + .expect("create_dir_all"); + + write_synced( + system_config_path.join("config.toml"), + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + + write_synced( + user_config_path.join("config.toml"), + "value = 3\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 3, + substate: TestSubstate { subvalue: 2 } + } + ); + } + + #[tokio::test] + async fn test_read_user_config_fragments() { + let _h = testing::start(); + + let context = TestContext::default(); + + let user_config_path = context.user_config_path().expect("user_config_path"); + create_dir_all(user_config_path.join("config.toml.d")) + .await + .expect("create_dir_all"); + + write_synced( + user_config_path.join("config.toml"), + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + + write_synced( + user_config_path.join("config.toml.d/frag.toml"), + "[substate]\nsubvalue = 3\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 3 } + } + ); + } + + #[tokio::test] + async fn test_read_system_config_fragments() { + let _h = testing::start(); + + let context = TestContext::default(); + + let system_config_path = context.system_config_path().expect("system_config_path"); + create_dir_all(system_config_path.join("config.toml.d")) + .await + .expect("create_dir_all"); + + write_synced( + system_config_path.join("config.toml"), + "value = 1\n\n[substate]\nsubvalue = 2\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 2 } + } + ); + + write_synced( + system_config_path.join("config.toml.d/frag.toml"), + "[substate]\nsubvalue = 3\n".as_bytes(), + ) + .await + .expect("write"); + + let config = read_config(&context).await.expect("read_config"); + assert_eq!( + config, + TestState { + value: 1, + substate: TestSubstate { subvalue: 3 } + } + ); + } }