1use std::collections::{HashMap, HashSet};
5use std::path::Path;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use crate::error::ControlError;
11use crate::manifest::{HashKeyKind, Upstream};
12
13use super::UpstreamGroup;
14use super::instance::UpstreamInstance;
15use super::lb::HashKeySource;
16
17#[derive(Debug, Default)]
22pub struct UpstreamRegistry {
23 groups: Mutex<HashMap<String, Arc<UpstreamGroup>>>,
24}
25
26impl UpstreamRegistry {
27 pub fn new() -> Self {
28 Self::default()
29 }
30
31 pub fn reconcile(&self, upstreams: &[Upstream], base_dir: &Path) -> Result<(), ControlError> {
42 let mut seen = HashSet::new();
43 for up in upstreams {
44 if up.addresses.is_empty() {
45 return Err(ControlError::EmptyUpstreamAddresses(up.name.clone()));
46 }
47 if !seen.insert(up.name.as_str()) {
48 return Err(ControlError::DuplicateUpstream(up.name.clone()));
49 }
50 up.validate_lb()
51 .map_err(|reason| ControlError::InvalidUpstreamLb {
52 name: up.name.clone(),
53 reason,
54 })?;
55 up.warn_missing_sni();
56 }
57 let mut tls_clients: Vec<Option<Arc<rustls::ClientConfig>>> =
64 Vec::with_capacity(upstreams.len());
65 let mut tls_snis: Vec<Option<rustls::pki_types::ServerName<'static>>> =
66 Vec::with_capacity(upstreams.len());
67 for up in upstreams {
68 let client = match &up.tls {
69 None => None,
70 Some(tls) => {
71 let prev = self.group(&up.name);
72 let reusable = prev
73 .as_ref()
74 .filter(|g| g.tls_manifest.as_ref() == Some(tls))
75 .and_then(|g| g.tls_client.clone());
76 match reusable {
77 Some(cfg) => Some(cfg),
78 None => Some(crate::tls::build_upstream_client_config(
79 &up.name, tls, base_dir,
80 )?),
81 }
82 }
83 };
84 tls_clients.push(client);
85 let sni = match up.tls.as_ref().and_then(|tls| tls.sni.as_deref()) {
86 None => None,
87 Some(sni) => Some(crate::tls::parse_upstream_sni(&up.name, sni)?),
88 };
89 tls_snis.push(sni);
90 }
91
92 let mut groups = self
93 .groups
94 .lock()
95 .map_err(|_| ControlError::UpstreamRegistryPoisoned)?;
96 let mut next: HashMap<String, Arc<UpstreamGroup>> = HashMap::with_capacity(upstreams.len());
97 for ((up, tls_client), tls_sni) in upstreams.iter().zip(tls_clients).zip(tls_snis) {
98 let prev_any = groups.get(&up.name);
99 let prev = prev_any.filter(|g| g.health == up.health && g.tls_manifest == up.tls);
103 let configured: Vec<(String, u32)> = up
104 .addresses
105 .iter()
106 .map(|spec| (spec.address().to_string(), spec.weight()))
107 .collect();
108 let resolve_interval = Duration::from_millis(up.resolve_interval_ms);
109 let maglev_table_size =
110 up.hash.as_ref().map(|h| h.table_size).unwrap_or(65537) as usize;
111 let prev_endpoints = prev.map(|g| g.endpoints.load_full());
112 let carry_resolved = prev
117 .filter(|g| {
118 !resolve_interval.is_zero()
119 && !g.resolve_interval.is_zero()
120 && g.configured == configured
121 && g.lb_algorithm == up.lb_algorithm
122 && g.maglev_table_size == maglev_table_size
123 })
124 .and_then(|_| prev_endpoints.clone());
125 let endpoints = match carry_resolved {
126 Some(current) => current,
127 None => {
128 let instances: Vec<Arc<UpstreamInstance>> = configured
129 .iter()
130 .map(|(addr, weight)| {
131 prev_endpoints
134 .as_ref()
135 .and_then(|ep| {
136 ep.instances
137 .iter()
138 .find(|i| i.address() == addr && i.weight() == *weight)
139 .cloned()
140 })
141 .unwrap_or_else(|| {
142 Arc::new(UpstreamInstance::new(
143 addr.clone(),
144 *weight,
145 &up.health,
146 ))
147 })
148 })
149 .collect();
150 Arc::new(super::Endpoints::build(
154 instances,
155 up.lb_algorithm,
156 maglev_table_size,
157 ))
158 }
159 };
160 let rr = prev_any.map(|g| g.rr.load(Ordering::Relaxed)).unwrap_or(0);
164 let hash_key = up.hash.as_ref().map(|h| match h.key {
165 HashKeyKind::Header => {
166 HashKeySource::Header(h.header.clone().unwrap_or_default().to_ascii_lowercase())
167 }
168 HashKeyKind::SourceIp => HashKeySource::SourceIp,
169 });
170 next.insert(
171 up.name.clone(),
172 Arc::new(UpstreamGroup {
173 name: up.name.clone(),
174 health: up.health.clone(),
175 endpoints: arc_swap::ArcSwap::new(endpoints),
176 configured,
177 resolve_interval,
178 lb_algorithm: up.lb_algorithm,
179 maglev_table_size,
180 request_timeout: Duration::from_millis(up.request_timeout_ms),
181 overall_timeout: Duration::from_millis(up.overall_timeout_ms),
182 max_retries: up.max_retries,
183 rr: AtomicUsize::new(rr),
184 max_requests: up.circuit_breaker.max_requests as usize,
185 in_flight: AtomicUsize::new(0),
186 outlier_consecutive: up.outlier_detection.consecutive_gateway_failures,
187 outlier_base_ejection: Duration::from_millis(
188 up.outlier_detection.base_ejection_time_ms,
189 ),
190 outlier_max_ejection_percent: up.outlier_detection.max_ejection_percent,
191 outlier_decision: std::sync::Mutex::new(()),
192 hash_key,
193 tls_manifest: up.tls.clone(),
194 tls_client,
195 tls_sni,
196 }),
197 );
198 }
199 *groups = next;
200 Ok(())
201 }
202
203 pub fn group(&self, name: &str) -> Option<Arc<UpstreamGroup>> {
205 let Ok(groups) = self.groups.lock() else {
206 tracing::error!(
209 upstream = name,
210 "upstream registry lock poisoned in group()"
211 );
212 return None;
213 };
214 groups.get(name).cloned()
215 }
216
217 pub fn groups(&self) -> Vec<Arc<UpstreamGroup>> {
219 match self.groups.lock() {
220 Ok(g) => g.values().cloned().collect(),
221 Err(_) => {
222 tracing::error!(
225 "upstream registry lock poisoned in groups(); health probing sees no upstreams"
226 );
227 Vec::new()
228 }
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::manifest::{
237 AddressSpec, CircuitBreaker, HealthConfig, LbAlgorithm, OutlierDetection,
238 };
239
240 fn health(healthy_threshold: u32, unhealthy_threshold: u32) -> HealthConfig {
241 HealthConfig {
242 path: "/healthz".to_string(),
243 interval_ms: 100,
244 timeout_ms: 50,
245 healthy_threshold,
246 unhealthy_threshold,
247 port: None,
248 }
249 }
250
251 fn upstream(name: &str, addrs: &[&str], h: HealthConfig) -> Upstream {
252 Upstream {
253 name: name.to_string(),
254 addresses: addrs
255 .iter()
256 .map(|s| AddressSpec::Bare(s.to_string()))
257 .collect(),
258 lb_algorithm: LbAlgorithm::RoundRobin,
259 hash: None,
260 tls: None,
261 resolve_interval_ms: 0,
262 health: h,
263 request_timeout_ms: 30_000,
264 max_retries: 1,
265 overall_timeout_ms: 0,
266 circuit_breaker: CircuitBreaker::default(),
267 outlier_detection: OutlierDetection::default(),
268 }
269 }
270
271 #[test]
272 fn reconcile_preserves_unchanged_adds_new_drops_removed() {
273 let reg = UpstreamRegistry::new();
274 reg.reconcile(
275 &[upstream("u", &["a:1", "b:2"], health(1, 3))],
276 std::path::Path::new("."),
277 )
278 .unwrap();
279 let g0 = reg.group("u").unwrap();
280 g0.endpoints().instances[0].record_probe_success(); assert!(g0.endpoints().instances[0].is_healthy());
282
283 reg.reconcile(
285 &[upstream("u", &["a:1", "c:3"], health(1, 3))],
286 std::path::Path::new("."),
287 )
288 .unwrap();
289 let g1 = reg.group("u").unwrap();
290 assert_eq!(g1.endpoints().instances.len(), 2);
291 assert!(
292 g1.endpoints().instances[0].is_healthy(),
293 "the unchanged a:1 keeps its health across reload"
294 );
295 assert_eq!(g1.endpoints().instances[1].address(), "c:3");
296 assert!(
297 !g1.endpoints().instances[1].is_healthy(),
298 "the new c:3 starts pessimistic"
299 );
300 }
301
302 #[test]
303 fn reconcile_changing_health_policy_reprobes_from_pessimistic() {
304 let reg = UpstreamRegistry::new();
305 reg.reconcile(
306 &[upstream("u", &["a:1"], health(1, 3))],
307 std::path::Path::new("."),
308 )
309 .unwrap();
310 reg.group("u").unwrap().endpoints().instances[0].record_probe_success();
311 assert!(reg.group("u").unwrap().endpoints().instances[0].is_healthy());
312
313 reg.reconcile(
315 &[upstream("u", &["a:1"], health(2, 5))],
316 std::path::Path::new("."),
317 )
318 .unwrap();
319 assert!(
320 !reg.group("u").unwrap().endpoints().instances[0].is_healthy(),
321 "a health-policy change re-probes the instance from pessimistic"
322 );
323 }
324
325 fn write_ca_pem(dir: &std::path::Path) -> String {
327 let generated = rcgen::generate_simple_self_signed(vec!["ca.example".to_string()]).unwrap();
328 let path = dir.join("ca.pem");
329 std::fs::write(&path, generated.cert.pem()).unwrap();
330 "ca.pem".to_string()
331 }
332
333 #[test]
334 fn reconcile_keeps_the_tls_client_arc_across_an_unchanged_reload() {
335 let dir = tempfile::tempdir().unwrap();
339 let ca_path = write_ca_pem(dir.path());
340 let mut up = upstream("u", &["a:1"], health(1, 3));
341 up.tls = Some(crate::manifest::UpstreamTls {
342 ca_path: Some(ca_path),
343 ..Default::default()
344 });
345
346 let reg = UpstreamRegistry::new();
347 reg.reconcile(std::slice::from_ref(&up), dir.path())
348 .unwrap();
349 let g0 = reg.group("u").unwrap();
350 assert_eq!(
351 g0.scheme(),
352 "https",
353 "an [upstream.tls] group forwards https"
354 );
355 let cfg0 = g0
356 .tls_client_config()
357 .cloned()
358 .expect("a TLS client config");
359
360 reg.reconcile(std::slice::from_ref(&up), dir.path())
361 .unwrap();
362 let g1 = reg.group("u").unwrap();
363 let cfg1 = g1
364 .tls_client_config()
365 .cloned()
366 .expect("a TLS client config");
367 assert!(
368 Arc::ptr_eq(&cfg0, &cfg1),
369 "an unchanged [upstream.tls] must reuse the same ClientConfig Arc across reloads"
370 );
371 }
372
373 #[test]
374 fn reconcile_tls_change_reprobes_from_pessimistic() {
375 let dir = tempfile::tempdir().unwrap();
378 let ca_path = write_ca_pem(dir.path());
379 let reg = UpstreamRegistry::new();
380 reg.reconcile(&[upstream("u", &["a:1"], health(1, 3))], dir.path())
381 .unwrap();
382 reg.group("u").unwrap().endpoints().instances[0].record_probe_success();
383 assert!(reg.group("u").unwrap().endpoints().instances[0].is_healthy());
384
385 let mut up = upstream("u", &["a:1"], health(1, 3));
386 up.tls = Some(crate::manifest::UpstreamTls {
387 ca_path: Some(ca_path),
388 ..Default::default()
389 });
390 reg.reconcile(&[up], dir.path()).unwrap();
391 assert!(
392 !reg.group("u").unwrap().endpoints().instances[0].is_healthy(),
393 "enabling [upstream.tls] must re-probe the instance from pessimistic"
394 );
395 }
396
397 #[test]
398 fn reconcile_rejects_a_bad_ca_path_before_any_mutation() {
399 let dir = tempfile::tempdir().unwrap();
402 let reg = UpstreamRegistry::new();
403 reg.reconcile(&[upstream("u", &["a:1"], health(1, 3))], dir.path())
404 .unwrap();
405 reg.group("u").unwrap().endpoints().instances[0].record_probe_success();
406
407 let mut up = upstream("u", &["a:1"], health(1, 3));
408 up.tls = Some(crate::manifest::UpstreamTls {
409 ca_path: Some("missing-ca.pem".to_string()),
410 ..Default::default()
411 });
412 let err = reg.reconcile(&[up], dir.path());
413 assert!(
414 matches!(err, Err(ControlError::UpstreamTlsCa { .. })),
415 "a missing CA file must be a typed fail-closed error"
416 );
417 let g = reg.group("u").unwrap();
418 assert_eq!(g.scheme(), "http", "the running group is untouched");
419 assert!(
420 g.endpoints().instances[0].is_healthy(),
421 "the running instance keeps its health after the rejected reconcile"
422 );
423 }
424
425 #[test]
426 fn reconcile_exposes_the_parsed_tls_sni_override() {
427 let dir = tempfile::tempdir().unwrap();
430 let ca_path = write_ca_pem(dir.path());
431 let mut up = upstream("u", &["10.0.0.9:1"], health(1, 3));
432 up.tls = Some(crate::manifest::UpstreamTls {
433 ca_path: Some(ca_path),
434 sni: Some("backend.internal".to_string()),
435 ..Default::default()
436 });
437
438 let reg = UpstreamRegistry::new();
439 reg.reconcile(std::slice::from_ref(&up), dir.path())
440 .unwrap();
441 let g = reg.group("u").unwrap();
442 assert_eq!(
443 g.tls_sni().map(|n| n.to_str().to_string()),
444 Some("backend.internal".to_string()),
445 "the declared sni must be reachable for the connector to override with"
446 );
447 }
448
449 #[test]
450 fn reconcile_rejects_an_unparsable_tls_sni_before_any_mutation() {
451 let reg = UpstreamRegistry::new();
455 reg.reconcile(
456 &[upstream("u", &["a:1"], health(1, 3))],
457 std::path::Path::new("."),
458 )
459 .unwrap();
460 reg.group("u").unwrap().endpoints().instances[0].record_probe_success();
461
462 let mut up = upstream("u", &["a:1"], health(1, 3));
463 up.tls = Some(crate::manifest::UpstreamTls {
464 ca_path: None,
465 sni: Some("not a valid sni!!".to_string()),
466 ..Default::default()
467 });
468 let err = reg.reconcile(&[up], std::path::Path::new("."));
469 assert!(
470 matches!(err, Err(ControlError::UpstreamTlsSni { .. })),
471 "an unparsable sni must be a typed fail-closed error, got {err:?}"
472 );
473 let g = reg.group("u").unwrap();
474 assert_eq!(g.scheme(), "http", "the running group is untouched");
475 assert!(
476 g.endpoints().instances[0].is_healthy(),
477 "the running instance keeps its health after the rejected reconcile"
478 );
479 }
480
481 #[test]
482 fn reconcile_rejects_empty_addresses_and_duplicate_names() {
483 let reg = UpstreamRegistry::new();
484 let empty = reg.reconcile(
485 &[upstream("u", &[], health(1, 1))],
486 std::path::Path::new("."),
487 );
488 assert!(matches!(
489 empty,
490 Err(ControlError::EmptyUpstreamAddresses(_))
491 ));
492
493 let dup = reg.reconcile(
494 &[
495 upstream("u", &["a:1"], health(1, 1)),
496 upstream("u", &["b:2"], health(1, 1)),
497 ],
498 std::path::Path::new("."),
499 );
500 assert!(matches!(dup, Err(ControlError::DuplicateUpstream(_))));
501 }
502}