Skip to main content

kipuka/ha/
pool.rs

1//! CA connection pool with circuit-breaker and priority weighting.
2//!
3//! Manages connections to multiple CA backends with per-CA health tracking.
4//! Implements RHELBU-3536 R1 (multi-CA) and R2 (circuit breaker).
5
6use std::collections::{HashMap, VecDeque};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use parking_lot::{Mutex, RwLock};
11use tracing::{debug, info, warn};
12
13use super::health::HealthState;
14use super::strategy::{FailoverStrategy, FallbackBehavior, StrategySelector};
15
16/// Opaque identifier for a CA backend.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18pub struct CaId(pub String);
19
20impl std::fmt::Display for CaId {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.write_str(&self.0)
23    }
24}
25
26impl std::borrow::Borrow<str> for CaId {
27    fn borrow(&self) -> &str {
28        &self.0
29    }
30}
31
32impl std::borrow::Borrow<String> for CaId {
33    fn borrow(&self) -> &String {
34        &self.0
35    }
36}
37
38impl From<String> for CaId {
39    fn from(s: String) -> Self {
40        Self(s)
41    }
42}
43
44impl From<&str> for CaId {
45    fn from(s: &str) -> Self {
46        Self(s.to_owned())
47    }
48}
49
50/// Runtime status of a single CA backend.
51#[derive(Debug, Clone)]
52pub struct CaStatus {
53    /// Current health state from the health checker.
54    pub health: HealthState,
55    /// Number of consecutive probe failures.
56    pub consecutive_failures: u32,
57    /// Timestamp of the last successful probe.
58    pub last_success: Option<Instant>,
59    /// Timestamp when the circuit breaker tripped (CA marked unavailable).
60    pub circuit_open_since: Option<Instant>,
61    /// Recent response latency (exponential moving average in milliseconds).
62    pub latency_ema_ms: f64,
63    /// Last observed response latency (for admin display).
64    pub last_latency: Option<Duration>,
65}
66
67impl CaStatus {
68    fn new() -> Self {
69        Self {
70            health: HealthState::Healthy,
71            consecutive_failures: 0,
72            last_success: None,
73            circuit_open_since: None,
74            latency_ema_ms: 0.0,
75            last_latency: None,
76        }
77    }
78}
79
80/// Registered CA backend with its configuration and connection info.
81#[derive(Debug, Clone)]
82pub struct CaConnection {
83    /// Unique identifier.
84    pub id: CaId,
85    /// Base URL or endpoint for this CA.
86    pub endpoint: String,
87    /// Static priority weight (higher = preferred).
88    pub weight: u32,
89    /// Priority order for active-passive (lower = higher priority).
90    pub priority: u32,
91}
92
93/// Circuit-breaker configuration (RHELBU-3536 R2).
94#[derive(Debug, Clone)]
95pub struct CircuitBreakerConfig {
96    /// Number of consecutive failures before opening the circuit.
97    pub failure_threshold: u32,
98    /// Duration to wait before re-probing an unavailable CA.
99    pub cooldown: Duration,
100}
101
102impl Default for CircuitBreakerConfig {
103    fn default() -> Self {
104        Self {
105            failure_threshold: 3,
106            cooldown: Duration::from_secs(60),
107        }
108    }
109}
110
111/// Pool configuration.
112#[derive(Debug, Clone)]
113pub struct PoolConfig {
114    /// Failover strategy for CA selection.
115    pub strategy: FailoverStrategy,
116    /// Behavior when all CAs are unavailable.
117    pub fallback: FallbackBehavior,
118    /// Circuit-breaker settings.
119    pub circuit_breaker: CircuitBreakerConfig,
120}
121
122/// Configuration for the queue-and-retry fallback (RHELBU-3536 R6).
123#[derive(Debug, Clone)]
124pub struct QueueConfig {
125    /// Maximum number of requests that can be queued.
126    /// Requests beyond this limit are rejected with HTTP 503.
127    /// Default: 100.
128    pub max_queue_size: usize,
129    /// Time-to-live for queued requests in seconds.
130    /// Requests older than this are drained and discarded.
131    /// Default: 300 (5 minutes).
132    pub queue_ttl_secs: u64,
133    /// Maximum size in bytes for a single CSR to be enqueued.
134    /// Requests exceeding this limit are rejected to prevent memory exhaustion.
135    /// Default: 65536 (64 KiB).
136    pub max_csr_size: usize,
137}
138
139impl Default for QueueConfig {
140    fn default() -> Self {
141        Self {
142            max_queue_size: 100,
143            queue_ttl_secs: 300,
144            max_csr_size: 65_536,
145        }
146    }
147}
148
149/// A CSR enrollment request queued for retry when all CAs are down.
150#[derive(Debug, Clone)]
151pub struct QueuedRequest {
152    /// DER-encoded CSR to submit when a CA becomes available.
153    pub csr_der: Vec<u8>,
154    /// Timestamp when the request was enqueued.
155    pub queued_at: Instant,
156}
157
158/// Result of attempting to enqueue a request when all CAs are down.
159#[derive(Debug)]
160pub enum EnqueueResult {
161    /// The request was successfully queued; the caller should return
162    /// HTTP 503 with a `Retry-After` header.
163    Queued {
164        /// Suggested retry delay in seconds.
165        retry_after_secs: u32,
166        /// Current queue depth after insertion.
167        queue_depth: usize,
168    },
169    /// The queue is full; the request was rejected.
170    QueueFull {
171        /// Current queue capacity.
172        max_size: usize,
173    },
174}
175
176/// Thread-safe pool of CA backend connections.
177///
178/// Routes enrollment requests to healthy CAs based on the configured
179/// [`FailoverStrategy`]. The pool is updated by the [`super::health::HealthChecker`]
180/// and read by request handlers concurrently.
181pub struct CaPool {
182    /// Registered CA backends (insertion-ordered by priority).
183    connections: Vec<CaConnection>,
184    /// Per-CA runtime status, protected by `RwLock` for concurrent reads.
185    statuses: RwLock<HashMap<CaId, CaStatus>>,
186    /// Pool configuration.
187    config: PoolConfig,
188    /// Strategy selector for CA routing.
189    selector: StrategySelector,
190    /// Queue for requests waiting to be retried when all CAs are down.
191    /// Only used when `config.fallback` is `QueueAndRetry`.
192    retry_queue: Arc<Mutex<VecDeque<QueuedRequest>>>,
193    /// Queue-and-retry configuration.
194    queue_config: QueueConfig,
195}
196
197impl CaPool {
198    /// Create a new pool with the given backends and configuration.
199    pub fn new(connections: Vec<CaConnection>, config: PoolConfig) -> Self {
200        Self::with_queue_config(connections, config, QueueConfig::default())
201    }
202
203    /// Create a new pool with explicit queue-and-retry configuration.
204    pub fn with_queue_config(
205        connections: Vec<CaConnection>,
206        config: PoolConfig,
207        queue_config: QueueConfig,
208    ) -> Self {
209        let mut statuses = HashMap::new();
210        for conn in &connections {
211            statuses.insert(conn.id.clone(), CaStatus::new());
212        }
213        let selector = StrategySelector::new(config.strategy.clone());
214
215        Self {
216            connections,
217            statuses: RwLock::new(statuses),
218            config,
219            selector,
220            retry_queue: Arc::new(Mutex::new(VecDeque::new())),
221            queue_config,
222        }
223    }
224
225    /// Select the best available CA for an enrollment request.
226    ///
227    /// Returns `None` when no healthy CA is available and the fallback
228    /// behavior is [`FallbackBehavior::Reject`].
229    pub fn select(&self) -> Option<CaConnection> {
230        self.select_allowed(
231            &self
232                .connections
233                .iter()
234                .map(|c| c.id.0.clone())
235                .collect::<Vec<_>>(),
236        )
237    }
238
239    /// Select only within the caller's authorized CA set.
240    pub fn select_allowed(&self, allowed: &[String]) -> Option<CaConnection> {
241        let statuses = self.statuses.read();
242        let healthy: Vec<&CaConnection> = self
243            .connections
244            .iter()
245            .filter(|c| {
246                allowed.contains(&c.id.0)
247                    && statuses
248                        .get(&c.id)
249                        .map(|s| s.health.is_available())
250                        .unwrap_or(false)
251            })
252            .collect();
253
254        if healthy.is_empty() {
255            warn!("no healthy CA backends available");
256            return match self.config.fallback {
257                FallbackBehavior::Reject => None,
258                FallbackBehavior::QueueAndRetry => {
259                    // The caller should use `enqueue_request()` to queue the CSR
260                    // and return HTTP 503 with Retry-After to the client.
261                    debug!("queue-and-retry fallback active; caller should enqueue");
262                    None
263                }
264            };
265        }
266
267        let snapshot: Vec<(&CaConnection, &CaStatus)> = healthy
268            .iter()
269            .filter_map(|c| statuses.get(&c.id).map(|s| (*c, s)))
270            .collect();
271
272        self.selector.select(&snapshot)
273    }
274
275    /// Record a successful request to a CA, updating latency EMA.
276    pub fn record_success(&self, id: &CaId, latency: Duration) {
277        let mut statuses = self.statuses.write();
278        if let Some(status) = statuses.get_mut(id) {
279            status.consecutive_failures = 0;
280            status.last_success = Some(Instant::now());
281            status.circuit_open_since = None;
282            status.last_latency = Some(latency);
283
284            let ms = latency.as_secs_f64() * 1000.0;
285            // Exponential moving average with alpha=0.3.
286            status.latency_ema_ms = status.latency_ema_ms * 0.7 + ms * 0.3;
287
288            if status.health != HealthState::Healthy {
289                info!(ca = %id, "CA recovered, marking healthy");
290                status.health = HealthState::Healthy;
291            }
292        }
293    }
294
295    /// Record a failed request, applying circuit-breaker logic (RHELBU-3536 R2).
296    pub fn record_failure(&self, id: &CaId) {
297        let mut statuses = self.statuses.write();
298        if let Some(status) = statuses.get_mut(id) {
299            status.consecutive_failures += 1;
300            debug!(
301                ca = %id,
302                failures = status.consecutive_failures,
303                "CA request failed"
304            );
305
306            if status.consecutive_failures >= self.config.circuit_breaker.failure_threshold {
307                if status.health != HealthState::Unavailable {
308                    warn!(
309                        ca = %id,
310                        failures = status.consecutive_failures,
311                        "circuit breaker tripped, marking CA unavailable"
312                    );
313                    status.health = HealthState::Unavailable;
314                    status.circuit_open_since = Some(Instant::now());
315                }
316            } else if status.health == HealthState::Healthy {
317                status.health = HealthState::Degraded;
318            }
319        }
320    }
321
322    /// Check whether a tripped circuit breaker should allow a re-probe.
323    pub fn should_reprobe(&self, id: &CaId) -> bool {
324        let statuses = self.statuses.read();
325        statuses.get(id).is_some_and(|s| {
326            s.circuit_open_since
327                .is_some_and(|opened| opened.elapsed() >= self.config.circuit_breaker.cooldown)
328        })
329    }
330
331    /// Update the health state for a CA (called by the health checker).
332    pub fn set_health(&self, id: &CaId, state: HealthState) {
333        let mut statuses = self.statuses.write();
334        if let Some(status) = statuses.get_mut(id) {
335            let prev = status.health.clone();
336            status.health = state.clone();
337            if prev != state {
338                info!(ca = %id, from = ?prev, to = ?state, "CA health state transition");
339            }
340        }
341    }
342
343    /// Snapshot of current statuses for monitoring.
344    pub fn status_snapshot(&self) -> HashMap<CaId, CaStatus> {
345        self.statuses.read().clone()
346    }
347
348    /// All registered CA connections.
349    pub fn connections(&self) -> &[CaConnection] {
350        &self.connections
351    }
352
353    /// Pool configuration.
354    pub fn config(&self) -> &PoolConfig {
355        &self.config
356    }
357
358    /// Whether the fallback behavior is queue-and-retry.
359    pub fn is_queue_and_retry(&self) -> bool {
360        matches!(self.config.fallback, FallbackBehavior::QueueAndRetry)
361    }
362
363    /// Enqueue a CSR for later retry when all CAs are down.
364    ///
365    /// Drains expired entries before inserting. Returns `EnqueueResult::Queued`
366    /// with a suggested `Retry-After` value on success, or
367    /// `EnqueueResult::QueueFull` if the queue is at capacity.
368    pub fn enqueue_request(&self, csr_der: Vec<u8>) -> EnqueueResult {
369        // Reject oversized CSRs before acquiring the lock.
370        if csr_der.len() > self.queue_config.max_csr_size {
371            warn!(
372                csr_size = csr_der.len(),
373                max_size = self.queue_config.max_csr_size,
374                "rejecting oversized CSR for queue"
375            );
376            return EnqueueResult::QueueFull {
377                max_size: self.queue_config.max_queue_size,
378            };
379        }
380
381        let mut queue = self.retry_queue.lock();
382
383        // Drain expired entries first.
384        let ttl = Duration::from_secs(self.queue_config.queue_ttl_secs);
385        let before = queue.len();
386        queue.retain(|req| req.queued_at.elapsed() < ttl);
387        let expired = before - queue.len();
388        if expired > 0 {
389            debug!(
390                expired,
391                remaining = queue.len(),
392                "drained expired queued requests"
393            );
394        }
395
396        // Check capacity.
397        if queue.len() >= self.queue_config.max_queue_size {
398            warn!(
399                max_size = self.queue_config.max_queue_size,
400                "retry queue full, rejecting request"
401            );
402            return EnqueueResult::QueueFull {
403                max_size: self.queue_config.max_queue_size,
404            };
405        }
406
407        queue.push_back(QueuedRequest {
408            csr_der,
409            queued_at: Instant::now(),
410        });
411
412        let depth = queue.len();
413        info!(queue_depth = depth, "request enqueued for retry");
414
415        EnqueueResult::Queued {
416            retry_after_secs: 30,
417            queue_depth: depth,
418        }
419    }
420
421    /// Drain all non-expired queued requests for processing.
422    ///
423    /// Called when a CA becomes available to process the backlog.
424    /// Returns queued requests that have not exceeded the TTL.
425    pub fn drain_queue(&self) -> Vec<QueuedRequest> {
426        let mut queue = self.retry_queue.lock();
427        let ttl = Duration::from_secs(self.queue_config.queue_ttl_secs);
428
429        let mut live = Vec::new();
430        while let Some(req) = queue.pop_front() {
431            if req.queued_at.elapsed() < ttl {
432                live.push(req);
433            } else {
434                debug!("discarding expired queued request");
435            }
436        }
437
438        if !live.is_empty() {
439            info!(count = live.len(), "drained queued requests for retry");
440        }
441
442        live
443    }
444
445    /// Current number of queued requests (for monitoring).
446    pub fn queue_depth(&self) -> usize {
447        self.retry_queue.lock().len()
448    }
449
450    /// Queue configuration.
451    pub fn queue_config(&self) -> &QueueConfig {
452        &self.queue_config
453    }
454}