solstice-ci/crates/common/src/config.rs
Till Wegmueller 11ce9cc881
Introduce centralized configuration handling via KDL and environment variables
This commit adds:
- A unified configuration system (`AppConfig`) that aggregates KDL files and environment variables with precedence handling.
- Example KDL configuration files for the orchestrator and forge-integration modules.
- Updates to orchestrator and forge-integration to load and apply configurations from `AppConfig`.
- Improved AMQP and database configuration with overlays from CLI, environment, or KDL.
- Deprecated `TODO.txt` as it's now represented in the configuration examples.
2025-11-06 23:48:03 +01:00

97 lines
4 KiB
Rust

use std::collections::HashMap;
use std::path::PathBuf;
use miette::{IntoDiagnostic as _, Result};
use kdl::{KdlDocument, KdlValue};
/// Internal application configuration aggregated from env and KDL.
#[derive(Clone, Debug)]
pub struct AppConfig {
pub grpc_addr: Option<String>,
pub http_addr: Option<String>,
pub database_url: Option<String>,
pub otlp_endpoint: Option<String>,
pub mq: crate::mq::MqConfig,
}
impl AppConfig {
/// Load config by reading env vars and KDL files without mutating the environment.
/// Precedence: KDL (lowest) < Environment < CLI (applied by callers).
pub fn load(service: &str) -> Result<Self> {
let kdl_map = load_kdl_kv(service)?;
let grpc_addr = std::env::var("GRPC_ADDR").ok().or_else(|| kdl_map.get("GRPC_ADDR").cloned());
let http_addr = std::env::var("HTTP_ADDR").ok().or_else(|| kdl_map.get("HTTP_ADDR").cloned());
let database_url = std::env::var("DATABASE_URL").ok().or_else(|| kdl_map.get("DATABASE_URL").cloned());
let otlp_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok().or_else(|| kdl_map.get("OTEL_EXPORTER_OTLP_ENDPOINT").cloned());
// Build MQ config from env with KDL fallbacks, then defaults
let url = std::env::var("AMQP_URL")
.ok()
.or_else(|| kdl_map.get("AMQP_URL").cloned())
.unwrap_or_else(|| "amqp://127.0.0.1:5672/%2f".into());
let exchange = std::env::var("AMQP_EXCHANGE")
.ok()
.or_else(|| kdl_map.get("AMQP_EXCHANGE").cloned())
.unwrap_or_else(|| "solstice.jobs".into());
let routing_key = std::env::var("AMQP_ROUTING_KEY")
.ok()
.or_else(|| kdl_map.get("AMQP_ROUTING_KEY").cloned())
.unwrap_or_else(|| "jobrequest.v1".into());
let queue = std::env::var("AMQP_QUEUE")
.ok()
.or_else(|| kdl_map.get("AMQP_QUEUE").cloned())
.unwrap_or_else(|| "solstice.jobs.v1".into());
let dlx = std::env::var("AMQP_DLX")
.ok()
.or_else(|| kdl_map.get("AMQP_DLX").cloned())
.unwrap_or_else(|| "solstice.dlx".into());
let dlq = std::env::var("AMQP_DLQ")
.ok()
.or_else(|| kdl_map.get("AMQP_DLQ").cloned())
.unwrap_or_else(|| "solstice.jobs.v1.dlq".into());
let prefetch = std::env::var("AMQP_PREFETCH")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| kdl_map.get("AMQP_PREFETCH").and_then(|s| s.parse().ok()))
.unwrap_or(64u16);
let mq = crate::mq::MqConfig { url, exchange, routing_key, queue, dlx, dlq, prefetch };
Ok(Self { grpc_addr, http_addr, database_url, otlp_endpoint, mq })
}
}
/// Load KDL files into a simple key/value map of strings.
fn load_kdl_kv(service: &str) -> Result<HashMap<String, String>> {
let global = PathBuf::from("/etc/solstice/solstice.kdl");
let svc = PathBuf::from(format!("/etc/solstice/{}.kdl", service));
let mut map = HashMap::new();
for path in [global, svc] {
if !path.exists() { continue; }
let s = std::fs::read_to_string(&path).into_diagnostic()?;
let doc: KdlDocument = s.parse().into_diagnostic()?;
for node in doc.nodes() {
let key = node.name().value().to_string();
// Prefer first argument, otherwise `value` property; skip nulls
let value_str = if let Some(entry) = node.entries().first() {
kdl_value_to_string(entry.value())
} else if let Some(v) = node.get("value") {
kdl_value_to_string(v)
} else {
None
};
if let Some(v) = value_str {
// Only insert if not already set by a previous file (global lowest precedence)
map.entry(key).or_insert(v);
}
}
}
Ok(map)
}
fn kdl_value_to_string(v: &KdlValue) -> Option<String> {
match v {
KdlValue::Null => None,
_ => Some(v.to_string()),
}
}