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 });
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 token: "file-token".to_owned(),
442 });
443
444 let config =
445 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
446
447 let auth = config.auth.ok_or("auth section should remain present")?;
448 assert_eq!(auth.token, "env-token");
449
450 Ok(())
451 }
452
453 #[test]
454 fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
455 {
456 let mut config = sample_config()?;
457 config.auth = None;
458
459 let config =
460 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
461
462 let auth = config.auth.ok_or("auth section should have been created")?;
464 assert_eq!(auth.token, "env-token");
465
466 Ok(())
467 }
468
469 #[test]
470 fn absent_environment_variables_leave_config_unchanged()
471 -> Result<(), Box<dyn std::error::Error>> {
472 let config = sample_config()?;
473 let original_address = config.listen_address;
474 let original_health_address = config.health_listen_address;
475 let original_drain_timeout_ms = config.drain_timeout_ms;
476 let original_path = config.persistence_path.clone();
477 let original_cluster_name = config
478 .cluster
479 .as_ref()
480 .map(|cluster| cluster.node_name.clone());
481
482 let config = apply_env_overrides_from(config, Vec::new())?;
483
484 assert_eq!(config.listen_address, original_address);
485 assert_eq!(config.health_listen_address, original_health_address);
486 assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
487 assert_eq!(config.persistence_path, original_path);
488 assert_eq!(
489 config
490 .cluster
491 .as_ref()
492 .map(|cluster| cluster.node_name.clone()),
493 original_cluster_name
494 );
495
496 Ok(())
497 }
498
499 #[test]
500 fn invalid_listen_address_override_returns_config_load()
501 -> Result<(), Box<dyn std::error::Error>> {
502 let config = sample_config()?;
503 let result = apply_env_overrides_from(
504 config,
505 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
506 );
507
508 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
509
510 Ok(())
511 }
512
513 #[test]
514 fn invalid_health_listen_address_override_returns_config_load()
515 -> Result<(), Box<dyn std::error::Error>> {
516 let config = sample_config()?;
517 let result = apply_env_overrides_from(
518 config,
519 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
520 );
521
522 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
523
524 Ok(())
525 }
526
527 #[test]
528 fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
529 {
530 let config = sample_config()?;
531 let result = apply_env_overrides_from(
532 config,
533 vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
534 );
535
536 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
537
538 Ok(())
539 }
540
541 #[test]
542 fn cluster_override_without_cluster_section_returns_validation_error()
543 -> Result<(), Box<dyn std::error::Error>> {
544 let mut config = sample_config()?;
545 config.cluster = None;
546 let result = apply_env_overrides_from(
547 config,
548 vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
549 );
550
551 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
552
553 Ok(())
554 }
555
556 #[test]
557 fn file_then_env_then_validate_pipeline_gives_env_precedence()
558 -> Result<(), Box<dyn std::error::Error>> {
559 let toml = r#"
560listen_address = "127.0.0.1:8080"
561health_listen_address = "127.0.0.1:8081"
562drain_timeout_ms = 30000
563persistence_path = "/tmp"
564
565[[channels]]
566name = "orders"
567durable = true
568
569[[routing_rules]]
570source_channel = "orders"
571target_channel = "orders"
572"#;
573 let path = write_temp_config(toml)?;
574 let config = load_from_file(&path)?;
575 let mut config = apply_env_overrides_from(
576 config,
577 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
578 )?;
579 validate(&mut config, path.parent())?;
580 remove_temp_file(&path)?;
581
582 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
583
584 Ok(())
585 }
586
587 fn sample_config_with_websocket() -> Result<ServerConfig, Box<dyn std::error::Error>> {
590 let mut config = sample_config()?;
591 config.websocket = Some(crate::config::types::WebSocketConfig {
592 listen_address: socket("127.0.0.1:8082")?,
593 path: "/liminal".to_owned(),
594 allowed_origins: Vec::new(),
595 ping_interval_ms: None,
596 });
597 Ok(config)
598 }
599
600 #[test]
601 fn websocket_overrides_replace_declared_section_values()
602 -> Result<(), Box<dyn std::error::Error>> {
603 let config = sample_config_with_websocket()?;
604 let config = apply_env_overrides_from(
605 config,
606 vec![
607 env_pair("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
608 env_pair("LIMINAL_WEBSOCKET_PATH", "/bridge"),
609 env_pair(
610 "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
611 "https://a.example.com, https://b.example.com",
612 ),
613 env_pair("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
614 ],
615 )?;
616 let websocket = config.websocket.ok_or("websocket section missing")?;
617 assert_eq!(websocket.listen_address, socket("0.0.0.0:9292")?);
618 assert_eq!(websocket.path, "/bridge");
619 assert_eq!(
620 websocket.allowed_origins,
621 vec![
622 "https://a.example.com".to_owned(),
623 "https://b.example.com".to_owned()
624 ]
625 );
626 assert_eq!(websocket.ping_interval_ms, Some(15_000));
627 Ok(())
628 }
629
630 #[test]
631 fn websocket_override_without_declared_section_is_refused()
632 -> Result<(), Box<dyn std::error::Error>> {
633 for (name, value) in [
634 ("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
635 ("LIMINAL_WEBSOCKET_PATH", "/bridge"),
636 ("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "https://a.example.com"),
637 ("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
638 ] {
639 let config = sample_config()?;
640 let result = apply_env_overrides_from(config, vec![env_pair(name, value)]);
641 let Err(ServerError::ConfigValidation { message }) = result else {
642 return Err(format!("{name}: fabricating [websocket] must be refused").into());
643 };
644 assert!(
645 message.contains("[websocket]"),
646 "{name}: expected a section-required error, got: {message}"
647 );
648 }
649 Ok(())
650 }
651
652 #[test]
653 fn websocket_empty_origin_list_override_is_fail_closed()
654 -> Result<(), Box<dyn std::error::Error>> {
655 let config = sample_config_with_websocket()?;
656 let config = apply_env_overrides_from(
657 config,
658 vec![env_pair("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "")],
659 )?;
660 let websocket = config.websocket.ok_or("websocket section missing")?;
661 assert!(websocket.allowed_origins.is_empty());
662 Ok(())
663 }
664
665 #[test]
666 fn websocket_origin_list_with_empty_entry_is_refused() -> Result<(), Box<dyn std::error::Error>>
667 {
668 let config = sample_config_with_websocket()?;
669 let result = apply_env_overrides_from(
670 config,
671 vec![env_pair(
672 "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
673 "https://a.example.com,,https://b.example.com",
674 )],
675 );
676 let Err(ServerError::ConfigLoad { message }) = result else {
677 return Err("an empty origin entry must be a typed load error".into());
678 };
679 assert!(
680 message.contains("empty origin"),
681 "expected an empty-origin error, got: {message}"
682 );
683 Ok(())
684 }
685}