Skip to main content

plecto_control/upstream/
registry.rs

1//! The live registry of upstream groups (ADR 000017), owned by `Control` OUTSIDE the swapped
2//! `ActiveConfig` so health state survives a reload.
3
4use 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/// The live set of upstreams, keyed by name. Owned by `Control`, OUTSIDE the swapped
18/// `ActiveConfig`, so health state survives a reload (ADR 000017). The `Mutex` is contended only by
19/// `reconcile` (on reload) and the prober supervisor (`groups`) / a config build (`group`) — never
20/// the per-request hot path, which holds an `Arc<UpstreamGroup>` resolved at build time.
21#[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    /// Reconcile the registry to `upstreams` (ADR 000017). Validation (duplicate name, empty
32    /// addresses, the LB config — ADR 000035 — and the `[upstream.tls]` CA load, ADR 000042) runs
33    /// FIRST against the whole list, so a bad manifest leaves the running set untouched
34    /// (all-or-nothing, like the rest of a reload). Then, per upstream: build a new group whose
35    /// instances reuse the existing `Arc<UpstreamInstance>` for any unchanged `(name, address,
36    /// weight)` *when the health policy and `[upstream.tls]` are unchanged* (a TLS change makes
37    /// prior probe results meaningless, so it re-probes from pessimistic like a health-policy
38    /// change), create a fresh pessimistic instance otherwise, build the LB state (a Maglev
39    /// upstream recomputes its table from the instance set), and drop upstreams no longer present.
40    /// `base_dir` resolves each `[upstream.tls] ca_path`, like the `[[tls]]` cert paths.
41    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        // Build (or reuse) each upstream's TLS client config (ADR 000042) BEFORE any mutation —
58        // a bad CA file aborts the whole reconcile fail-closed. Reusing the prior group's `Arc`
59        // when `[upstream.tls]` is unchanged keeps its identity stable across reloads, so the
60        // fast path's per-config connection pool (keyed on that identity) survives. The `sni`
61        // override (ADR 000050) is cheap to parse (no I/O), so it is rebuilt fresh every
62        // reconcile rather than reuse-cached like the CA-derived `ClientConfig`.
63        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            // reuse the prior group's instances only if the health policy AND the TLS config are
100            // identical; a change re-probes the upstream from pessimistic (new thresholds apply /
101            // old probe results were for a different security context).
102            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            // A resolving group whose declared addresses / LB config are unchanged carries its
113            // CURRENT endpoint set (the resolved IPs + their health) across the reload wholesale —
114            // otherwise every reload would discard the resolved set and re-enter the pessimistic
115            // window until the next refresh + probe pass.
116            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                            // reuse only when address AND weight are unchanged; a weight edit (LB
132                            // capacity) builds a fresh instance, like a health-policy change.
133                            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                    // Build the LB state from the manifest (ADR 000035). Maglev recomputes its
151                    // lookup table from the instance set + weights; validation above guaranteed a
152                    // hash block and a valid (prime, in-range) table size.
153                    Arc::new(super::Endpoints::build(
154                        instances,
155                        up.lb_algorithm,
156                        maglev_table_size,
157                    ))
158                }
159            };
160            // carry the round-robin cursor across the reload (independent of which instances or the
161            // health policy changed — it is only a rotation counter) so the first post-reload pick
162            // continues the rotation instead of restarting at the eligible set's head (ADR 000024).
163            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    /// The group named `name`, if present — used to resolve a route's upstream at config-build time.
204    pub fn group(&self, name: &str) -> Option<Arc<UpstreamGroup>> {
205        let Ok(groups) = self.groups.lock() else {
206            // A poisoned lock (a panic while it was held) must not be a SILENT `None` — the
207            // caller would misreport it as an unknown upstream (DECREE §3: no swallowed errors).
208            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    /// A snapshot of every current group, for the health-check supervisor to probe.
218    pub fn groups(&self) -> Vec<Arc<UpstreamGroup>> {
219        match self.groups.lock() {
220            Ok(g) => g.values().cloned().collect(),
221            Err(_) => {
222                // An empty snapshot here silently STOPS all health probing — new instances would
223                // never promote. Loud, so the operator sees why health froze.
224                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(); // a:1 becomes healthy
281        assert!(g0.endpoints().instances[0].is_healthy());
282
283        // reload: drop b:2, keep a:1, add c:3 — same health policy
284        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        // same address, different health policy → fresh pessimistic instance, new thresholds apply
314        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    /// A CA PEM written to a temp dir (rcgen self-signed acts as the root), for `[upstream.tls]`.
326    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        // ADR 000042: while `[upstream.tls]` is unchanged, the ClientConfig Arc must be REUSED —
336        // the fast path keys its per-config connection pool on the Arc's identity, so a stable
337        // Arc means a reload never cold-starts upstream connections.
338        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        // A TLS on/off (or CA) change makes prior probe results meaningless — the instance must
376        // start pessimistic again, exactly like a health-policy change (ADR 000042 / 000017).
377        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        // Fail-closed all-or-nothing (ADR 000042): an unreadable CA aborts the reconcile BEFORE
400        // the registry mutates, so the running (healthy) set stays live on a bad reload.
401        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        // ADR 000050: a declared `sni` is parsed and reachable via `tls_sni()`, independent of
428        // whether the upstream address is a hostname or an IP literal.
429        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        // Fail-closed all-or-nothing (ADR 000050), like the CA-path check: a `sni` that parses as
452        // neither a DNS name nor an IP aborts the reconcile BEFORE the registry mutates, rather
453        // than letting every handshake to this upstream fail at request time.
454        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}