mod admin_auth;
mod cluster;
mod engine;
mod env_overrides;
pub(crate) mod env_substitute;
mod kafka;
mod logging;
mod observability;
mod query;
mod rate_limit;
mod retired_env;
mod server;
mod storage;
mod trace_queue;
mod unknown_env;
pub(super) mod validation;
mod write;
pub use admin_auth::{AdminAuthConfig, constant_time_eq};
pub use cluster::ClusterConfig;
pub use engine::EngineConfig;
pub use env_overrides::known_env_override_keys;
pub use kafka::{DlqConfig, KafkaAuthConfig, KafkaIngestConfig, TopicMapping};
pub use logging::{LogFormat, LoggingConfig};
pub use observability::{
AsyncOnOverflow, CorsConfig, MetricsConfig, TraceStorageConfig, TraceStorageMode, TracingConfig,
};
pub use query::QueryConfig;
pub use rate_limit::{EndpointRateLimits, RateLimitConfig};
pub use retired_env::retired_env_names;
pub use server::{CompressionConfig, DocsConfig, IngestConfig, ServerConfig, TlsConfig};
pub use storage::StorageConfig;
pub use trace_queue::TraceQueueConfig;
pub use unknown_env::{RESERVED_PREFIX as RESERVED_ENV_PREFIX, looks_like_env_override};
pub use write::WriteConfig;
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::errors::OrionError;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AppConfig {
#[serde(default = "default_environment")]
pub environment: String,
pub server: ServerConfig,
pub storage: StorageConfig,
pub ingest: IngestConfig,
pub engine: EngineConfig,
pub trace_queue: TraceQueueConfig,
pub query: QueryConfig,
pub write: WriteConfig,
pub kafka: KafkaIngestConfig,
pub logging: LoggingConfig,
pub metrics: MetricsConfig,
pub cors: CorsConfig,
pub tracing: TracingConfig,
pub trace_storage: TraceStorageConfig,
pub rate_limit: RateLimitConfig,
pub channel_filter: ChannelFilterConfig,
pub audit: AuditConfig,
pub admin_auth: AdminAuthConfig,
pub cluster: ClusterConfig,
}
fn default_environment() -> String {
"development".to_string()
}
impl Default for AppConfig {
fn default() -> Self {
Self {
environment: default_environment(),
server: ServerConfig::default(),
storage: StorageConfig::default(),
ingest: IngestConfig::default(),
engine: EngineConfig::default(),
trace_queue: TraceQueueConfig::default(),
query: QueryConfig::default(),
write: WriteConfig::default(),
kafka: KafkaIngestConfig::default(),
logging: LoggingConfig::default(),
metrics: MetricsConfig::default(),
cors: CorsConfig::default(),
tracing: TracingConfig::default(),
trace_storage: TraceStorageConfig::default(),
rate_limit: RateLimitConfig::default(),
channel_filter: ChannelFilterConfig::default(),
audit: AuditConfig::default(),
admin_auth: AdminAuthConfig::default(),
cluster: ClusterConfig::default(),
}
}
}
impl AppConfig {
pub fn is_production(&self) -> bool {
self.environment.to_lowercase().starts_with("prod")
}
pub fn docs_enabled(&self) -> bool {
self.server
.docs
.enabled
.unwrap_or_else(|| !self.is_production())
}
pub fn verbose_errors(&self) -> bool {
self.server
.verbose_errors
.unwrap_or_else(|| !self.is_production())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ChannelFilterConfig {
pub include: Vec<String>,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AuditConfig {
pub retention_days: u64,
pub cleanup_interval_secs: u64,
pub max_pending: usize,
pub drain_timeout_secs: u64,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
retention_days: 90,
cleanup_interval_secs: 3600,
max_pending: 1000,
drain_timeout_secs: 5,
}
}
}
impl AuditConfig {
pub(crate) fn validate(&self) -> Result<(), OrionError> {
validation::require_nonzero(self.max_pending as u64, "audit.max_pending")?;
validation::require_nonzero(self.drain_timeout_secs, "audit.drain_timeout_secs")
}
}
pub fn load_config(path: Option<&str>) -> Result<AppConfig, OrionError> {
let mut referenced_by_config_file = std::collections::BTreeSet::new();
let mut config = if let Some(p) = path {
let raw = std::fs::read_to_string(Path::new(p)).map_err(|e| OrionError::Internal {
context: format!("Failed to read config file '{p}'"),
source: Some(Box::new(e)),
})?;
referenced_by_config_file = env_substitute::referenced_vars(&raw);
let content = env_substitute::substitute(&raw, p)?;
toml::from_str::<AppConfig>(&content).map_err(|e| OrionError::Internal {
context: format!("Failed to parse config file '{p}'"),
source: Some(Box::new(e)),
})?
} else {
AppConfig::default()
};
env_overrides::apply_env_overrides(&mut config, &referenced_by_config_file)?;
validation::validate_config(&config)?;
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.storage.url, "sqlite:orion.db");
assert_eq!(config.storage.max_connections, 50);
assert_eq!(config.storage.busy_timeout_ms, 5000);
assert_eq!(config.storage.acquire_timeout_secs, 3);
assert_eq!(config.engine.health_check_timeout_secs, 2);
assert_eq!(config.trace_queue.shutdown_timeout_secs, 30);
}
#[test]
fn test_load_config_no_file() {
let _guard = env_guard();
let config = load_config(None).expect("test");
assert!(config.server.port > 0);
assert!(!config.server.host.is_empty());
}
#[test]
fn test_toml_parsing() {
let toml_str = r#"
[server]
host = "127.0.0.1"
port = 3000
[storage]
url = "sqlite:test.db"
[logging]
level = "debug"
format = "json"
"#;
let config: AppConfig = toml::from_str(toml_str).expect("test");
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 3000);
assert_eq!(config.storage.url, "sqlite:test.db");
assert_eq!(config.logging.level, "debug");
}
#[test]
fn test_load_config_nonexistent_file() {
let _guard = env_guard();
let result = load_config(Some("/nonexistent/path/config.toml"));
assert!(result.is_err());
}
#[test]
fn load_config_refuses_a_misspelled_override() {
let _guard = env_guard();
unsafe { std::env::set_var("ORION_SERVER__PORTT", "3000") };
let result = load_config(None);
unsafe { std::env::remove_var("ORION_SERVER__PORTT") };
let err = result
.expect_err("a misspelled ORION_* variable must not be silently ignored")
.to_string();
assert!(err.contains("ORION_SERVER__PORTT"), "{err}");
assert!(err.contains("ORION_SERVER__PORT"), "{err}");
}
#[test]
fn load_config_ignores_the_reserved_namespace() {
let _guard = env_guard();
unsafe { std::env::set_var("ORION_SECRET_SOME_TOKEN", "s3cret") };
let result = load_config(None);
unsafe { std::env::remove_var("ORION_SECRET_SOME_TOKEN") };
result.expect("ORION_SECRET_* is never interpreted as configuration");
}
#[test]
fn misspelled_key_is_rejected() {
let err = toml::from_str::<AppConfig>("[server]\nwrokers = 4\n")
.expect_err("a typo'd key must not deserialize to the default");
let msg = err.to_string();
assert!(
msg.contains("wrokers"),
"the error must name the unknown key so it can be found, got: {msg}"
);
}
#[test]
fn misspelled_section_is_rejected() {
let err = toml::from_str::<AppConfig>("[serverr]\nport = 3000\n")
.expect_err("a typo'd section must not be ignored");
assert!(err.to_string().contains("serverr"));
}
#[test]
fn misspelled_security_key_is_rejected() {
let err = toml::from_str::<AppConfig>("[admin_auth]\nenable = true\n")
.expect_err("`enable` is not `enabled` and must not silently disable admin auth");
assert!(err.to_string().contains("enable"));
}
#[test]
fn misspelled_nested_key_is_rejected() {
let err = toml::from_str::<AppConfig>("[server.tls]\nenabld = true\n")
.expect_err("nested structs must reject unknown keys as well");
assert!(err.to_string().contains("enabld"));
}
#[test]
fn documented_keys_still_parse() {
let toml_str = r#"
[server]
host = "127.0.0.1"
port = 3000
[server.tls]
enabled = false
[admin_auth]
enabled = true
api_keys = ["0123456789abcdef0123456789abcdef"]
[rate_limit]
enabled = true
[rate_limit.endpoints]
admin_rps = 20
"#;
let config: AppConfig = toml::from_str(toml_str).expect("documented keys must parse");
assert_eq!(config.server.port, 3000);
assert!(config.admin_auth.enabled);
}
fn write_temp_toml(content: &str, suffix: &str) -> String {
let mut path = std::env::temp_dir();
path.push(format!(
"orion-test-config-{}-{}.toml",
suffix,
uuid::Uuid::new_v4()
));
std::fs::write(&path, content).expect("test");
path.to_string_lossy().into_owned()
}
#[test]
fn test_load_config_substitutes_env_vars() {
let _guard = env_guard();
let var_name = "ORION_TEST_SUBST_DB_URL";
unsafe {
std::env::set_var(var_name, "postgres://test-host/db");
}
let toml = format!(
r#"
[server]
port = 8080
[storage]
url = "${{{var_name}}}"
"#
);
let path = write_temp_toml(&toml, "subst");
let config = load_config(Some(&path)).expect("test");
assert_eq!(config.storage.url, "postgres://test-host/db");
unsafe {
std::env::remove_var(var_name);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_load_config_uses_default_when_var_missing() {
let _guard = env_guard();
let toml = r#"
[server]
port = 8080
[storage]
url = "${ORION_TEST_NEVER_SET_VAR:-sqlite:fallback.db}"
"#;
let path = write_temp_toml(toml, "default");
let config = load_config(Some(&path)).expect("test");
assert_eq!(config.storage.url, "sqlite:fallback.db");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_load_config_fails_on_missing_required_var() {
let _guard = env_guard();
let toml = r#"
[storage]
url = "${ORION_TEST_REQUIRED_BUT_UNSET_xyz}"
"#;
let path = write_temp_toml(toml, "missing");
let result = load_config(Some(&path));
let err = result.expect_err("substitution must fail when required var is unset");
match err {
OrionError::Config { message } => {
assert!(message.contains("ORION_TEST_REQUIRED_BUT_UNSET_xyz"));
}
other => unreachable!("expected Config error, got {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_toml_parsing_with_rate_limit() {
let toml_str = r#"
[server]
port = 8080
[rate_limit]
enabled = true
default_rps = 200
default_burst = 100
[rate_limit.endpoints]
admin_rps = 50
data_rps = 500
"#;
let config: AppConfig = toml::from_str(toml_str).expect("test");
assert!(config.rate_limit.enabled);
assert_eq!(config.rate_limit.default_rps, 200);
assert_eq!(config.rate_limit.default_burst, 100);
assert_eq!(config.rate_limit.endpoints.admin_rps, Some(50));
assert_eq!(config.rate_limit.endpoints.data_rps, Some(500));
}
#[test]
fn test_cors_config_default() {
let config = CorsConfig::default();
assert_eq!(config.allowed_origins, vec!["*"]);
}
#[test]
fn test_kafka_ingest_config_default() {
let config = KafkaIngestConfig::default();
assert!(!config.enabled);
assert_eq!(config.brokers, vec!["localhost:9092"]);
assert_eq!(config.group_id, "orion");
assert!(config.topics.is_empty());
assert!(!config.dlq.enabled);
assert_eq!(config.dlq.topic, "orion-dlq");
}
#[test]
fn test_tracing_config_default() {
let config = TracingConfig::default();
assert!(!config.enabled);
assert_eq!(config.otlp_endpoint, "http://localhost:4317");
assert_eq!(config.service_name, "orion");
assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn audit_queue_knobs_reject_zero() {
assert!(AuditConfig::default().validate().is_ok());
let err = AuditConfig {
max_pending: 0,
..AuditConfig::default()
}
.validate()
.expect_err("a zero-capacity audit queue must be refused");
assert!(err.to_string().contains("audit.max_pending"), "{err}");
let err = AuditConfig {
drain_timeout_secs: 0,
..AuditConfig::default()
}
.validate()
.expect_err("a zero drain timeout skips the drain, it does not disable the bound");
assert!(
err.to_string().contains("audit.drain_timeout_secs"),
"{err}"
);
}
#[test]
fn test_toml_parsing_admin_auth() {
let toml_str = r#"
[admin_auth]
enabled = true
api_keys = ["my-key"]
header = "X-Custom-Auth"
"#;
let config: AppConfig = toml::from_str(toml_str).expect("test");
assert!(config.admin_auth.enabled);
assert_eq!(config.admin_auth.api_keys, vec!["my-key".to_string()]);
assert_eq!(config.admin_auth.header, "X-Custom-Auth");
}
#[test]
fn test_toml_parsing_admin_auth_api_keys() {
let toml_str = r#"
[admin_auth]
enabled = true
api_keys = ["key-a", "key-b"]
header = "Authorization"
"#;
let config: AppConfig = toml::from_str(toml_str).expect("test");
assert!(config.admin_auth.enabled);
assert_eq!(
config.admin_auth.api_keys,
vec!["key-a".to_string(), "key-b".to_string()]
);
}
}