//! Server deployment configuration. //! //! Reads from environment variables to select adapter (local/redis/nats) //! and WebTransport settings. use std::env; /// Adapter + message bus configuration for multi-node scale-out. #[derive(Debug, Clone)] pub struct DeployConfig { /// "local" | "redis" | "nats" pub adapter_mode: String, /// Redis URL for single-node Redis, e.g. `redis://localhost:6379`. pub redis_url: Option, /// Whether Redis cluster mode is enabled. pub redis_cluster_enabled: bool, /// Redis cluster nodes, comma-separated host:port pairs. /// Example: "redis1:6379,redis2:6379,redis3:6379" pub redis_cluster_nodes: String, /// Redis password (optional). pub redis_password: String, /// NATS connection URL (used when adapter_mode = "nats"). pub nats_url: String, /// Unique server ID for this node. pub server_id: String, /// Enable WebTransport server. pub webtransport_enabled: bool, /// WebTransport listen port. pub webtransport_port: u16, /// TLS certificate path (required for WebTransport). pub cert_path: String, /// TLS key path (required for WebTransport). pub key_path: String, } impl DeployConfig { pub fn from_env() -> Self { let server_id = env::var("IMKS_SERVER_ID").unwrap_or_else(|_| hostname()); Self { adapter_mode: env::var("IMKS_ADAPTER").unwrap_or_else(|_| "local".into()), redis_url: env::var("IMKS_REDIS_URL") .ok() .or_else(|| env::var("APP_REDIS_URL").ok()) .filter(|v| !v.trim().is_empty()), redis_cluster_enabled: env_bool("IMKS_REDIS_CLUSTER_ENABLED") .or_else(|| env_bool("APP_REDIS_CLUSTER_ENABLED")) .unwrap_or(false), redis_cluster_nodes: env::var("IMKS_REDIS_CLUSTER_NODES") .or_else(|_| env::var("APP_REDIS_CLUSTER_NODES")) .unwrap_or_default(), redis_password: env::var("IMKS_REDIS_PASSWORD") .or_else(|_| env::var("APP_REDIS_PASSWORD")) .unwrap_or_default(), nats_url: env::var("IMKS_NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()), server_id, webtransport_enabled: env::var("IMKS_WT_ENABLED") .map(|v| v == "true" || v == "1") .unwrap_or(false), webtransport_port: env::var("IMKS_WT_PORT") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(3001), cert_path: env::var("IMKS_WT_CERT_PATH").unwrap_or_default(), key_path: env::var("IMKS_WT_KEY_PATH").unwrap_or_default(), } } /// Build a redis-rs compatible Redis URL. /// /// This mirrors appks: /// - cluster disabled: require `IMKS_REDIS_URL` or `APP_REDIS_URL` /// - cluster enabled: require `IMKS_REDIS_CLUSTER_NODES` or `APP_REDIS_CLUSTER_NODES` pub fn redis_url(&self) -> Result { if self.redis_cluster_enabled { return self.redis_cluster_url(); } self.redis_url.clone().ok_or_else(|| { "Redis cluster disabled but IMKS_REDIS_URL/APP_REDIS_URL is not set".into() }) } /// Build a redis-cluster URL from cluster_nodes and optional password. /// /// Produces a redis-rs compatible URL in the format: /// `redis-cluster://[password@]host1:port1?node=host2:port2&node=host3:port3` /// /// The first node becomes the URL authority host:port; additional nodes /// are appended as `node` query parameters. pub fn redis_cluster_url(&self) -> Result { let auth = if self.redis_password.is_empty() { String::new() } else { format!(":{}@", self.redis_password) }; let nodes: Vec = self .redis_cluster_nodes .split(',') .map(normalize_redis_cluster_node) .filter(|s| !s.is_empty()) .collect(); if nodes.is_empty() { return Err("Redis cluster enabled but IMKS_REDIS_CLUSTER_NODES/APP_REDIS_CLUSTER_NODES is empty".into()); } if nodes.len() == 1 { return Ok(format!("redis-cluster://{}{}", auth, nodes[0])); } let mut url = format!("redis-cluster://{}{}", auth, nodes[0]); for (i, node) in nodes.iter().skip(1).enumerate() { url.push(if i == 0 { '?' } else { '&' }); url.push_str(&format!("node={}", node)); } Ok(url) } } impl Default for DeployConfig { fn default() -> Self { Self::from_env() } } fn env_bool(key: &str) -> Option { env::var(key).ok().map(|v| v == "true" || v == "1") } fn normalize_redis_cluster_node(node: &str) -> String { let trimmed = node.trim(); let without_scheme = trimmed .strip_prefix("redis://") .or_else(|| trimmed.strip_prefix("rediss://")) .unwrap_or(trimmed); let without_auth = without_scheme .rsplit_once('@') .map(|(_, host)| host) .unwrap_or(without_scheme); without_auth .split('/') .next() .unwrap_or_default() .to_string() } fn hostname() -> String { env::var("HOSTNAME") .or_else(|_| env::var("HOST")) .unwrap_or_else(|_| "imks-node-1".into()) } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_cluster_nodes_accepts_appks_style_urls() { assert_eq!( normalize_redis_cluster_node("redis://127.0.0.1:6380"), "127.0.0.1:6380" ); assert_eq!( normalize_redis_cluster_node("rediss://127.0.0.1:6381/0"), "127.0.0.1:6381" ); assert_eq!( normalize_redis_cluster_node("redis://:secret@127.0.0.1:6382"), "127.0.0.1:6382" ); assert_eq!( normalize_redis_cluster_node("127.0.0.1:6383"), "127.0.0.1:6383" ); } #[test] fn redis_cluster_url_normalizes_nodes() { let config = DeployConfig { adapter_mode: "redis".into(), redis_url: None, redis_cluster_enabled: true, redis_cluster_nodes: "redis://127.0.0.1:6380,redis://127.0.0.1:6381".into(), redis_password: String::new(), nats_url: "nats://localhost:4222".into(), server_id: "test".into(), webtransport_enabled: false, webtransport_port: 3001, cert_path: String::new(), key_path: String::new(), }; assert_eq!( config.redis_cluster_url().unwrap(), "redis-cluster://127.0.0.1:6380?node=127.0.0.1:6381" ); } }