@@ -6,3 +6,8 @@ version.workspace = true
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
figment.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
figment = { workspace = true, features = ["test"] }
|
||||
|
||||
18
crates/config/src/analysis_config.rs
Normal file
18
crates/config/src/analysis_config.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
const DEFAULT_MINIMUM_SAMPLE_SIZE: usize = 30;
|
||||
const DEFAULT_FALSE_DISCOVERY_RATE: f64 = 0.10;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AnalysisConfig {
|
||||
pub minimum_sample_size: usize,
|
||||
pub false_discovery_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for AnalysisConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
minimum_sample_size: DEFAULT_MINIMUM_SAMPLE_SIZE,
|
||||
false_discovery_rate: DEFAULT_FALSE_DISCOVERY_RATE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::{AuthConfig, EntryConfig, PresetConfig, PushConfig, ServerConfig, StorageConfig};
|
||||
use super::{
|
||||
AnalysisConfig, AuthConfig, EntryConfig, ImportConfig, PresetConfig, ProviderConfig,
|
||||
PushConfig, ServerConfig, StorageConfig, WorkerConfig,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -9,4 +12,8 @@ pub struct AppConfig {
|
||||
pub auth: AuthConfig,
|
||||
pub push: PushConfig,
|
||||
pub preset: PresetConfig,
|
||||
pub provider: ProviderConfig,
|
||||
pub analysis: AnalysisConfig,
|
||||
pub import: ImportConfig,
|
||||
pub worker: WorkerConfig,
|
||||
}
|
||||
|
||||
7
crates/config/src/errors.rs
Normal file
7
crates/config/src/errors.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("configuration file {0} was named by the environment but does not exist")]
|
||||
NamedFileMissing(String),
|
||||
#[error("could not read configuration: {0}")]
|
||||
Unreadable(#[from] Box<figment::Error>),
|
||||
}
|
||||
18
crates/config/src/import_config.rs
Normal file
18
crates/config/src/import_config.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
const DEFAULT_MAXIMUM_DAYS_PER_IMPORT: usize = 90;
|
||||
const DEFAULT_REJECTIONS_KEPT: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ImportConfig {
|
||||
pub maximum_days_per_import: usize,
|
||||
pub rejections_kept: usize,
|
||||
}
|
||||
|
||||
impl Default for ImportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
maximum_days_per_import: DEFAULT_MAXIMUM_DAYS_PER_IMPORT,
|
||||
rejections_kept: DEFAULT_REJECTIONS_KEPT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
mod analysis_config;
|
||||
mod app_config;
|
||||
mod auth_config;
|
||||
mod entry_config;
|
||||
mod errors;
|
||||
mod import_config;
|
||||
mod loader;
|
||||
mod preset;
|
||||
mod provider_config;
|
||||
mod push_config;
|
||||
mod server_config;
|
||||
mod storage_config;
|
||||
mod worker_config;
|
||||
|
||||
pub use analysis_config::AnalysisConfig;
|
||||
pub use app_config::AppConfig;
|
||||
pub use auth_config::AuthConfig;
|
||||
pub use entry_config::EntryConfig;
|
||||
pub use errors::ConfigError;
|
||||
pub use import_config::ImportConfig;
|
||||
pub use loader::load;
|
||||
pub use preset::{PresetActivity, PresetConfig};
|
||||
pub use provider_config::ProviderConfig;
|
||||
pub use push_config::PushConfig;
|
||||
pub use server_config::{CorsConfig, ServerConfig};
|
||||
pub use storage_config::{MediaBackend, StorageConfig};
|
||||
pub use worker_config::WorkerConfig;
|
||||
|
||||
56
crates/config/src/loader.rs
Normal file
56
crates/config/src/loader.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::path::Path;
|
||||
|
||||
use figment::Figment;
|
||||
use figment::providers::{Env, Format, Toml};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::errors::ConfigError;
|
||||
|
||||
const DEFAULT_CONFIG_FILE: &str = "config.toml";
|
||||
const CONFIG_FILE_VARIABLE: &str = "KMOOD_CONFIG_FILE";
|
||||
const VARIABLE_PREFIX: &str = "KMOOD_";
|
||||
const SECTION_SEPARATOR: &str = "__";
|
||||
|
||||
pub fn load() -> Result<AppConfig, ConfigError> {
|
||||
let named_file = std::env::var(CONFIG_FILE_VARIABLE).ok();
|
||||
let path = named_file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE);
|
||||
|
||||
if named_file.is_some() && !Path::new(path).exists() {
|
||||
return Err(ConfigError::NamedFileMissing(path.to_owned()));
|
||||
}
|
||||
|
||||
let config = Figment::new()
|
||||
.merge(Toml::file(path))
|
||||
.merge(Env::prefixed(VARIABLE_PREFIX).split(SECTION_SEPARATOR))
|
||||
.extract()
|
||||
.map_err(Box::new)?;
|
||||
|
||||
announce(path);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn announce(path: &str) {
|
||||
if Path::new(path).exists() {
|
||||
tracing::info!(path, "loaded configuration from file");
|
||||
} else {
|
||||
tracing::info!("no configuration file found, using defaults");
|
||||
}
|
||||
|
||||
let overridden = overriding_variables();
|
||||
if !overridden.is_empty() {
|
||||
tracing::info!(
|
||||
variables = ?overridden,
|
||||
"configuration overridden from the environment"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn overriding_variables() -> Vec<String> {
|
||||
let mut names: Vec<String> = std::env::vars()
|
||||
.map(|(name, _)| name)
|
||||
.filter(|name| name.starts_with(VARIABLE_PREFIX) && name != CONFIG_FILE_VARIABLE)
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
5
crates/config/src/provider_config.rs
Normal file
5
crates/config/src/provider_config.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProviderConfig {
|
||||
pub encryption_key: Option<String>,
|
||||
}
|
||||
39
crates/config/src/worker_config.rs
Normal file
39
crates/config/src/worker_config.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
const DEFAULT_POLL_SECONDS: u64 = 15;
|
||||
const DEFAULT_SWEEP_SECONDS: u64 = 900;
|
||||
const DEFAULT_SESSION_CLEANUP_SECONDS: u64 = 3600;
|
||||
const DEFAULT_REMINDER_SECONDS: u64 = 60;
|
||||
const DEFAULT_JOBS_PER_POLL: usize = 20;
|
||||
const DEFAULT_ENQUEUED_PER_SWEEP: usize = 200;
|
||||
const DEFAULT_MOST_ATTEMPTS: u32 = 5;
|
||||
const DEFAULT_STALLED_AFTER_SECONDS: i64 = 300;
|
||||
const DEFAULT_LOOK_UP_WEATHER: bool = true;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WorkerConfig {
|
||||
pub poll_seconds: u64,
|
||||
pub sweep_seconds: u64,
|
||||
pub session_cleanup_seconds: u64,
|
||||
pub reminder_seconds: u64,
|
||||
pub jobs_per_poll: usize,
|
||||
pub enqueued_per_sweep: usize,
|
||||
pub most_attempts: u32,
|
||||
pub stalled_after_seconds: i64,
|
||||
pub look_up_weather: bool,
|
||||
}
|
||||
|
||||
impl Default for WorkerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_seconds: DEFAULT_POLL_SECONDS,
|
||||
sweep_seconds: DEFAULT_SWEEP_SECONDS,
|
||||
session_cleanup_seconds: DEFAULT_SESSION_CLEANUP_SECONDS,
|
||||
reminder_seconds: DEFAULT_REMINDER_SECONDS,
|
||||
jobs_per_poll: DEFAULT_JOBS_PER_POLL,
|
||||
enqueued_per_sweep: DEFAULT_ENQUEUED_PER_SWEEP,
|
||||
most_attempts: DEFAULT_MOST_ATTEMPTS,
|
||||
stalled_after_seconds: DEFAULT_STALLED_AFTER_SECONDS,
|
||||
look_up_weather: DEFAULT_LOOK_UP_WEATHER,
|
||||
}
|
||||
}
|
||||
}
|
||||
7
crates/config/tests/loader.rs
Normal file
7
crates/config/tests/loader.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
#[path = "loader/layering_test.rs"]
|
||||
mod layering_test;
|
||||
|
||||
#[path = "loader/rejection_test.rs"]
|
||||
mod rejection_test;
|
||||
138
crates/config/tests/loader/layering_test.rs
Normal file
138
crates/config/tests/loader/layering_test.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use figment::Jail;
|
||||
|
||||
use config::{MediaBackend, load};
|
||||
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
|
||||
#[test]
|
||||
fn with_no_file_and_no_environment_every_default_stands() {
|
||||
Jail::expect_with(|_jail| {
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(loaded.server.port, 3000);
|
||||
assert_eq!(loaded.server.host, "0.0.0.0");
|
||||
assert_eq!(loaded.storage.data_dir, "./data");
|
||||
assert!(loaded.auth.jwt_secret.is_none());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_overrides_the_defaults_it_names() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.create_file(CONFIG_FILE, "[server]\nport = 4321\n")?;
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(loaded.server.port, 4321);
|
||||
assert_eq!(loaded.server.host, "0.0.0.0");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_overrides_the_file() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.create_file(CONFIG_FILE, "[server]\nport = 4321\nhost = \"127.0.0.1\"\n")?;
|
||||
jail.set_env("KMOOD_SERVER__PORT", "8080");
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(loaded.server.port, 8080);
|
||||
assert_eq!(loaded.server.host, "127.0.0.1");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_underscore_descends_a_section() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("KMOOD_SERVER__CORS__ALLOW_ANY_ORIGIN", "false");
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert!(!loaded.server.cors.allow_any_origin);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_underscore_stays_inside_a_field_name() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("KMOOD_STORAGE__DATA_DIR", "/var/lib/kmood");
|
||||
jail.set_env("KMOOD_AUTH__JWT_SECRET", "from-the-environment");
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(loaded.storage.data_dir, "/var/lib/kmood");
|
||||
assert_eq!(
|
||||
loaded.auth.jwt_secret.as_deref(),
|
||||
Some("from-the-environment")
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_can_switch_the_media_backend() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("KMOOD_STORAGE__MEDIA__BACKEND", "s3");
|
||||
jail.set_env("KMOOD_STORAGE__MEDIA__BUCKET", "moods");
|
||||
jail.set_env("KMOOD_STORAGE__MEDIA__REGION", "eu-central-1");
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
match loaded.storage.media {
|
||||
MediaBackend::S3 { bucket, region, .. } => {
|
||||
assert_eq!(bucket, "moods");
|
||||
assert_eq!(region, "eu-central-1");
|
||||
}
|
||||
other => panic!("expected the s3 backend, got {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrelated_variable_leaves_the_configuration_alone() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("PORT", "9999");
|
||||
jail.set_env("SERVER__PORT", "9999");
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(loaded.server.port, 3000);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_arrives_as_an_array() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env(
|
||||
"KMOOD_SERVER__CORS__ALLOWED_ORIGINS",
|
||||
r#"["https://a.example","https://b.example"]"#,
|
||||
);
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.server.cors.allowed_origins,
|
||||
vec!["https://a.example", "https://b.example"]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_to_read_can_be_named_by_the_environment() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.create_file("elsewhere.toml", "[server]\nport = 7777\n")?;
|
||||
jail.set_env("KMOOD_CONFIG_FILE", "elsewhere.toml");
|
||||
|
||||
let loaded = load().unwrap();
|
||||
|
||||
assert_eq!(loaded.server.port, 7777);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
47
crates/config/tests/loader/rejection_test.rs
Normal file
47
crates/config/tests/loader/rejection_test.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use figment::Jail;
|
||||
|
||||
use config::load;
|
||||
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
|
||||
#[test]
|
||||
fn a_malformed_file_is_refused_rather_than_ignored() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.create_file(CONFIG_FILE, "[server\nport = ")?;
|
||||
|
||||
assert!(load().is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_of_the_wrong_type_is_refused() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("KMOOD_SERVER__PORT", "not-a-port");
|
||||
|
||||
assert!(load().is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_names_the_key_at_fault() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("KMOOD_SERVER__PORT", "not-a-port");
|
||||
|
||||
let message = load().unwrap_err().to_string().to_lowercase();
|
||||
|
||||
assert!(message.contains("port"), "unhelpful message: {message}");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_file_that_is_missing_is_refused_rather_than_ignored() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.set_env("KMOOD_CONFIG_FILE", "nowhere.toml");
|
||||
|
||||
assert!(load().is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user