praxis_core/config/cluster/
mod.rs1mod endpoint;
7mod health_check;
8mod load_balancer_strategy;
9
10use std::sync::Arc;
11
12pub use endpoint::Endpoint;
13pub use health_check::{HealthCheckConfig, HealthCheckType};
14pub use load_balancer_strategy::{ConsistentHashOpts, LoadBalancerStrategy, ParameterisedStrategy, SimpleStrategy};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Deserialize, Serialize)]
38#[serde(deny_unknown_fields)]
39pub struct Cluster {
40 pub name: Arc<str>,
42
43 #[serde(default)]
50 pub connection_timeout_ms: Option<u64>,
51
52 pub endpoints: Vec<Endpoint>,
55
56 #[serde(default)]
58 pub health_check: Option<HealthCheckConfig>,
59
60 #[serde(default)]
65 pub idle_timeout_ms: Option<u64>,
66
67 #[serde(default)]
69 pub load_balancer_strategy: LoadBalancerStrategy,
70
71 #[serde(default)]
87 pub max_connections: Option<u32>,
88
89 #[serde(default)]
98 pub read_timeout_ms: Option<u64>,
99
100 #[serde(default)]
104 pub tls: Option<praxis_tls::ClusterTls>,
105
106 #[serde(default)]
115 pub total_connection_timeout_ms: Option<u64>,
116
117 #[serde(default)]
123 pub write_timeout_ms: Option<u64>,
124}
125
126impl Cluster {
127 pub fn with_defaults(name: &str, endpoints: Vec<Endpoint>) -> Self {
144 Self {
145 name: Arc::from(name),
146 connection_timeout_ms: None,
147 endpoints,
148 health_check: None,
149 idle_timeout_ms: None,
150 load_balancer_strategy: LoadBalancerStrategy::default(),
151 max_connections: None,
152 read_timeout_ms: None,
153 tls: None,
154 total_connection_timeout_ms: None,
155 write_timeout_ms: None,
156 }
157 }
158}
159
160#[cfg(test)]
165#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
166#[allow(
167 clippy::unwrap_used,
168 clippy::expect_used,
169 clippy::indexing_slicing,
170 clippy::needless_raw_strings,
171 clippy::needless_raw_string_hashes,
172 reason = "tests use unwrap/expect/indexing/raw strings for brevity"
173)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn parse_cluster_minimal() {
179 let yaml = r#"
180name: "backend"
181endpoints: ["10.0.0.1:8080"]
182"#;
183 let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
184 assert_eq!(&*cluster.name, "backend", "cluster name mismatch");
185 assert_eq!(
186 cluster.endpoints[0].address(),
187 "10.0.0.1:8080",
188 "endpoint address mismatch"
189 );
190 assert_eq!(cluster.endpoints[0].weight(), 1, "default weight should be 1");
191 assert_eq!(
192 cluster.load_balancer_strategy,
193 LoadBalancerStrategy::default(),
194 "strategy should default"
195 );
196 assert!(
197 cluster.connection_timeout_ms.is_none(),
198 "connection_timeout should default to None"
199 );
200 }
201
202 #[test]
203 fn parse_cluster_with_weights() {
204 let yaml = r#"
205name: "backend"
206endpoints:
207 - "10.0.0.1:8080"
208 - address: "10.0.0.2:8080"
209 weight: 3
210"#;
211 let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
212 assert_eq!(cluster.endpoints.len(), 2, "should parse two endpoints");
213 assert_eq!(cluster.endpoints[0].weight(), 1, "simple endpoint weight should be 1");
214 assert_eq!(cluster.endpoints[1].weight(), 3, "weighted endpoint weight should be 3");
215 }
216
217 #[test]
218 fn parse_cluster_with_timeouts() {
219 let yaml = r#"
220name: "backend"
221endpoints: ["10.0.0.1:8080"]
222connection_timeout_ms: 5000
223idle_timeout_ms: 30000
224read_timeout_ms: 10000
225write_timeout_ms: 10000
226"#;
227 let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
228 assert_eq!(
229 cluster.connection_timeout_ms,
230 Some(5000),
231 "connection_timeout_ms mismatch"
232 );
233 assert_eq!(cluster.idle_timeout_ms, Some(30000), "idle_timeout_ms mismatch");
234 assert_eq!(cluster.read_timeout_ms, Some(10000), "read_timeout_ms mismatch");
235 assert_eq!(cluster.write_timeout_ms, Some(10000), "write_timeout_ms mismatch");
236 }
237
238 #[test]
239 fn cluster_roundtrips_via_serde() {
240 let cluster = Cluster {
241 connection_timeout_ms: Some(1000),
242 ..Cluster::with_defaults("web", vec!["10.0.0.1:80".into()])
243 };
244 let value = serde_yaml::to_value(&cluster).unwrap();
245 let back: Cluster = serde_yaml::from_value(value).unwrap();
246 assert_eq!(back.name, cluster.name, "name should roundtrip");
247 assert_eq!(back.endpoints, cluster.endpoints, "endpoints should roundtrip");
248 assert_eq!(
249 back.connection_timeout_ms, cluster.connection_timeout_ms,
250 "timeout should roundtrip"
251 );
252 }
253
254 #[test]
255 fn tls_and_sni_parse_correctly() {
256 let yaml = r#"
257name: "backend"
258endpoints: ["10.0.0.1:443"]
259tls:
260 sni: "api.example.com"
261"#;
262 let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
263 assert!(cluster.tls.is_some(), "tls should be present");
264 assert_eq!(
265 cluster.tls.as_ref().unwrap().sni.as_deref(),
266 Some("api.example.com"),
267 "sni mismatch"
268 );
269 }
270
271 #[test]
272 fn tls_verify_defaults_to_true() {
273 let yaml = r#"
274name: "backend"
275endpoints: ["10.0.0.1:443"]
276tls: {}
277"#;
278 let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
279 assert!(cluster.tls.as_ref().unwrap().verify, "verify should default to true");
280 }
281
282 #[test]
283 fn tls_verify_can_be_disabled() {
284 let yaml = r#"
285name: "backend"
286endpoints: ["10.0.0.1:443"]
287tls:
288 verify: false
289"#;
290 let cluster: Cluster = serde_yaml::from_str(yaml).unwrap();
291 assert!(
292 !cluster.tls.as_ref().unwrap().verify,
293 "verify should be false when explicitly set"
294 );
295 }
296
297 #[test]
298 fn no_tls_by_default() {
299 let cluster = Cluster::with_defaults("web", vec!["10.0.0.1:80".into()]);
300 assert!(cluster.tls.is_none(), "tls should be None by default");
301 }
302}