Skip to main content

rustfs_targets/runtime/tls/
coordinator.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Target TLS reload coordinator. Manages per-target background poll loops
16//! that periodically check TLS material fingerprints and drive safe reload.
17
18use super::config::{ReloadApplyMode, ReloadDetectMode, TlsReloadOptions};
19#[cfg(test)]
20use super::fingerprint::TargetTlsFingerprint;
21use super::fingerprint::{TargetTlsGeneration, build_target_tls_fingerprint};
22use super::metrics::{record_target_tls_publication_fail, record_target_tls_reload_result, record_target_tls_reload_skipped};
23#[cfg(test)]
24use super::state::TargetTlsInputSet;
25use super::state::{TargetTlsPublishedState, TargetTlsRuntimeState, TargetTlsStatusSnapshot};
26use super::r#trait::ReloadableTargetTls;
27use super::validate::validate_tls_material;
28use crate::error::TargetError;
29use std::collections::HashMap;
30use std::sync::Arc;
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32use tokio::sync::RwLock;
33use tokio::task::JoinHandle;
34use tracing::{debug, info, warn};
35
36/// Minimum positive poll interval. A zero interval would panic inside
37/// `tokio::time::interval`, silently killing the poll loop.
38const MIN_RELOAD_INTERVAL: Duration = Duration::from_secs(1);
39
40/// Bound on how long registration waits to join a replaced poll loop before
41/// detaching it, so a stuck old loop cannot block a re-registration forever.
42const REPLACE_JOIN_TIMEOUT: Duration = Duration::from_secs(5);
43
44struct TargetReloadEntry {
45    #[expect(dead_code)]
46    target_label: String,
47    cancel_tx: tokio::sync::mpsc::Sender<()>,
48    poll_handle: JoinHandle<()>,
49}
50
51/// The top-level coordinator that manages TLS reload for all registered targets.
52///
53/// Typically one instance per process, held alongside `TargetRuntimeManager`.
54/// Each registered target gets its own background poll loop that periodically
55/// checks TLS fingerprints and drives the build/apply cycle.
56pub struct TargetTlsReloadCoordinator {
57    entries: RwLock<HashMap<String, TargetReloadEntry>>,
58}
59
60impl Default for TargetTlsReloadCoordinator {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl TargetTlsReloadCoordinator {
67    pub fn new() -> Self {
68        Self {
69            entries: RwLock::new(HashMap::new()),
70        }
71    }
72
73    /// Register a target for coordinated TLS reload. Spawns a background poll loop.
74    ///
75    /// Returns the initial runtime state that the target should hold for
76    /// accessing the current TLS material via `ArcSwap`.
77    pub async fn register<T: ReloadableTargetTls>(
78        &self,
79        target: Arc<T>,
80        options: TlsReloadOptions,
81    ) -> Result<Arc<TargetTlsRuntimeState<T::Material>>, TargetError> {
82        if !options.enabled {
83            return Err(TargetError::Configuration("TLS reload is disabled".to_string()));
84        }
85
86        let inputs = target.tls_input_set();
87        let target_label = inputs.target_label.clone();
88
89        // Compute the fingerprint BEFORE building material. If a cert rotation
90        // races registration, this ordering guarantees the stored fingerprint is
91        // never *newer* than the published material, so the next poll observes a
92        // fingerprint change and rebuilds (self-healing). The reverse ordering
93        // pinned the old cert permanently (TOCTOU).
94        let initial_fingerprint =
95            build_target_tls_fingerprint(&inputs.ca_path, &inputs.client_cert_path, &inputs.client_key_path).await?;
96        let initial_material = Arc::new(target.build_tls_material().await?);
97
98        let initial_state = Arc::new(TargetTlsPublishedState {
99            generation: TargetTlsGeneration(1),
100            fingerprint: initial_fingerprint,
101            material: initial_material,
102            loaded_at_unix_ms: unix_time_ms(),
103        });
104
105        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, inputs));
106
107        // A detection loop always runs when reload is enabled. Watch mode used to
108        // return success without any loop, silently disabling hot-reload; it now
109        // falls back to interval-based polling so detection is never a no-op.
110        let mut entries = self.entries.write().await;
111
112        // Stop-before-start: if a loop is already registered under this label,
113        // cancel and join it *before* publishing the replacement so two loops
114        // never race on the same target's TLS material (issue #970).
115        if let Some(previous) = entries.remove(&target_label) {
116            let _ = previous.cancel_tx.send(()).await;
117            if tokio::time::timeout(REPLACE_JOIN_TIMEOUT, previous.poll_handle)
118                .await
119                .is_err()
120            {
121                warn!(target = %target_label, "Timed out joining previous TLS reload loop; detaching");
122            }
123            info!(target = %target_label, "Replaced existing TLS reload loop (stop-before-start)");
124        }
125
126        let detect_mode = options.detect_mode;
127        let (cancel_tx, cancel_rx) = tokio::sync::mpsc::channel(1);
128        let poll_handle = tokio::spawn(spawn_target_poll_loop(target, Arc::clone(&runtime_state), options, cancel_rx));
129        entries.insert(
130            target_label.clone(),
131            TargetReloadEntry {
132                target_label: target_label.clone(),
133                cancel_tx,
134                poll_handle,
135            },
136        );
137        info!(target = %target_label, detect_mode = ?detect_mode, "Registered target for TLS reload coordinator");
138
139        Ok(runtime_state)
140    }
141
142    /// Unregister a target and stop its poll loop.
143    pub async fn unregister(&self, target_label: &str) -> Result<(), TargetError> {
144        let mut entries = self.entries.write().await;
145        if let Some(entry) = entries.remove(target_label) {
146            let _ = entry.cancel_tx.send(()).await;
147            entry.poll_handle.abort();
148            info!(target = %target_label, "Unregistered target from TLS reload coordinator");
149        }
150        Ok(())
151    }
152
153    /// Force an immediate reload check for a specific target.
154    /// Used by admin endpoints and test harnesses.
155    pub async fn force_reload<T: ReloadableTargetTls>(
156        &self,
157        target: &T,
158        runtime_state: &TargetTlsRuntimeState<T::Material>,
159        options: &TlsReloadOptions,
160    ) -> Result<TargetTlsGeneration, TargetError> {
161        reload_target_once(target, runtime_state, options).await
162    }
163
164    /// Stop all poll loops.
165    pub async fn shutdown(&self) {
166        let mut entries = self.entries.write().await;
167        for (label, entry) in entries.drain() {
168            let _ = entry.cancel_tx.send(()).await;
169            entry.poll_handle.abort();
170            debug!(target = %label, "Stopped TLS reload poll loop");
171        }
172    }
173
174    /// Collect status snapshots from all registered targets.
175    /// The caller must provide the runtime states separately since the
176    /// coordinator does not hold type-erased references to them.
177    pub fn build_status_snapshot<M>(
178        runtime_state: &TargetTlsRuntimeState<M>,
179        options: &TlsReloadOptions,
180    ) -> TargetTlsStatusSnapshot {
181        let current = runtime_state.current.load();
182        let last_attempt = runtime_state.last_attempt_unix_ms();
183        let last_success = runtime_state.last_success_unix_ms();
184        let last_error = runtime_state.last_error.read().clone();
185
186        TargetTlsStatusSnapshot {
187            target_label: runtime_state.inputs.target_label.clone(),
188            generation: current.generation.0,
189            reload_enabled: options.enabled,
190            detect_mode: match options.detect_mode {
191                ReloadDetectMode::Poll => "poll",
192                ReloadDetectMode::Watch => "watch",
193                ReloadDetectMode::Hybrid => "hybrid",
194            },
195            apply_mode: match options.apply_hint {
196                ReloadApplyMode::Lazy => "lazy",
197                ReloadApplyMode::SoftReconnect => "soft_reconnect",
198            },
199            last_attempt_time: if last_attempt > 0 { Some(last_attempt) } else { None },
200            last_success_time: if last_success > 0 { Some(last_success) } else { None },
201            last_error,
202            ca_path: runtime_state.inputs.ca_path.clone(),
203            client_cert_path: runtime_state.inputs.client_cert_path.clone(),
204            client_key_path: runtime_state.inputs.client_key_path.clone(),
205        }
206    }
207}
208
209/// Background poll loop for a single target.
210async fn spawn_target_poll_loop<T: ReloadableTargetTls>(
211    target: Arc<T>,
212    runtime_state: Arc<TargetTlsRuntimeState<T::Material>>,
213    options: TlsReloadOptions,
214    mut cancel_rx: tokio::sync::mpsc::Receiver<()>,
215) {
216    // Normalize a zero interval to a safe minimum: `tokio::time::interval(0)`
217    // panics, which would silently kill this spawned loop.
218    let interval_period = effective_reload_interval(options.interval);
219    let mut interval = tokio::time::interval(interval_period);
220    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
221    interval.tick().await; // skip the immediate first tick
222
223    let label = &runtime_state.inputs.target_label;
224    let debounce = options.debounce;
225    debug!(target = %label, interval_secs = interval_period.as_secs(), "TLS reload poll loop started");
226
227    loop {
228        tokio::select! {
229            biased;
230            _ = cancel_rx.recv() => {
231                info!(target = %label, "TLS reload poll loop stopped");
232                return;
233            }
234            _ = interval.tick() => {
235                // Enforce minimum stable age: if the last attempt was too recent
236                // (e.g. a rapid succession of ticks), wait one debounce period
237                // before reading files again to avoid picking up half-written certs.
238                let last_attempt = runtime_state.last_attempt_unix_ms();
239                if last_attempt > 0 {
240                    let elapsed_since_last = unix_time_ms().saturating_sub(last_attempt);
241                    if elapsed_since_last < debounce.as_millis() as u64 {
242                        continue;
243                    }
244                }
245
246                if let Err(err) = reload_target_once(target.as_ref(), runtime_state.as_ref(), &options).await {
247                    warn!(target = %label, error = %err, "TLS reload poll check failed (will retry)");
248                }
249            }
250        }
251    }
252}
253
254/// Single reload cycle: read → compare → validate → build → apply → publish.
255///
256/// Returns the new generation on success, or an error on failure.
257/// On failure the current generation and material are untouched.
258async fn reload_target_once<T: ReloadableTargetTls>(
259    target: &T,
260    runtime_state: &TargetTlsRuntimeState<T::Material>,
261    options: &TlsReloadOptions,
262) -> Result<TargetTlsGeneration, TargetError> {
263    // Serialize reload cycles for this target so a force_reload and a poll-loop
264    // tick cannot interleave and publish a stale material or duplicate a
265    // generation.
266    let _reload_guard = runtime_state.reload_lock.lock().await;
267
268    let now = unix_time_ms();
269    runtime_state.mark_attempt(now);
270    let started_at = std::time::Instant::now();
271    let label = &runtime_state.inputs.target_label;
272
273    // 1. Read TLS files and compute fingerprint
274    let inputs = &runtime_state.inputs;
275    let next_fingerprint =
276        match build_target_tls_fingerprint(&inputs.ca_path, &inputs.client_cert_path, &inputs.client_key_path).await {
277            Ok(fingerprint) => fingerprint,
278            Err(err) => {
279                // The first step must not fail silently: record the error and a
280                // failure metric so the observability surface does not show
281                // "all healthy" while reload is actually broken.
282                *runtime_state.last_error.write() = Some(err.to_string());
283                record_target_tls_publication_fail(label);
284                return Err(err);
285            }
286        };
287
288    // 2. Compare with current — skip if unchanged
289    let current = runtime_state.current.load();
290    if current.fingerprint == next_fingerprint {
291        record_target_tls_reload_skipped(label, "unchanged");
292        return Ok(current.generation);
293    }
294
295    // 3. Validate TLS files (cert/key pairing, CA parseable)
296    if let Err(err) = validate_tls_material(&inputs.ca_path, &inputs.client_cert_path, &inputs.client_key_path) {
297        *runtime_state.last_error.write() = Some(err.to_string());
298        record_target_tls_publication_fail(label);
299        return Err(err);
300    }
301
302    // Also call target-specific validation
303    if let Err(err) = target.validate_tls_files().await {
304        *runtime_state.last_error.write() = Some(err.to_string());
305        record_target_tls_publication_fail(label);
306        return Err(err);
307    }
308
309    // 4. Build new material (does not touch current state yet)
310    let new_material = match target.build_tls_material().await {
311        Ok(m) => Arc::new(m),
312        Err(err) => {
313            *runtime_state.last_error.write() = Some(err.to_string());
314            record_target_tls_publication_fail(label);
315            return Err(err);
316        }
317    };
318
319    // 5. Bump generation and apply
320    let new_generation = runtime_state.bump_generation();
321    if let Err(err) = target
322        .apply_tls_material(new_generation, Arc::clone(&new_material), options.apply_hint)
323        .await
324    {
325        *runtime_state.last_error.write() = Some(err.to_string());
326        record_target_tls_publication_fail(label);
327        return Err(err);
328    }
329
330    // 6. Publish new state
331    let published = Arc::new(TargetTlsPublishedState {
332        generation: new_generation,
333        fingerprint: next_fingerprint,
334        material: new_material,
335        loaded_at_unix_ms: now,
336    });
337    runtime_state.current.store(published.clone());
338    runtime_state.last_good.store(published);
339    runtime_state.mark_success(now);
340    *runtime_state.last_error.write() = None;
341
342    record_target_tls_reload_result(label, "ok", started_at.elapsed().as_secs_f64(), new_generation.0);
343
344    debug!(target = %label, generation = new_generation.0, "TLS reload successful");
345    Ok(new_generation)
346}
347
348fn unix_time_ms() -> u64 {
349    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
350}
351
352/// Normalizes a reload interval to a strictly positive duration. A zero
353/// interval would panic inside `tokio::time::interval`.
354fn effective_reload_interval(interval: Duration) -> Duration {
355    if interval.is_zero() { MIN_RELOAD_INTERVAL } else { interval }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
362
363    struct MockTarget {
364        inputs: TargetTlsInputSet,
365        build_calls: AtomicUsize,
366        apply_calls: AtomicUsize,
367        validate_calls: AtomicUsize,
368        should_fail_build: AtomicBool,
369        should_fail_apply: AtomicBool,
370        should_fail_validate: AtomicBool,
371    }
372
373    impl MockTarget {
374        fn new(label: &str) -> Self {
375            Self {
376                inputs: TargetTlsInputSet {
377                    ca_path: String::new(),
378                    client_cert_path: String::new(),
379                    client_key_path: String::new(),
380                    target_label: label.to_string(),
381                },
382                build_calls: AtomicUsize::new(0),
383                apply_calls: AtomicUsize::new(0),
384                validate_calls: AtomicUsize::new(0),
385                should_fail_build: AtomicBool::new(false),
386                should_fail_apply: AtomicBool::new(false),
387                should_fail_validate: AtomicBool::new(false),
388            }
389        }
390    }
391
392    #[async_trait::async_trait]
393    impl ReloadableTargetTls for MockTarget {
394        type Material = String;
395
396        fn tls_input_set(&self) -> TargetTlsInputSet {
397            self.inputs.clone()
398        }
399
400        async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
401            self.build_calls.fetch_add(1, Ordering::SeqCst);
402            if self.should_fail_build.load(Ordering::SeqCst) {
403                return Err(TargetError::Configuration("build failed".to_string()));
404            }
405            Ok("mock-material".to_string())
406        }
407
408        async fn apply_tls_material(
409            &self,
410            _generation: TargetTlsGeneration,
411            _material: Arc<Self::Material>,
412            _mode: ReloadApplyMode,
413        ) -> Result<(), TargetError> {
414            self.apply_calls.fetch_add(1, Ordering::SeqCst);
415            if self.should_fail_apply.load(Ordering::SeqCst) {
416                return Err(TargetError::Configuration("apply failed".to_string()));
417            }
418            Ok(())
419        }
420
421        async fn validate_tls_files(&self) -> Result<(), TargetError> {
422            self.validate_calls.fetch_add(1, Ordering::SeqCst);
423            if self.should_fail_validate.load(Ordering::SeqCst) {
424                return Err(TargetError::Configuration("validate failed".to_string()));
425            }
426            Ok(())
427        }
428    }
429
430    fn default_options() -> TlsReloadOptions {
431        TlsReloadOptions {
432            enabled: true,
433            detect_mode: ReloadDetectMode::Poll,
434            interval: std::time::Duration::from_secs(1),
435            debounce: std::time::Duration::from_secs(1),
436            min_stable_age: std::time::Duration::from_millis(100),
437            apply_hint: ReloadApplyMode::Lazy,
438        }
439    }
440
441    #[tokio::test]
442    async fn register_builds_initial_material() {
443        let coordinator = TargetTlsReloadCoordinator::new();
444        let target = Arc::new(MockTarget::new("test:webhook"));
445
446        let options = TlsReloadOptions {
447            detect_mode: ReloadDetectMode::Watch,
448            ..default_options()
449        };
450        let state = coordinator.register(target.clone(), options).await.unwrap();
451
452        assert_eq!(state.current.load().generation, TargetTlsGeneration(1));
453        assert_eq!(target.build_calls.load(Ordering::SeqCst), 1);
454    }
455
456    #[tokio::test]
457    async fn register_disabled_returns_error() {
458        let coordinator = TargetTlsReloadCoordinator::new();
459        let target = Arc::new(MockTarget::new("test:webhook"));
460
461        let options = TlsReloadOptions {
462            enabled: false,
463            ..default_options()
464        };
465        let result = coordinator.register(target, options).await;
466        assert!(result.is_err());
467    }
468
469    #[tokio::test]
470    async fn shutdown_stops_all_loops() {
471        let coordinator = TargetTlsReloadCoordinator::new();
472        let target = Arc::new(MockTarget::new("test:webhook"));
473
474        let _state = coordinator.register(target.clone(), default_options()).await.unwrap();
475        assert_eq!(coordinator.entries.read().await.len(), 1);
476
477        coordinator.shutdown().await;
478        assert!(coordinator.entries.read().await.is_empty());
479    }
480
481    #[tokio::test]
482    async fn force_reload_calls_build_and_apply() {
483        let target = MockTarget::new("test:webhook");
484        let initial_material = Arc::new("initial".to_string());
485        let initial_state = Arc::new(TargetTlsPublishedState {
486            generation: TargetTlsGeneration(1),
487            fingerprint: TargetTlsFingerprint::default(),
488            material: initial_material,
489            loaded_at_unix_ms: 0,
490        });
491        let inputs = target.tls_input_set();
492        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, inputs));
493        let options = default_options();
494
495        // Force reload should succeed since MockTarget uses empty paths
496        // and the fingerprint won't change from default
497        let result = reload_target_once(&target, &runtime_state, &options).await.unwrap();
498        // Since fingerprint is unchanged (empty paths), generation stays at 1
499        assert_eq!(result, TargetTlsGeneration(1));
500        // Build should NOT be called because fingerprint unchanged
501        assert_eq!(target.build_calls.load(Ordering::SeqCst), 0);
502    }
503
504    #[tokio::test]
505    async fn build_failure_preserves_old_generation() {
506        let target = MockTarget::new("test:webhook");
507        target.should_fail_build.store(true, Ordering::SeqCst);
508
509        // Use a non-default fingerprint so the reload will detect a change
510        // (empty paths → default fingerprint ≠ initial fingerprint)
511        let initial_material = Arc::new("initial".to_string());
512        let initial_fingerprint = TargetTlsFingerprint {
513            ca_sha256: Some([1; 32]),
514            client_cert_sha256: None,
515            client_key_sha256: None,
516        };
517        let initial_state = Arc::new(TargetTlsPublishedState {
518            generation: TargetTlsGeneration(1),
519            fingerprint: initial_fingerprint,
520            material: initial_material,
521            loaded_at_unix_ms: 0,
522        });
523        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
524        let options = default_options();
525
526        // Empty paths produce default fingerprint which differs from initial →
527        // validate passes (empty paths), then build is called and fails.
528        let result = reload_target_once(&target, &runtime_state, &options).await;
529        assert!(result.is_err());
530        // Generation should remain at 1
531        assert_eq!(runtime_state.current_generation(), TargetTlsGeneration(1));
532        assert_eq!(target.build_calls.load(Ordering::SeqCst), 1);
533    }
534
535    #[tokio::test]
536    async fn status_snapshot_reflects_state() {
537        let target = MockTarget::new("test:webhook");
538        let initial_material = Arc::new("initial".to_string());
539        let initial_state = Arc::new(TargetTlsPublishedState {
540            generation: TargetTlsGeneration(1),
541            fingerprint: TargetTlsFingerprint::default(),
542            material: initial_material,
543            loaded_at_unix_ms: 0,
544        });
545        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
546        let options = default_options();
547
548        let snapshot = TargetTlsReloadCoordinator::build_status_snapshot(runtime_state.as_ref(), &options);
549        assert_eq!(snapshot.target_label, "test:webhook");
550        assert_eq!(snapshot.generation, 1);
551        assert!(snapshot.reload_enabled);
552        assert_eq!(snapshot.detect_mode, "poll");
553        assert_eq!(snapshot.apply_mode, "lazy");
554        assert!(snapshot.last_attempt_time.is_none());
555        assert!(snapshot.last_error.is_none());
556    }
557
558    #[tokio::test]
559    async fn apply_failure_preserves_old_generation() {
560        let target = MockTarget::new("test:webhook");
561        target.should_fail_apply.store(true, Ordering::SeqCst);
562
563        // Use a non-default fingerprint so reload detects a change
564        let initial_material = Arc::new("initial".to_string());
565        let initial_fingerprint = TargetTlsFingerprint {
566            ca_sha256: Some([42; 32]),
567            client_cert_sha256: None,
568            client_key_sha256: None,
569        };
570        let initial_state = Arc::new(TargetTlsPublishedState {
571            generation: TargetTlsGeneration(3),
572            fingerprint: initial_fingerprint,
573            material: initial_material,
574            loaded_at_unix_ms: 0,
575        });
576        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
577        let options = default_options();
578
579        let result = reload_target_once(&target, &runtime_state, &options).await;
580        assert!(result.is_err());
581        // Generation should remain at 3
582        assert_eq!(runtime_state.current_generation(), TargetTlsGeneration(3));
583        assert!(runtime_state.last_error.read().is_some());
584    }
585
586    #[tokio::test]
587    async fn validate_failure_prevents_build() {
588        let target = MockTarget::new("test:kafka");
589        target.should_fail_validate.store(true, Ordering::SeqCst);
590
591        // Non-default fingerprint to trigger reload
592        let initial_material = Arc::new("initial".to_string());
593        let initial_fingerprint = TargetTlsFingerprint {
594            ca_sha256: Some([99; 32]),
595            client_cert_sha256: None,
596            client_key_sha256: None,
597        };
598        let initial_state = Arc::new(TargetTlsPublishedState {
599            generation: TargetTlsGeneration(1),
600            fingerprint: initial_fingerprint,
601            material: initial_material,
602            loaded_at_unix_ms: 0,
603        });
604        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
605        let options = default_options();
606
607        let result = reload_target_once(&target, &runtime_state, &options).await;
608        assert!(result.is_err());
609        // Build should NOT have been called
610        assert_eq!(target.build_calls.load(Ordering::SeqCst), 0);
611        // Validate was called
612        assert!(target.validate_calls.load(Ordering::SeqCst) > 0);
613        assert_eq!(runtime_state.current_generation(), TargetTlsGeneration(1));
614    }
615
616    #[tokio::test]
617    async fn error_is_cleared_on_successful_reload() {
618        let target = MockTarget::new("test:nats");
619
620        let initial_material = Arc::new("initial".to_string());
621        let initial_fingerprint = TargetTlsFingerprint {
622            ca_sha256: Some([1; 32]),
623            client_cert_sha256: None,
624            client_key_sha256: None,
625        };
626        let initial_state = Arc::new(TargetTlsPublishedState {
627            generation: TargetTlsGeneration(1),
628            fingerprint: initial_fingerprint,
629            material: initial_material,
630            loaded_at_unix_ms: 0,
631        });
632        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
633        let options = default_options();
634
635        // First: fail the reload
636        target.should_fail_build.store(true, Ordering::SeqCst);
637        let _ = reload_target_once(&target, &runtime_state, &options).await;
638        assert!(runtime_state.last_error.read().is_some());
639
640        // Now succeed (fingerprint still different from default)
641        target.should_fail_build.store(false, Ordering::SeqCst);
642        let result = reload_target_once(&target, &runtime_state, &options).await;
643        assert!(result.is_ok());
644        assert!(runtime_state.last_error.read().is_none());
645        assert!(runtime_state.last_success_unix_ms() > 0);
646    }
647
648    #[tokio::test]
649    async fn last_good_is_never_overwritten_by_failed_reload() {
650        let target = MockTarget::new("test:amqp");
651
652        let initial_material = Arc::new("good".to_string());
653        let initial_fingerprint = TargetTlsFingerprint {
654            ca_sha256: Some([5; 32]),
655            client_cert_sha256: None,
656            client_key_sha256: None,
657        };
658        let initial_state = Arc::new(TargetTlsPublishedState {
659            generation: TargetTlsGeneration(2),
660            fingerprint: initial_fingerprint.clone(),
661            material: initial_material.clone(),
662            loaded_at_unix_ms: 100,
663        });
664        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
665
666        // Verify last_good matches initial
667        let good = runtime_state.last_good.load();
668        assert_eq!(good.generation, TargetTlsGeneration(2));
669
670        // Fail a reload
671        target.should_fail_build.store(true, Ordering::SeqCst);
672        let _ = reload_target_once(&target, &runtime_state, &default_options()).await;
673
674        // last_good should still be the initial state
675        let good_after = runtime_state.last_good.load();
676        assert_eq!(good_after.generation, TargetTlsGeneration(2));
677        assert_eq!(good_after.fingerprint, initial_fingerprint);
678    }
679
680    #[tokio::test]
681    async fn unregister_stops_target_poll_loop() {
682        let coordinator = TargetTlsReloadCoordinator::new();
683        let target = Arc::new(MockTarget::new("test:pulsar"));
684
685        let _state = coordinator.register(target.clone(), default_options()).await.unwrap();
686        assert_eq!(coordinator.entries.read().await.len(), 1);
687
688        coordinator.unregister("test:pulsar").await.unwrap();
689        assert!(coordinator.entries.read().await.is_empty());
690    }
691
692    #[test]
693    fn effective_reload_interval_normalizes_zero() {
694        assert_eq!(effective_reload_interval(std::time::Duration::ZERO), MIN_RELOAD_INTERVAL);
695        let nonzero = std::time::Duration::from_secs(7);
696        assert_eq!(effective_reload_interval(nonzero), nonzero);
697    }
698
699    #[tokio::test]
700    async fn zero_interval_registration_does_not_panic() {
701        let coordinator = TargetTlsReloadCoordinator::new();
702        let target = Arc::new(MockTarget::new("test:zero-interval"));
703
704        let options = TlsReloadOptions {
705            interval: std::time::Duration::ZERO,
706            ..default_options()
707        };
708        // Registration spawns the poll loop; a zero interval must be normalized
709        // rather than panicking inside the spawned task.
710        let state = coordinator.register(target, options).await.unwrap();
711        assert_eq!(state.current.load().generation, TargetTlsGeneration(1));
712        assert_eq!(coordinator.entries.read().await.len(), 1);
713    }
714
715    #[tokio::test]
716    async fn watch_mode_starts_detection_loop() {
717        let coordinator = TargetTlsReloadCoordinator::new();
718        let target = Arc::new(MockTarget::new("test:watch"));
719
720        let options = TlsReloadOptions {
721            detect_mode: ReloadDetectMode::Watch,
722            ..default_options()
723        };
724        // Watch mode must not silently skip starting a detection loop.
725        let _state = coordinator.register(target, options).await.unwrap();
726        assert_eq!(coordinator.entries.read().await.len(), 1);
727    }
728
729    #[tokio::test]
730    async fn duplicate_label_registration_replaces_previous_loop() {
731        let coordinator = TargetTlsReloadCoordinator::new();
732        let first = Arc::new(MockTarget::new("test:dup"));
733        let second = Arc::new(MockTarget::new("test:dup"));
734
735        let _s1 = coordinator.register(first, default_options()).await.unwrap();
736        assert_eq!(coordinator.entries.read().await.len(), 1);
737
738        // Re-registering the same label must stop-and-join the old loop, leaving
739        // exactly one active entry (no orphaned duplicate loop).
740        let _s2 = coordinator.register(second, default_options()).await.unwrap();
741        assert_eq!(coordinator.entries.read().await.len(), 1);
742    }
743
744    #[tokio::test]
745    async fn first_step_fingerprint_failure_records_error_and_metric() {
746        let mut target = MockTarget::new("test:fp-fail");
747        // A non-empty CA path that does not exist forces the very first step
748        // (fingerprint read) to fail.
749        target.inputs.ca_path = "/nonexistent/rustfs-tls-test/ca-does-not-exist.pem".to_string();
750
751        let initial_state = Arc::new(TargetTlsPublishedState {
752            generation: TargetTlsGeneration(1),
753            fingerprint: TargetTlsFingerprint::default(),
754            material: Arc::new("initial".to_string()),
755            loaded_at_unix_ms: 0,
756        });
757        let runtime_state = Arc::new(TargetTlsRuntimeState::new(initial_state, target.tls_input_set()));
758
759        let result = reload_target_once(&target, &runtime_state, &default_options()).await;
760        assert!(result.is_err());
761        // The first-step failure must be visible, not silently swallowed.
762        assert!(runtime_state.last_error.read().is_some());
763        // Build must not have been reached.
764        assert_eq!(target.build_calls.load(Ordering::SeqCst), 0);
765    }
766
767    #[tokio::test]
768    async fn bump_generation_saturates_at_max() {
769        let target = MockTarget::new("test:saturation");
770        let initial_material = Arc::new("initial".to_string());
771        let max_gen_state = Arc::new(TargetTlsPublishedState {
772            generation: TargetTlsGeneration(u64::MAX),
773            fingerprint: TargetTlsFingerprint::default(),
774            material: initial_material,
775            loaded_at_unix_ms: 0,
776        });
777        let runtime_state = Arc::new(TargetTlsRuntimeState::new(max_gen_state, target.tls_input_set()));
778
779        let bumped = runtime_state.bump_generation();
780        assert_eq!(bumped, TargetTlsGeneration(u64::MAX)); // saturating add
781    }
782}