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";
19const WEBSOCKET_LISTEN_ADDRESS: &str = "LIMINAL_WEBSOCKET_LISTEN_ADDRESS";
20const WEBSOCKET_PATH: &str = "LIMINAL_WEBSOCKET_PATH";
21const WEBSOCKET_ALLOWED_ORIGINS: &str = "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS";
22const WEBSOCKET_PING_INTERVAL_MS: &str = "LIMINAL_WEBSOCKET_PING_INTERVAL_MS";
23
24pub fn apply_env_overrides(config: ServerConfig) -> Result<ServerConfig, ServerError> {
50 apply_env_overrides_from(config, std::env::vars_os())
51}
52
53pub(crate) fn apply_env_overrides_from<I>(
54 mut config: ServerConfig,
55 variables: I,
56) -> Result<ServerConfig, ServerError>
57where
58 I: IntoIterator<Item = (OsString, OsString)>,
59{
60 for (key, value) in variables {
61 let Some(key) = key.to_str() else {
62 continue;
63 };
64
65 if !key.starts_with(ENV_PREFIX) {
66 continue;
67 }
68
69 match key {
70 LISTEN_ADDRESS => {
71 config.listen_address = parse_socket_addr(LISTEN_ADDRESS, &value)?;
72 }
73 HEALTH_LISTEN_ADDRESS => {
74 config.health_listen_address = parse_socket_addr(HEALTH_LISTEN_ADDRESS, &value)?;
75 }
76 DRAIN_TIMEOUT_MS => {
77 config.drain_timeout_ms = parse_u64(DRAIN_TIMEOUT_MS, &value)?;
78 }
79 PERSISTENCE_PATH => {
80 config.persistence_path = Some(PathBuf::from(value));
81 }
82 CLUSTER_NODE_NAME => {
83 let node_name = env_string(CLUSTER_NODE_NAME, &value)?;
84 cluster_required(&mut config, CLUSTER_NODE_NAME)?.node_name = node_name;
85 }
86 CLUSTER_SEED_NODES => {
87 let seed_nodes = parse_seed_nodes(&value)?;
88 cluster_required(&mut config, CLUSTER_SEED_NODES)?.seed_nodes = seed_nodes;
89 }
90 CLUSTER_LISTEN_ADDRESS => {
91 let listen_address = parse_socket_addr(CLUSTER_LISTEN_ADDRESS, &value)?;
92 cluster_required(&mut config, CLUSTER_LISTEN_ADDRESS)?.listen_address =
93 listen_address;
94 }
95 CLUSTER_COOKIE => {
96 let cookie = env_string(CLUSTER_COOKIE, &value)?;
97 cluster_required(&mut config, CLUSTER_COOKIE)?.cookie = cookie;
98 }
99 WEBSOCKET_LISTEN_ADDRESS => {
100 let listen_address = parse_socket_addr(WEBSOCKET_LISTEN_ADDRESS, &value)?;
101 websocket_required(&mut config, WEBSOCKET_LISTEN_ADDRESS)?.listen_address =
102 listen_address;
103 }
104 WEBSOCKET_PATH => {
105 let path = env_string(WEBSOCKET_PATH, &value)?;
106 websocket_required(&mut config, WEBSOCKET_PATH)?.path = path;
107 }
108 WEBSOCKET_ALLOWED_ORIGINS => {
109 let allowed_origins = parse_allowed_origins(&value)?;
110 websocket_required(&mut config, WEBSOCKET_ALLOWED_ORIGINS)?.allowed_origins =
111 allowed_origins;
112 }
113 WEBSOCKET_PING_INTERVAL_MS => {
114 let interval = parse_u64(WEBSOCKET_PING_INTERVAL_MS, &value)?;
115 websocket_required(&mut config, WEBSOCKET_PING_INTERVAL_MS)?.ping_interval_ms =
116 Some(interval);
117 }
118 AUTH_TOKEN => {
119 let token = env_string(AUTH_TOKEN, &value)?;
124 config.auth = Some(super::types::AuthConfig { token, pass: None });
125 }
126 _ => {}
127 }
128 }
129
130 Ok(config)
131}
132
133fn parse_socket_addr(name: &str, value: &OsStr) -> Result<SocketAddr, ServerError> {
134 let value = env_string(name, value)?;
135 value.parse::<SocketAddr>().map_err(|error| {
136 config_load(format!(
137 "environment variable {name} must be a socket address: {error}"
138 ))
139 })
140}
141
142fn parse_u64(name: &str, value: &OsStr) -> Result<u64, ServerError> {
143 let value = env_string(name, value)?;
144 value.parse::<u64>().map_err(|error| {
145 config_load(format!(
146 "environment variable {name} must be an unsigned integer: {error}"
147 ))
148 })
149}
150
151fn parse_seed_nodes(value: &OsStr) -> Result<Vec<SocketAddr>, ServerError> {
152 let value = env_string(CLUSTER_SEED_NODES, value)?;
153 if value.trim().is_empty() {
154 return Ok(Vec::new());
155 }
156
157 value
158 .split(',')
159 .enumerate()
160 .map(|(index, candidate)| parse_seed_node(index, candidate))
161 .collect()
162}
163
164fn parse_seed_node(index: usize, candidate: &str) -> Result<SocketAddr, ServerError> {
165 let candidate = candidate.trim();
166 if candidate.is_empty() {
167 return Err(config_load(format!(
168 "environment variable {CLUSTER_SEED_NODES} contains an empty seed node at position {}",
169 index + 1
170 )));
171 }
172
173 candidate.parse::<SocketAddr>().map_err(|error| {
174 config_load(format!(
175 "environment variable {CLUSTER_SEED_NODES} contains invalid seed node '{}' at position {}: {error}",
176 candidate,
177 index + 1
178 ))
179 })
180}
181
182fn env_string(name: &str, value: &OsStr) -> Result<String, ServerError> {
183 value.to_str().map(str::to_owned).ok_or_else(|| {
184 config_load(format!(
185 "environment variable {name} contains non-Unicode data"
186 ))
187 })
188}
189
190fn parse_allowed_origins(value: &OsStr) -> Result<Vec<String>, ServerError> {
196 let value = env_string(WEBSOCKET_ALLOWED_ORIGINS, value)?;
197 if value.trim().is_empty() {
198 return Ok(Vec::new());
199 }
200 value
201 .split(',')
202 .enumerate()
203 .map(|(index, candidate)| {
204 let candidate = candidate.trim();
205 if candidate.is_empty() {
206 Err(config_load(format!(
207 "environment variable {WEBSOCKET_ALLOWED_ORIGINS} contains an empty origin \
208 at position {}",
209 index + 1
210 )))
211 } else {
212 Ok(candidate.to_owned())
213 }
214 })
215 .collect()
216}
217
218fn websocket_required<'a>(
222 config: &'a mut ServerConfig,
223 name: &str,
224) -> Result<&'a mut super::types::WebSocketConfig, ServerError> {
225 config
226 .websocket
227 .as_mut()
228 .ok_or_else(|| ServerError::ConfigValidation {
229 message: format!(
230 "environment variable {name} requires a [websocket] section in the configuration \
231 file"
232 ),
233 })
234}
235
236fn cluster_required<'a>(
237 config: &'a mut ServerConfig,
238 name: &str,
239) -> Result<&'a mut super::types::ClusterConfig, ServerError> {
240 config
241 .cluster
242 .as_mut()
243 .ok_or_else(|| ServerError::ConfigValidation {
244 message: format!(
245 "environment variable {name} requires a [cluster] section in the configuration file"
246 ),
247 })
248}
249
250const fn config_load(message: String) -> ServerError {
251 ServerError::ConfigLoad { message }
252}
253
254#[cfg(test)]
255mod tests {
256 use std::ffi::OsString;
257 use std::net::SocketAddr;
258 use std::path::{Path, PathBuf};
259
260 use crate::ServerError;
261
262 use super::apply_env_overrides_from;
263 use crate::config::types::{ChannelDef, ClusterConfig, RoutingRuleDef, ServerConfig};
264 use crate::config::{load_from_file, validate};
265
266 fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
267 Ok(address.parse()?)
268 }
269
270 fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
271 Ok(ServerConfig {
272 listen_address: socket("127.0.0.1:8080")?,
273 health_listen_address: socket("127.0.0.1:8081")?,
274 drain_timeout_ms: 30_000,
275 channels: vec![ChannelDef {
276 name: "orders".to_owned(),
277 schema_ref: None,
278 durable: true,
279 loaded_schema: None,
280 }],
281 routing_rules: vec![RoutingRuleDef {
282 source_channel: "orders".to_owned(),
283 target_channel: "orders".to_owned(),
284 predicate: None,
285 }],
286 persistence_path: Some(PathBuf::from("/tmp")),
287 cluster: Some(ClusterConfig {
288 node_name: "node-a".to_owned(),
289 listen_address: socket("127.0.0.1:9000")?,
290 seed_nodes: vec![socket("127.0.0.1:9001")?],
291 cookie: "test-cookie".to_owned(),
292 }),
293 auth: None,
294 services: crate::config::types::ServicesConfig::default(),
295 limits: crate::config::types::LimitsConfig::default(),
296 participant: None,
297 websocket: None,
298 })
299 }
300
301 fn env_pair(name: &str, value: &str) -> (OsString, OsString) {
302 (OsString::from(name), OsString::from(value))
303 }
304
305 fn write_temp_config(contents: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
306 let path = std::env::temp_dir().join(format!(
307 "liminal-server-env-pipeline-{}.toml",
308 std::process::id()
309 ));
310 std::fs::write(&path, contents)?;
311 Ok(path)
312 }
313
314 fn remove_temp_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
315 if path.exists() {
316 std::fs::remove_file(path)?;
317 }
318 Ok(())
319 }
320
321 #[test]
322 fn listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
323 let config = sample_config()?;
324 let config = apply_env_overrides_from(
325 config,
326 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
327 )?;
328
329 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
330
331 Ok(())
332 }
333
334 #[test]
335 fn health_listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>>
336 {
337 let config = sample_config()?;
338 let config = apply_env_overrides_from(
339 config,
340 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "0.0.0.0:9191")],
341 )?;
342
343 assert_eq!(config.health_listen_address, socket("0.0.0.0:9191")?);
344
345 Ok(())
346 }
347
348 #[test]
349 fn drain_timeout_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
350 let config = sample_config()?;
351 let config =
352 apply_env_overrides_from(config, vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "1250")])?;
353
354 assert_eq!(config.drain_timeout_ms, 1250);
355
356 Ok(())
357 }
358
359 #[test]
360 fn persistence_path_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
361 let config = sample_config()?;
362 let config = apply_env_overrides_from(
363 config,
364 vec![env_pair("LIMINAL_PERSISTENCE_PATH", "/var/lib/liminal")],
365 )?;
366
367 assert_eq!(
368 config.persistence_path.as_deref(),
369 Some(Path::new("/var/lib/liminal"))
370 );
371
372 Ok(())
373 }
374
375 #[test]
376 fn cluster_overrides_replace_existing_cluster_values() -> Result<(), Box<dyn std::error::Error>>
377 {
378 let config = sample_config()?;
379 let config = apply_env_overrides_from(
380 config,
381 vec![
382 env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b"),
383 env_pair(
384 "LIMINAL_CLUSTER_SEED_NODES",
385 "127.0.0.1:9100, 127.0.0.1:9200",
386 ),
387 ],
388 )?;
389
390 let Some(cluster) = config.cluster else {
391 return Err("cluster config should remain present".into());
392 };
393 assert_eq!(cluster.node_name, "node-b");
394 assert_eq!(cluster.seed_nodes.len(), 2);
395 assert_eq!(cluster.seed_nodes[0], socket("127.0.0.1:9100")?);
396 assert_eq!(cluster.seed_nodes[1], socket("127.0.0.1:9200")?);
397
398 Ok(())
399 }
400
401 #[test]
402 fn cluster_listen_address_and_cookie_overrides_replace_values()
403 -> Result<(), Box<dyn std::error::Error>> {
404 let config = sample_config()?;
405 let config = apply_env_overrides_from(
406 config,
407 vec![
408 env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500"),
409 env_pair("LIMINAL_CLUSTER_COOKIE", "override-cookie"),
410 ],
411 )?;
412
413 let Some(cluster) = config.cluster else {
414 return Err("cluster config should remain present".into());
415 };
416 assert_eq!(cluster.listen_address, socket("127.0.0.1:9500")?);
417 assert_eq!(cluster.cookie, "override-cookie");
418
419 Ok(())
420 }
421
422 #[test]
423 fn cluster_listen_address_override_without_cluster_section_returns_validation_error()
424 -> Result<(), Box<dyn std::error::Error>> {
425 let mut config = sample_config()?;
426 config.cluster = None;
427 let result = apply_env_overrides_from(
428 config,
429 vec![env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500")],
430 );
431
432 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
433
434 Ok(())
435 }
436
437 #[test]
438 fn auth_token_override_replaces_existing_token() -> Result<(), Box<dyn std::error::Error>> {
439 let mut config = sample_config()?;
440 config.auth = Some(crate::config::types::AuthConfig {
441 pass: None,
442 token: "file-token".to_owned(),
443 });
444
445 let config =
446 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
447
448 let auth = config.auth.ok_or("auth section should remain present")?;
449 assert_eq!(auth.token, "env-token");
450
451 Ok(())
452 }
453
454 #[test]
455 fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
456 {
457 let mut config = sample_config()?;
458 config.auth = None;
459
460 let config =
461 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
462
463 let auth = config.auth.ok_or("auth section should have been created")?;
465 assert_eq!(auth.token, "env-token");
466
467 Ok(())
468 }
469
470 #[test]
471 fn absent_environment_variables_leave_config_unchanged()
472 -> Result<(), Box<dyn std::error::Error>> {
473 let config = sample_config()?;
474 let original_address = config.listen_address;
475 let original_health_address = config.health_listen_address;
476 let original_drain_timeout_ms = config.drain_timeout_ms;
477 let original_path = config.persistence_path.clone();
478 let original_cluster_name = config
479 .cluster
480 .as_ref()
481 .map(|cluster| cluster.node_name.clone());
482
483 let config = apply_env_overrides_from(config, Vec::new())?;
484
485 assert_eq!(config.listen_address, original_address);
486 assert_eq!(config.health_listen_address, original_health_address);
487 assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
488 assert_eq!(config.persistence_path, original_path);
489 assert_eq!(
490 config
491 .cluster
492 .as_ref()
493 .map(|cluster| cluster.node_name.clone()),
494 original_cluster_name
495 );
496
497 Ok(())
498 }
499
500 #[test]
501 fn invalid_listen_address_override_returns_config_load()
502 -> Result<(), Box<dyn std::error::Error>> {
503 let config = sample_config()?;
504 let result = apply_env_overrides_from(
505 config,
506 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
507 );
508
509 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
510
511 Ok(())
512 }
513
514 #[test]
515 fn invalid_health_listen_address_override_returns_config_load()
516 -> Result<(), Box<dyn std::error::Error>> {
517 let config = sample_config()?;
518 let result = apply_env_overrides_from(
519 config,
520 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
521 );
522
523 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
524
525 Ok(())
526 }
527
528 #[test]
529 fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
530 {
531 let config = sample_config()?;
532 let result = apply_env_overrides_from(
533 config,
534 vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
535 );
536
537 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
538
539 Ok(())
540 }
541
542 #[test]
543 fn cluster_override_without_cluster_section_returns_validation_error()
544 -> Result<(), Box<dyn std::error::Error>> {
545 let mut config = sample_config()?;
546 config.cluster = None;
547 let result = apply_env_overrides_from(
548 config,
549 vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
550 );
551
552 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
553
554 Ok(())
555 }
556
557 #[test]
558 fn file_then_env_then_validate_pipeline_gives_env_precedence()
559 -> Result<(), Box<dyn std::error::Error>> {
560 let toml = r#"
561listen_address = "127.0.0.1:8080"
562health_listen_address = "127.0.0.1:8081"
563drain_timeout_ms = 30000
564persistence_path = "/tmp"
565
566[[channels]]
567name = "orders"
568durable = true
569
570[[routing_rules]]
571source_channel = "orders"
572target_channel = "orders"
573"#;
574 let path = write_temp_config(toml)?;
575 let config = load_from_file(&path)?;
576 let mut config = apply_env_overrides_from(
577 config,
578 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
579 )?;
580 validate(&mut config, path.parent())?;
581 remove_temp_file(&path)?;
582
583 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
584
585 Ok(())
586 }
587
588 fn sample_config_with_websocket() -> Result<ServerConfig, Box<dyn std::error::Error>> {
591 let mut config = sample_config()?;
592 config.websocket = Some(crate::config::types::WebSocketConfig {
593 listen_address: socket("127.0.0.1:8082")?,
594 path: "/liminal".to_owned(),
595 allowed_origins: Vec::new(),
596 ping_interval_ms: None,
597 });
598 Ok(config)
599 }
600
601 #[test]
602 fn websocket_overrides_replace_declared_section_values()
603 -> Result<(), Box<dyn std::error::Error>> {
604 let config = sample_config_with_websocket()?;
605 let config = apply_env_overrides_from(
606 config,
607 vec![
608 env_pair("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
609 env_pair("LIMINAL_WEBSOCKET_PATH", "/bridge"),
610 env_pair(
611 "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
612 "https://a.example.com, https://b.example.com",
613 ),
614 env_pair("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
615 ],
616 )?;
617 let websocket = config.websocket.ok_or("websocket section missing")?;
618 assert_eq!(websocket.listen_address, socket("0.0.0.0:9292")?);
619 assert_eq!(websocket.path, "/bridge");
620 assert_eq!(
621 websocket.allowed_origins,
622 vec![
623 "https://a.example.com".to_owned(),
624 "https://b.example.com".to_owned()
625 ]
626 );
627 assert_eq!(websocket.ping_interval_ms, Some(15_000));
628 Ok(())
629 }
630
631 #[test]
632 fn websocket_override_without_declared_section_is_refused()
633 -> Result<(), Box<dyn std::error::Error>> {
634 for (name, value) in [
635 ("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
636 ("LIMINAL_WEBSOCKET_PATH", "/bridge"),
637 ("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "https://a.example.com"),
638 ("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
639 ] {
640 let config = sample_config()?;
641 let result = apply_env_overrides_from(config, vec![env_pair(name, value)]);
642 let Err(ServerError::ConfigValidation { message }) = result else {
643 return Err(format!("{name}: fabricating [websocket] must be refused").into());
644 };
645 assert!(
646 message.contains("[websocket]"),
647 "{name}: expected a section-required error, got: {message}"
648 );
649 }
650 Ok(())
651 }
652
653 #[test]
654 fn websocket_empty_origin_list_override_is_fail_closed()
655 -> Result<(), Box<dyn std::error::Error>> {
656 let config = sample_config_with_websocket()?;
657 let config = apply_env_overrides_from(
658 config,
659 vec![env_pair("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "")],
660 )?;
661 let websocket = config.websocket.ok_or("websocket section missing")?;
662 assert!(websocket.allowed_origins.is_empty());
663 Ok(())
664 }
665
666 #[test]
667 fn websocket_origin_list_with_empty_entry_is_refused() -> Result<(), Box<dyn std::error::Error>>
668 {
669 let config = sample_config_with_websocket()?;
670 let result = apply_env_overrides_from(
671 config,
672 vec![env_pair(
673 "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
674 "https://a.example.com,,https://b.example.com",
675 )],
676 );
677 let Err(ServerError::ConfigLoad { message }) = result else {
678 return Err("an empty origin entry must be a typed load error".into());
679 };
680 assert!(
681 message.contains("empty origin"),
682 "expected an empty-origin error, got: {message}"
683 );
684 Ok(())
685 }
686}