1use std::ffi::{OsStr, OsString};
2use std::net::SocketAddr;
3use std::path::PathBuf;
4
5use crate::ServerError;
6
7use super::types::ServerConfig;
8
9const ENV_PREFIX: &str = "LIMINAL_";
10const LISTEN_ADDRESS: &str = "LIMINAL_LISTEN_ADDRESS";
11const HEALTH_LISTEN_ADDRESS: &str = "LIMINAL_HEALTH_LISTEN_ADDRESS";
12const DRAIN_TIMEOUT_MS: &str = "LIMINAL_DRAIN_TIMEOUT_MS";
13const PERSISTENCE_PATH: &str = "LIMINAL_PERSISTENCE_PATH";
14const CLUSTER_NODE_NAME: &str = "LIMINAL_CLUSTER_NODE_NAME";
15const CLUSTER_SEED_NODES: &str = "LIMINAL_CLUSTER_SEED_NODES";
16const CLUSTER_LISTEN_ADDRESS: &str = "LIMINAL_CLUSTER_LISTEN_ADDRESS";
17const CLUSTER_COOKIE: &str = "LIMINAL_CLUSTER_COOKIE";
18const AUTH_TOKEN: &str = "LIMINAL_AUTH_TOKEN";
19
20pub fn apply_env_overrides(config: ServerConfig) -> Result<ServerConfig, ServerError> {
43 apply_env_overrides_from(config, std::env::vars_os())
44}
45
46pub(crate) fn apply_env_overrides_from<I>(
47 mut config: ServerConfig,
48 variables: I,
49) -> Result<ServerConfig, ServerError>
50where
51 I: IntoIterator<Item = (OsString, OsString)>,
52{
53 for (key, value) in variables {
54 let Some(key) = key.to_str() else {
55 continue;
56 };
57
58 if !key.starts_with(ENV_PREFIX) {
59 continue;
60 }
61
62 match key {
63 LISTEN_ADDRESS => {
64 config.listen_address = parse_socket_addr(LISTEN_ADDRESS, &value)?;
65 }
66 HEALTH_LISTEN_ADDRESS => {
67 config.health_listen_address = parse_socket_addr(HEALTH_LISTEN_ADDRESS, &value)?;
68 }
69 DRAIN_TIMEOUT_MS => {
70 config.drain_timeout_ms = parse_u64(DRAIN_TIMEOUT_MS, &value)?;
71 }
72 PERSISTENCE_PATH => {
73 config.persistence_path = Some(PathBuf::from(value));
74 }
75 CLUSTER_NODE_NAME => {
76 let node_name = env_string(CLUSTER_NODE_NAME, &value)?;
77 cluster_required(&mut config, CLUSTER_NODE_NAME)?.node_name = node_name;
78 }
79 CLUSTER_SEED_NODES => {
80 let seed_nodes = parse_seed_nodes(&value)?;
81 cluster_required(&mut config, CLUSTER_SEED_NODES)?.seed_nodes = seed_nodes;
82 }
83 CLUSTER_LISTEN_ADDRESS => {
84 let listen_address = parse_socket_addr(CLUSTER_LISTEN_ADDRESS, &value)?;
85 cluster_required(&mut config, CLUSTER_LISTEN_ADDRESS)?.listen_address =
86 listen_address;
87 }
88 CLUSTER_COOKIE => {
89 let cookie = env_string(CLUSTER_COOKIE, &value)?;
90 cluster_required(&mut config, CLUSTER_COOKIE)?.cookie = cookie;
91 }
92 AUTH_TOKEN => {
93 let token = env_string(AUTH_TOKEN, &value)?;
98 config.auth = Some(super::types::AuthConfig { token });
99 }
100 _ => {}
101 }
102 }
103
104 Ok(config)
105}
106
107fn parse_socket_addr(name: &str, value: &OsStr) -> Result<SocketAddr, ServerError> {
108 let value = env_string(name, value)?;
109 value.parse::<SocketAddr>().map_err(|error| {
110 config_load(format!(
111 "environment variable {name} must be a socket address: {error}"
112 ))
113 })
114}
115
116fn parse_u64(name: &str, value: &OsStr) -> Result<u64, ServerError> {
117 let value = env_string(name, value)?;
118 value.parse::<u64>().map_err(|error| {
119 config_load(format!(
120 "environment variable {name} must be an unsigned integer: {error}"
121 ))
122 })
123}
124
125fn parse_seed_nodes(value: &OsStr) -> Result<Vec<SocketAddr>, ServerError> {
126 let value = env_string(CLUSTER_SEED_NODES, value)?;
127 if value.trim().is_empty() {
128 return Ok(Vec::new());
129 }
130
131 value
132 .split(',')
133 .enumerate()
134 .map(|(index, candidate)| parse_seed_node(index, candidate))
135 .collect()
136}
137
138fn parse_seed_node(index: usize, candidate: &str) -> Result<SocketAddr, ServerError> {
139 let candidate = candidate.trim();
140 if candidate.is_empty() {
141 return Err(config_load(format!(
142 "environment variable {CLUSTER_SEED_NODES} contains an empty seed node at position {}",
143 index + 1
144 )));
145 }
146
147 candidate.parse::<SocketAddr>().map_err(|error| {
148 config_load(format!(
149 "environment variable {CLUSTER_SEED_NODES} contains invalid seed node '{}' at position {}: {error}",
150 candidate,
151 index + 1
152 ))
153 })
154}
155
156fn env_string(name: &str, value: &OsStr) -> Result<String, ServerError> {
157 value.to_str().map(str::to_owned).ok_or_else(|| {
158 config_load(format!(
159 "environment variable {name} contains non-Unicode data"
160 ))
161 })
162}
163
164fn cluster_required<'a>(
165 config: &'a mut ServerConfig,
166 name: &str,
167) -> Result<&'a mut super::types::ClusterConfig, ServerError> {
168 config
169 .cluster
170 .as_mut()
171 .ok_or_else(|| ServerError::ConfigValidation {
172 message: format!(
173 "environment variable {name} requires a [cluster] section in the configuration file"
174 ),
175 })
176}
177
178const fn config_load(message: String) -> ServerError {
179 ServerError::ConfigLoad { message }
180}
181
182#[cfg(test)]
183mod tests {
184 use std::ffi::OsString;
185 use std::net::SocketAddr;
186 use std::path::{Path, PathBuf};
187
188 use crate::ServerError;
189
190 use super::apply_env_overrides_from;
191 use crate::config::types::{ChannelDef, ClusterConfig, RoutingRuleDef, ServerConfig};
192 use crate::config::{load_from_file, validate};
193
194 fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
195 Ok(address.parse()?)
196 }
197
198 fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
199 Ok(ServerConfig {
200 listen_address: socket("127.0.0.1:8080")?,
201 health_listen_address: socket("127.0.0.1:8081")?,
202 drain_timeout_ms: 30_000,
203 channels: vec![ChannelDef {
204 name: "orders".to_owned(),
205 schema_ref: None,
206 durable: true,
207 loaded_schema: None,
208 }],
209 routing_rules: vec![RoutingRuleDef {
210 source_channel: "orders".to_owned(),
211 target_channel: "orders".to_owned(),
212 predicate: None,
213 }],
214 persistence_path: Some(PathBuf::from("/tmp")),
215 cluster: Some(ClusterConfig {
216 node_name: "node-a".to_owned(),
217 listen_address: socket("127.0.0.1:9000")?,
218 seed_nodes: vec![socket("127.0.0.1:9001")?],
219 cookie: "test-cookie".to_owned(),
220 }),
221 auth: None,
222 })
223 }
224
225 fn env_pair(name: &str, value: &str) -> (OsString, OsString) {
226 (OsString::from(name), OsString::from(value))
227 }
228
229 fn write_temp_config(contents: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
230 let path = std::env::temp_dir().join(format!(
231 "liminal-server-env-pipeline-{}.toml",
232 std::process::id()
233 ));
234 std::fs::write(&path, contents)?;
235 Ok(path)
236 }
237
238 fn remove_temp_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
239 if path.exists() {
240 std::fs::remove_file(path)?;
241 }
242 Ok(())
243 }
244
245 #[test]
246 fn listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
247 let config = sample_config()?;
248 let config = apply_env_overrides_from(
249 config,
250 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
251 )?;
252
253 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
254
255 Ok(())
256 }
257
258 #[test]
259 fn health_listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>>
260 {
261 let config = sample_config()?;
262 let config = apply_env_overrides_from(
263 config,
264 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "0.0.0.0:9191")],
265 )?;
266
267 assert_eq!(config.health_listen_address, socket("0.0.0.0:9191")?);
268
269 Ok(())
270 }
271
272 #[test]
273 fn drain_timeout_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
274 let config = sample_config()?;
275 let config =
276 apply_env_overrides_from(config, vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "1250")])?;
277
278 assert_eq!(config.drain_timeout_ms, 1250);
279
280 Ok(())
281 }
282
283 #[test]
284 fn persistence_path_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
285 let config = sample_config()?;
286 let config = apply_env_overrides_from(
287 config,
288 vec![env_pair("LIMINAL_PERSISTENCE_PATH", "/var/lib/liminal")],
289 )?;
290
291 assert_eq!(
292 config.persistence_path.as_deref(),
293 Some(Path::new("/var/lib/liminal"))
294 );
295
296 Ok(())
297 }
298
299 #[test]
300 fn cluster_overrides_replace_existing_cluster_values() -> Result<(), Box<dyn std::error::Error>>
301 {
302 let config = sample_config()?;
303 let config = apply_env_overrides_from(
304 config,
305 vec![
306 env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b"),
307 env_pair(
308 "LIMINAL_CLUSTER_SEED_NODES",
309 "127.0.0.1:9100, 127.0.0.1:9200",
310 ),
311 ],
312 )?;
313
314 let Some(cluster) = config.cluster else {
315 return Err("cluster config should remain present".into());
316 };
317 assert_eq!(cluster.node_name, "node-b");
318 assert_eq!(cluster.seed_nodes.len(), 2);
319 assert_eq!(cluster.seed_nodes[0], socket("127.0.0.1:9100")?);
320 assert_eq!(cluster.seed_nodes[1], socket("127.0.0.1:9200")?);
321
322 Ok(())
323 }
324
325 #[test]
326 fn cluster_listen_address_and_cookie_overrides_replace_values()
327 -> Result<(), Box<dyn std::error::Error>> {
328 let config = sample_config()?;
329 let config = apply_env_overrides_from(
330 config,
331 vec![
332 env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500"),
333 env_pair("LIMINAL_CLUSTER_COOKIE", "override-cookie"),
334 ],
335 )?;
336
337 let Some(cluster) = config.cluster else {
338 return Err("cluster config should remain present".into());
339 };
340 assert_eq!(cluster.listen_address, socket("127.0.0.1:9500")?);
341 assert_eq!(cluster.cookie, "override-cookie");
342
343 Ok(())
344 }
345
346 #[test]
347 fn cluster_listen_address_override_without_cluster_section_returns_validation_error()
348 -> Result<(), Box<dyn std::error::Error>> {
349 let mut config = sample_config()?;
350 config.cluster = None;
351 let result = apply_env_overrides_from(
352 config,
353 vec![env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500")],
354 );
355
356 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
357
358 Ok(())
359 }
360
361 #[test]
362 fn auth_token_override_replaces_existing_token() -> Result<(), Box<dyn std::error::Error>> {
363 let mut config = sample_config()?;
364 config.auth = Some(crate::config::types::AuthConfig {
365 token: "file-token".to_owned(),
366 });
367
368 let config =
369 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
370
371 let auth = config.auth.ok_or("auth section should remain present")?;
372 assert_eq!(auth.token, "env-token");
373
374 Ok(())
375 }
376
377 #[test]
378 fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
379 {
380 let mut config = sample_config()?;
381 config.auth = None;
382
383 let config =
384 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
385
386 let auth = config.auth.ok_or("auth section should have been created")?;
388 assert_eq!(auth.token, "env-token");
389
390 Ok(())
391 }
392
393 #[test]
394 fn absent_environment_variables_leave_config_unchanged()
395 -> Result<(), Box<dyn std::error::Error>> {
396 let config = sample_config()?;
397 let original_address = config.listen_address;
398 let original_health_address = config.health_listen_address;
399 let original_drain_timeout_ms = config.drain_timeout_ms;
400 let original_path = config.persistence_path.clone();
401 let original_cluster_name = config
402 .cluster
403 .as_ref()
404 .map(|cluster| cluster.node_name.clone());
405
406 let config = apply_env_overrides_from(config, Vec::new())?;
407
408 assert_eq!(config.listen_address, original_address);
409 assert_eq!(config.health_listen_address, original_health_address);
410 assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
411 assert_eq!(config.persistence_path, original_path);
412 assert_eq!(
413 config
414 .cluster
415 .as_ref()
416 .map(|cluster| cluster.node_name.clone()),
417 original_cluster_name
418 );
419
420 Ok(())
421 }
422
423 #[test]
424 fn invalid_listen_address_override_returns_config_load()
425 -> Result<(), Box<dyn std::error::Error>> {
426 let config = sample_config()?;
427 let result = apply_env_overrides_from(
428 config,
429 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
430 );
431
432 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
433
434 Ok(())
435 }
436
437 #[test]
438 fn invalid_health_listen_address_override_returns_config_load()
439 -> Result<(), Box<dyn std::error::Error>> {
440 let config = sample_config()?;
441 let result = apply_env_overrides_from(
442 config,
443 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
444 );
445
446 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
447
448 Ok(())
449 }
450
451 #[test]
452 fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
453 {
454 let config = sample_config()?;
455 let result = apply_env_overrides_from(
456 config,
457 vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
458 );
459
460 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
461
462 Ok(())
463 }
464
465 #[test]
466 fn cluster_override_without_cluster_section_returns_validation_error()
467 -> Result<(), Box<dyn std::error::Error>> {
468 let mut config = sample_config()?;
469 config.cluster = None;
470 let result = apply_env_overrides_from(
471 config,
472 vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
473 );
474
475 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
476
477 Ok(())
478 }
479
480 #[test]
481 fn file_then_env_then_validate_pipeline_gives_env_precedence()
482 -> Result<(), Box<dyn std::error::Error>> {
483 let toml = r#"
484listen_address = "127.0.0.1:8080"
485health_listen_address = "127.0.0.1:8081"
486drain_timeout_ms = 30000
487persistence_path = "/tmp"
488
489[[channels]]
490name = "orders"
491durable = true
492
493[[routing_rules]]
494source_channel = "orders"
495target_channel = "orders"
496"#;
497 let path = write_temp_config(toml)?;
498 let config = load_from_file(&path)?;
499 let mut config = apply_env_overrides_from(
500 config,
501 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
502 )?;
503 validate(&mut config, path.parent())?;
504 remove_temp_file(&path)?;
505
506 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
507
508 Ok(())
509 }
510}