// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use crate::config::TlsReloadOptions; use crate::error::TlsRuntimeError; use crate::material::TlsMaterialSnapshot; use crate::metrics::{ TLS_RUNTIME_FOUNDATION_CONSUMER, record_tls_generation, record_tls_publication_fail, record_tls_reload_result, record_tls_reload_skipped, }; use crate::source::TlsSource; use crate::state::{ TlsGeneration, TlsPublishedState, TlsReloadRuntimeState, TlsRuntimeConsumerSection, TlsRuntimeOutboundSection, TlsRuntimeRuntimeSection, TlsRuntimeServerSection, TlsRuntimeStatusSnapshot, detect_mode_label, }; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; pub trait TlsConsumer: Send + Sync + 'static { fn on_publish(&self, generation: TlsGeneration, state: Arc>) -> Result<(), TlsRuntimeError>; } #[derive(Debug)] pub struct TlsReloadCoordinator { source: TlsSource, options: TlsReloadOptions, } impl TlsReloadCoordinator { pub fn new(source: TlsSource, options: TlsReloadOptions) -> Self { Self { source, options } } pub fn source(&self) -> &TlsSource { &self.source } pub fn options(&self) -> &TlsReloadOptions { &self.options } pub async fn status_snapshot(&self, runtime_state: &TlsReloadRuntimeState) -> TlsRuntimeStatusSnapshot { let current = runtime_state.current.load(); let last_attempt = runtime_state.last_attempt_unix_ms(); let last_success = runtime_state.last_success_unix_ms(); TlsRuntimeStatusSnapshot { runtime: TlsRuntimeRuntimeSection { generation: current.generation.0, reload_enabled: self.options.enabled, detect_mode: detect_mode_label(self.options.detect_mode), last_attempt_time: (last_attempt != 0).then_some(last_attempt), last_success_time: (last_success != 0).then_some(last_success), last_error: runtime_state.last_error.read().await.clone(), source_path: self.source.base_dir.display().to_string(), }, outbound: TlsRuntimeOutboundSection { has_roots: !current.material.outbound.root_ca_pem.is_empty(), has_mtls_identity: current.material.outbound.mtls_identity.is_some(), }, server: TlsRuntimeServerSection { has_material: current.material.server.is_some(), }, consumer: TlsRuntimeConsumerSection { stale_generation: false }, } } pub async fn load_initial_snapshot(&self) -> Result { TlsMaterialSnapshot::load(&self.source).await } pub async fn publish_initial_state(&self, snapshot: TlsMaterialSnapshot) -> Arc> { let published = Arc::new(TlsPublishedState { generation: TlsGeneration(1), fingerprint: snapshot.fingerprint.clone(), material: Arc::new(snapshot), loaded_at_unix_ms: unix_time_ms(), }); record_tls_generation(TLS_RUNTIME_FOUNDATION_CONSUMER, published.generation.0); published } pub async fn reload_once( &self, runtime_state: &TlsReloadRuntimeState, consumer: &C, ) -> Result>>, TlsRuntimeError> where C: TlsConsumer, { runtime_state.mark_attempt(unix_time_ms()); let started_at = std::time::Instant::now(); let snapshot = self.load_initial_snapshot().await?; let current = runtime_state.current.load(); if current.fingerprint == snapshot.fingerprint { debug!(source = %self.source.base_dir.display(), "TLS material unchanged; skipping publication"); record_tls_reload_skipped(TLS_RUNTIME_FOUNDATION_CONSUMER, "unchanged"); return Ok(None); } let published = Arc::new(TlsPublishedState { generation: runtime_state.bump_generation(), fingerprint: snapshot.fingerprint.clone(), material: Arc::new(snapshot), loaded_at_unix_ms: unix_time_ms(), }); if let Err(err) = consumer.on_publish(published.generation, published.clone()) { record_tls_publication_fail(TLS_RUNTIME_FOUNDATION_CONSUMER); return Err(err); } runtime_state.current.store(published.clone()); runtime_state.last_good.store(published.clone()); runtime_state.mark_success(unix_time_ms()); *runtime_state.last_error.write().await = None; record_tls_reload_result( TLS_RUNTIME_FOUNDATION_CONSUMER, "ok", Some(started_at.elapsed().as_secs_f64()), Some(published.generation.0), ); Ok(Some(published)) } pub fn spawn_poll_loop( self: Arc, runtime_state: Arc>, consumer: Arc, ) -> Option> where C: TlsConsumer, { if !self.options.enabled { debug!(source = %self.source.base_dir.display(), "TLS reload disabled; poll loop not started"); return None; } let interval_duration = self.options.interval; info!( source = %self.source.base_dir.display(), interval_secs = interval_duration.as_secs(), detect_mode = ?self.options.detect_mode, "TLS poll reload loop enabled" ); Some(tokio::spawn(async move { let mut interval = tokio::time::interval(interval_duration); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval.tick().await; loop { interval.tick().await; if let Err(err) = self.reload_once(runtime_state.as_ref(), consumer.as_ref()).await { warn!( source = %self.source.base_dir.display(), error = %err, "TLS reload failed (will retry)" ); *runtime_state.last_error.write().await = Some(err.to_string()); } } })) } } fn unix_time_ms() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 } #[cfg(test)] mod tests { use super::*; use crate::config::ReloadDetectMode; use crate::source::TlsSource; use std::sync::Arc; struct NopConsumer; impl TlsConsumer for NopConsumer { fn on_publish( &self, _generation: TlsGeneration, _state: Arc>, ) -> Result<(), TlsRuntimeError> { Ok(()) } } #[tokio::test] async fn reload_once_skips_when_fingerprint_unchanged() { let temp = tempfile::tempdir().expect("tempdir"); let source = TlsSource::from_directory(temp.path().to_path_buf()); let options = TlsReloadOptions::default(); let coordinator = TlsReloadCoordinator::new(source.clone(), options); // Load the actual snapshot from the (empty) temp dir so its fingerprint // matches what reload_once will observe on the next load. let initial_snapshot = coordinator.load_initial_snapshot().await.expect("initial load"); let initial = coordinator.publish_initial_state(initial_snapshot).await; let runtime_state = TlsReloadRuntimeState::new(initial); let consumer = NopConsumer; let result = coordinator.reload_once(&runtime_state, &consumer).await; // Fingerprint has not changed → should skip and return Ok(None). assert!(result.is_ok(), "reload_once should succeed: {:?}", result.err()); assert!(result.unwrap().is_none(), "should skip when fingerprint unchanged"); } #[tokio::test] async fn spawn_poll_loop_starts_for_watch_mode_until_native_watcher_exists() { let temp = tempfile::tempdir().expect("tempdir"); let source = TlsSource::from_directory(temp.path().to_path_buf()); let options = TlsReloadOptions { detect_mode: ReloadDetectMode::Watch, ..Default::default() }; let coordinator = Arc::new(TlsReloadCoordinator::new(source.clone(), options)); let initial_snapshot = coordinator.load_initial_snapshot().await.expect("initial load"); let initial = coordinator.publish_initial_state(initial_snapshot).await; let runtime_state = Arc::new(TlsReloadRuntimeState::new(initial)); let consumer = Arc::new(NopConsumer); let handle = coordinator.spawn_poll_loop(runtime_state, consumer); assert!(handle.is_some(), "watch mode should fall back to polling"); handle.expect("poll loop should start").abort(); } }