Skip to main content

praxis_core/config/
runtime.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2// Copyright (c) 2024 Shane Utt
3
4//! Runtime tuning: worker thread count, work-stealing toggle, logging overrides, and upstream CA.
5
6use std::collections::HashMap;
7
8use serde::Deserialize;
9
10// -----------------------------------------------------------------------------
11// RuntimeConfig
12// -----------------------------------------------------------------------------
13
14/// Configuration for the runtime of the proxy server.
15///
16/// ```
17/// use praxis_core::config::RuntimeConfig;
18///
19/// let cfg = RuntimeConfig::default();
20/// assert_eq!(cfg.threads, 0);
21/// assert!(cfg.work_stealing);
22/// assert_eq!(cfg.global_queue_interval, Some(61));
23/// assert!(cfg.log_overrides.is_empty());
24/// assert_eq!(cfg.upstream_keepalive_pool_size, Some(64));
25/// assert!(cfg.upstream_ca_file.is_none());
26///
27/// let cfg: RuntimeConfig = serde_yaml::from_str("threads: 4\nwork_stealing: true").unwrap();
28/// assert_eq!(cfg.threads, 4);
29/// assert!(cfg.work_stealing);
30/// ```
31#[derive(Debug, Clone, Deserialize)]
32pub struct RuntimeConfig {
33    /// Number of worker threads per service.
34    ///
35    /// Auto-detected by default.
36    #[serde(default)]
37    pub threads: usize,
38
39    /// Allow work-stealing between worker threads of the same service.
40    #[serde(default = "default_work_stealing")]
41    pub work_stealing: bool,
42
43    /// Per-module log level overrides.
44    ///
45    /// ```
46    /// use praxis_core::config::RuntimeConfig;
47    ///
48    /// let yaml = r#"
49    /// log_overrides:
50    ///   praxis_filter::pipeline: trace
51    ///   praxis_protocol: debug
52    /// "#;
53    /// let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
54    /// assert_eq!(cfg.log_overrides.len(), 2);
55    /// assert_eq!(cfg.log_overrides["praxis_filter::pipeline"], "trace");
56    /// ```
57    #[serde(default)]
58    pub log_overrides: HashMap<String, String>,
59
60    /// Fixed global queue interval for the tokio scheduler.
61    ///
62    /// ```
63    /// use praxis_core::config::RuntimeConfig;
64    ///
65    /// let cfg = RuntimeConfig::default();
66    /// assert_eq!(cfg.global_queue_interval, Some(61));
67    ///
68    /// let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: 128").unwrap();
69    /// assert_eq!(cfg.global_queue_interval, Some(128));
70    /// ```
71    #[serde(default = "default_global_queue_interval")]
72    pub global_queue_interval: Option<u32>,
73
74    /// Path to a PEM CA file used as the root certificate store for all upstream TLS connections.
75    ///
76    /// When set, this **replaces** the system trust store (not additive). If backends
77    /// use both a private CA and public CAs, create a combined PEM bundle containing
78    /// all required root certificates.
79    ///
80    /// ```
81    /// use praxis_core::config::RuntimeConfig;
82    ///
83    /// let cfg: RuntimeConfig =
84    ///     serde_yaml::from_str("upstream_ca_file: /etc/praxis/ca-bundle.pem").unwrap();
85    /// assert_eq!(
86    ///     cfg.upstream_ca_file.as_deref(),
87    ///     Some("/etc/praxis/ca-bundle.pem")
88    /// );
89    ///
90    /// let cfg = RuntimeConfig::default();
91    /// assert!(cfg.upstream_ca_file.is_none());
92    /// ```
93    #[serde(default)]
94    pub upstream_ca_file: Option<String>,
95
96    /// Maximum number of idle upstream connections kept per thread.
97    ///
98    /// ```
99    /// use praxis_core::config::RuntimeConfig;
100    ///
101    /// let cfg = RuntimeConfig::default();
102    /// assert_eq!(cfg.upstream_keepalive_pool_size, Some(64));
103    ///
104    /// let cfg: RuntimeConfig = serde_yaml::from_str("upstream_keepalive_pool_size: 32").unwrap();
105    /// assert_eq!(cfg.upstream_keepalive_pool_size, Some(32));
106    /// ```
107    #[serde(default = "default_upstream_keepalive_pool_size")]
108    pub upstream_keepalive_pool_size: Option<usize>,
109}
110
111impl Default for RuntimeConfig {
112    fn default() -> Self {
113        Self {
114            threads: 0,
115            work_stealing: default_work_stealing(),
116            global_queue_interval: default_global_queue_interval(),
117            log_overrides: HashMap::new(),
118            upstream_ca_file: None,
119            upstream_keepalive_pool_size: default_upstream_keepalive_pool_size(),
120        }
121    }
122}
123
124/// Serde default for [`RuntimeConfig::work_stealing`].
125fn default_work_stealing() -> bool {
126    true
127}
128
129/// Serde default for [`RuntimeConfig::upstream_keepalive_pool_size`].
130#[allow(clippy::unnecessary_wraps, reason = "serde default")]
131fn default_upstream_keepalive_pool_size() -> Option<usize> {
132    Some(64)
133}
134
135/// Serde default for [`RuntimeConfig::global_queue_interval`].
136#[allow(clippy::unnecessary_wraps, reason = "serde default")]
137fn default_global_queue_interval() -> Option<u32> {
138    Some(61)
139}
140
141// -----------------------------------------------------------------------------
142// Tests
143// -----------------------------------------------------------------------------
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn default_has_zero_threads_and_work_stealing_true() {
151        let cfg = RuntimeConfig::default();
152        assert_eq!(cfg.threads, 0, "default threads should be 0");
153        assert!(cfg.work_stealing, "default work_stealing should be true");
154    }
155
156    #[test]
157    fn deserialise_empty_yaml_gives_defaults() {
158        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
159        assert_eq!(cfg.threads, 0, "empty yaml should give 0 threads");
160        assert!(cfg.work_stealing, "empty yaml should give work_stealing=true");
161    }
162
163    #[test]
164    fn deserialise_explicit_threads() {
165        let cfg: RuntimeConfig = serde_yaml::from_str("threads: 4").unwrap();
166        assert_eq!(cfg.threads, 4, "explicit threads should be preserved");
167        assert!(cfg.work_stealing, "unset work_stealing should default to true");
168    }
169
170    #[test]
171    fn deserialise_work_stealing_disabled() {
172        let cfg: RuntimeConfig = serde_yaml::from_str("work_stealing: false").unwrap();
173        assert_eq!(cfg.threads, 0, "unset threads should default to 0");
174        assert!(!cfg.work_stealing, "explicit work_stealing=false should be preserved");
175    }
176
177    #[test]
178    fn deserialise_all_fields() {
179        let yaml = "threads: 8\nwork_stealing: true";
180        let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
181        assert_eq!(cfg.threads, 8, "threads should be 8");
182        assert!(cfg.work_stealing, "work_stealing should be true");
183    }
184
185    #[test]
186    fn deserialise_log_overrides() {
187        let yaml = r#"
188log_overrides:
189  praxis_filter::pipeline: trace
190  praxis_protocol: debug
191"#;
192        let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
193        assert_eq!(cfg.log_overrides.len(), 2, "should have 2 log overrides");
194        assert_eq!(
195            cfg.log_overrides["praxis_filter::pipeline"], "trace",
196            "pipeline override mismatch"
197        );
198        assert_eq!(
199            cfg.log_overrides["praxis_protocol"], "debug",
200            "protocol override mismatch"
201        );
202    }
203
204    #[test]
205    fn default_log_overrides_is_empty() {
206        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
207        assert!(cfg.log_overrides.is_empty(), "log_overrides should default to empty");
208    }
209
210    #[test]
211    fn global_queue_interval_defaults_to_61() {
212        let cfg = RuntimeConfig::default();
213        assert_eq!(cfg.global_queue_interval, Some(61), "default interval should be 61");
214    }
215
216    #[test]
217    fn deserialise_global_queue_interval() {
218        let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: 128").unwrap();
219        assert_eq!(cfg.global_queue_interval, Some(128), "explicit interval should be 128");
220    }
221
222    #[test]
223    fn deserialise_global_queue_interval_null() {
224        let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: null").unwrap();
225        assert!(cfg.global_queue_interval.is_none(), "null interval should be None");
226    }
227
228    #[test]
229    fn upstream_keepalive_pool_size_defaults_to_64() {
230        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
231        assert_eq!(
232            cfg.upstream_keepalive_pool_size,
233            Some(64),
234            "default pool size should be 64"
235        );
236    }
237
238    #[test]
239    fn deserialise_upstream_keepalive_pool_size() {
240        let cfg: RuntimeConfig = serde_yaml::from_str("upstream_keepalive_pool_size: 64").unwrap();
241        assert_eq!(
242            cfg.upstream_keepalive_pool_size,
243            Some(64),
244            "explicit pool size should be 64"
245        );
246    }
247
248    #[test]
249    fn upstream_ca_file_defaults_to_none() {
250        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
251        assert!(
252            cfg.upstream_ca_file.is_none(),
253            "upstream_ca_file should default to None"
254        );
255    }
256
257    #[test]
258    fn deserialise_upstream_ca_file() {
259        let cfg: RuntimeConfig = serde_yaml::from_str("upstream_ca_file: /etc/ssl/ca.pem").unwrap();
260        assert_eq!(
261            cfg.upstream_ca_file.as_deref(),
262            Some("/etc/ssl/ca.pem"),
263            "explicit upstream_ca_file should be preserved"
264        );
265    }
266}