Skip to main content

zentinel_proxy/reload/
mod.rs

1//! Configuration hot reload module for Zentinel proxy.
2//!
3//! This module implements zero-downtime configuration reloading with validation,
4//! atomic swaps, and rollback support for production reliability.
5//!
6//! ## Submodules
7//!
8//! - `coordinator`: Graceful reload coordination and request draining
9//! - `signals`: OS signal handling (SIGHUP, SIGTERM)
10//! - `validators`: Runtime configuration validators
11
12mod coordinator;
13pub mod diff;
14mod signals;
15mod validators;
16
17pub use coordinator::GracefulReloadCoordinator;
18pub use signals::{SignalManager, SignalType};
19pub use validators::{RouteValidator, UpstreamValidator};
20
21// Re-export for use by proxy initialization
22
23use arc_swap::ArcSwap;
24use notify::{Event, EventKind, RecursiveMode, Watcher};
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28use tokio::sync::{broadcast, Mutex, RwLock};
29use tracing::{debug, error, info, trace, warn};
30
31use zentinel_common::errors::{ZentinelError, ZentinelResult};
32use zentinel_config::Config;
33
34use crate::logging::{AuditLogEntry, SharedLogManager};
35use crate::tls::CertificateReloader;
36
37// ============================================================================
38// Reload Events and Types
39// ============================================================================
40
41/// Reload event types
42#[derive(Debug, Clone)]
43pub enum ReloadEvent {
44    /// Configuration reload started
45    Started {
46        timestamp: Instant,
47        trigger: ReloadTrigger,
48    },
49    /// Configuration validated successfully
50    Validated { timestamp: Instant },
51    /// Configuration applied successfully
52    Applied { timestamp: Instant, version: String },
53    /// Configuration reload failed
54    Failed { timestamp: Instant, error: String },
55    /// Configuration rolled back
56    RolledBack { timestamp: Instant, reason: String },
57}
58
59/// Reload trigger source
60#[derive(Debug, Clone)]
61pub enum ReloadTrigger {
62    /// Manual reload via API
63    Manual,
64    /// File change detected
65    FileChange,
66    /// Signal received (SIGHUP)
67    Signal,
68    /// Scheduled reload
69    Scheduled,
70    /// Gateway API controller reconciliation
71    GatewayApi,
72}
73
74// ============================================================================
75// Traits
76// ============================================================================
77
78/// Configuration validator trait
79#[async_trait::async_trait]
80pub trait ConfigValidator: Send + Sync {
81    /// Validate configuration before applying
82    async fn validate(&self, config: &Config) -> ZentinelResult<()>;
83
84    /// Validator name for logging
85    fn name(&self) -> &str;
86}
87
88/// Reload hook trait for custom actions
89#[async_trait::async_trait]
90pub trait ReloadHook: Send + Sync {
91    /// Called before reload starts
92    async fn pre_reload(&self, old_config: &Config, new_config: &Config) -> ZentinelResult<()>;
93
94    /// Called after successful reload
95    async fn post_reload(&self, old_config: &Config, new_config: &Config);
96
97    /// Called on reload failure
98    async fn on_failure(&self, config: &Config, error: &ZentinelError);
99
100    /// Hook name for logging
101    fn name(&self) -> &str;
102}
103
104// ============================================================================
105// Reload Statistics
106// ============================================================================
107
108/// Reload statistics
109#[derive(Default)]
110pub struct ReloadStats {
111    /// Total reload attempts
112    pub total_reloads: std::sync::atomic::AtomicU64,
113    /// Successful reloads
114    pub successful_reloads: std::sync::atomic::AtomicU64,
115    /// Failed reloads
116    pub failed_reloads: std::sync::atomic::AtomicU64,
117    /// Rollbacks performed
118    pub rollbacks: std::sync::atomic::AtomicU64,
119    /// Current config version (incremented on each successful reload)
120    pub config_version: std::sync::atomic::AtomicU64,
121    /// Last successful reload time
122    pub last_success: RwLock<Option<Instant>>,
123    /// Last failure time
124    pub last_failure: RwLock<Option<Instant>>,
125    /// Average reload duration
126    pub avg_duration_ms: RwLock<f64>,
127}
128
129// ============================================================================
130// Configuration Manager
131// ============================================================================
132
133/// Configuration manager with hot reload support
134pub struct ConfigManager {
135    /// Current active configuration
136    current_config: Arc<ArcSwap<Config>>,
137    /// Previous configuration for rollback
138    previous_config: Arc<RwLock<Option<Arc<Config>>>>,
139    /// Configuration file path
140    config_path: PathBuf,
141    /// File watcher for auto-reload (uses RwLock for interior mutability)
142    watcher: Arc<RwLock<Option<notify::RecommendedWatcher>>>,
143    /// Reload event broadcaster
144    reload_tx: broadcast::Sender<ReloadEvent>,
145    /// Reload statistics
146    stats: Arc<ReloadStats>,
147    /// Validation hooks
148    validators: Arc<RwLock<Vec<Box<dyn ConfigValidator>>>>,
149    /// Reload hooks
150    reload_hooks: Arc<RwLock<Vec<Box<dyn ReloadHook>>>>,
151    /// Certificate reloader for TLS hot-reload
152    cert_reloader: Arc<CertificateReloader>,
153    /// Serializes reload operations so only one runs at a time
154    reload_mutex: Arc<Mutex<()>>,
155}
156
157impl ConfigManager {
158    /// Create new configuration manager
159    pub async fn new(
160        config_path: impl AsRef<Path>,
161        initial_config: Config,
162    ) -> ZentinelResult<Self> {
163        let config_path = config_path.as_ref().to_path_buf();
164        let (reload_tx, _) = broadcast::channel(100);
165
166        info!(
167            config_path = %config_path.display(),
168            route_count = initial_config.routes.len(),
169            upstream_count = initial_config.upstreams.len(),
170            listener_count = initial_config.listeners.len(),
171            "Initializing configuration manager"
172        );
173
174        trace!(
175            config_path = %config_path.display(),
176            "Creating ArcSwap for configuration"
177        );
178
179        Ok(Self {
180            current_config: Arc::new(ArcSwap::from_pointee(initial_config)),
181            previous_config: Arc::new(RwLock::new(None)),
182            config_path,
183            watcher: Arc::new(RwLock::new(None)),
184            reload_tx,
185            stats: Arc::new(ReloadStats::default()),
186            validators: Arc::new(RwLock::new(Vec::new())),
187            reload_hooks: Arc::new(RwLock::new(Vec::new())),
188            cert_reloader: Arc::new(CertificateReloader::new()),
189            reload_mutex: Arc::new(Mutex::new(())),
190        })
191    }
192
193    /// Get the certificate reloader for registering TLS listeners
194    pub fn cert_reloader(&self) -> Arc<CertificateReloader> {
195        Arc::clone(&self.cert_reloader)
196    }
197
198    /// Get current configuration
199    pub fn current(&self) -> Arc<Config> {
200        self.current_config.load_full()
201    }
202
203    /// Start watching configuration file for changes
204    ///
205    /// When enabled, the proxy will automatically reload configuration
206    /// when the config file is modified. Also watches the config file's
207    /// parent directory to catch included files in multi-file configs.
208    pub async fn start_watching(&self) -> ZentinelResult<()> {
209        // Check if already watching
210        if self.watcher.read().await.is_some() {
211            warn!("File watcher already active, skipping");
212            return Ok(());
213        }
214
215        let config_path = self.config_path.clone();
216
217        // Use a notify channel to signal that a relevant file changed.
218        // We use a tokio::sync::Notify instead of an mpsc channel — multiple
219        // rapid events coalesce into a single notification automatically.
220        let notify = Arc::new(tokio::sync::Notify::new());
221        let notify_sender = Arc::clone(&notify);
222
223        let watched_path = config_path.clone();
224        let mut watcher =
225            notify::recommended_watcher(move |event: Result<Event, notify::Error>| {
226                match event {
227                    Ok(event) => {
228                        if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
229                            // Check if the event is for a .kdl file or our config file
230                            let dominated = event.paths.iter().any(|p| {
231                                p == &watched_path || p.extension().is_some_and(|ext| ext == "kdl")
232                            });
233                            if dominated {
234                                notify_sender.notify_one();
235                            }
236                        }
237                    }
238                    Err(e) => {
239                        warn!(error = %e, "File watcher error");
240                    }
241                }
242            })
243            .map_err(|e| ZentinelError::Config {
244                message: format!("Failed to create file watcher: {}", e),
245                source: None,
246            })?;
247
248        // Watch the config file itself
249        watcher
250            .watch(&config_path, RecursiveMode::NonRecursive)
251            .map_err(|e| ZentinelError::Config {
252                message: format!("Failed to watch config file: {}", e),
253                source: None,
254            })?;
255
256        // Also watch the config file's parent directory to catch included files.
257        // Multi-file configs use `include "routes/*.kdl"` — those files live
258        // alongside or under the main config directory.
259        if let Some(parent) = config_path.parent() {
260            if let Err(e) = watcher.watch(parent, RecursiveMode::Recursive) {
261                warn!(
262                    path = %parent.display(),
263                    error = %e,
264                    "Could not watch config directory for included files, \
265                     only the main config file will trigger auto-reload"
266                );
267            } else {
268                debug!(
269                    path = %parent.display(),
270                    "Watching config directory recursively for included file changes"
271                );
272            }
273        }
274
275        // Store watcher using interior mutability
276        *self.watcher.write().await = Some(watcher);
277
278        // Spawn event handler task with proper debounce
279        let manager = Arc::new(self.clone_for_task());
280        let config_path_log = self.config_path.clone();
281        tokio::spawn(async move {
282            loop {
283                // Wait for at least one file change notification
284                notify.notified().await;
285
286                // Debounce: wait for changes to settle. If more notifications
287                // arrive during this window, we consume them and keep waiting
288                // until no new changes arrive for 200ms.
289                while let Ok(()) =
290                    tokio::time::timeout(Duration::from_millis(200), notify.notified()).await
291                {
292                    // Another change arrived within the window, keep waiting
293                    trace!("Debounce: additional file change, resetting timer");
294                }
295
296                info!("Configuration file changed, triggering reload");
297
298                if let Err(e) = manager.reload(ReloadTrigger::FileChange).await {
299                    error!(error = %e, "Auto-reload failed, continuing with current configuration");
300                }
301            }
302        });
303
304        // Spawn a fallback polling loop that checks the file's content hash
305        // every 1 second. inotify can miss rename-based atomic writes on some
306        // filesystems (emptyDir, overlayfs). The poll acts as a safety net.
307        // We compare content length + first/last bytes as a cheap hash to avoid
308        // re-reading the entire file on every poll.
309        let poll_manager = Arc::new(self.clone_for_task());
310        let poll_path = self.config_path.clone();
311        tokio::spawn(async move {
312            use std::io::Read;
313            let content_sig = |p: &std::path::Path| -> Option<(u64, Vec<u8>)> {
314                let mut f = std::fs::File::open(p).ok()?;
315                let meta = f.metadata().ok()?;
316                let len = meta.len();
317                // Read first 256 bytes as a fingerprint (covers schema + listeners)
318                let mut buf = vec![0u8; 256.min(len as usize)];
319                f.read_exact(&mut buf).ok()?;
320                Some((len, buf))
321            };
322            let mut last_sig = content_sig(&poll_path);
323
324            loop {
325                tokio::time::sleep(Duration::from_secs(1)).await;
326
327                let current_sig = content_sig(&poll_path);
328                if current_sig != last_sig {
329                    last_sig = current_sig;
330                    debug!("Config file content changed (poll fallback), triggering reload");
331                    if let Err(e) = poll_manager.reload(ReloadTrigger::FileChange).await {
332                        error!(error = %e, "Poll-triggered reload failed");
333                    }
334                }
335            }
336        });
337
338        info!(
339            config_file = %self.config_path.display(),
340            "Auto-reload enabled: watching for configuration changes (with poll fallback)"
341        );
342        Ok(())
343    }
344
345    /// Reload configuration
346    ///
347    /// Only one reload runs at a time. If a reload is already in progress
348    /// (from file watcher, SIGHUP, or manual trigger), this call waits
349    /// for it to finish before starting.
350    pub async fn reload(&self, trigger: ReloadTrigger) -> ZentinelResult<()> {
351        // Serialize reloads — only one at a time
352        let _reload_guard = self.reload_mutex.lock().await;
353
354        let start = Instant::now();
355        let reload_num = self
356            .stats
357            .total_reloads
358            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
359            + 1;
360
361        info!(
362            trigger = ?trigger,
363            reload_num = reload_num,
364            config_path = %self.config_path.display(),
365            "Starting configuration reload"
366        );
367
368        // Notify reload started
369        let _ = self.reload_tx.send(ReloadEvent::Started {
370            timestamp: Instant::now(),
371            trigger: trigger.clone(),
372        });
373
374        trace!(
375            config_path = %self.config_path.display(),
376            "Reading configuration file"
377        );
378
379        // Load new configuration
380        let new_config = match Config::from_file(&self.config_path) {
381            Ok(config) => {
382                debug!(
383                    route_count = config.routes.len(),
384                    upstream_count = config.upstreams.len(),
385                    listener_count = config.listeners.len(),
386                    "Configuration file parsed successfully"
387                );
388                config
389            }
390            Err(e) => {
391                let error_msg = format!("Failed to load configuration: {}", e);
392                error!(
393                    config_path = %self.config_path.display(),
394                    error = %e,
395                    "Failed to load configuration file"
396                );
397                self.stats
398                    .failed_reloads
399                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
400                *self.stats.last_failure.write().await = Some(Instant::now());
401
402                let _ = self.reload_tx.send(ReloadEvent::Failed {
403                    timestamp: Instant::now(),
404                    error: error_msg.clone(),
405                });
406
407                return Err(ZentinelError::Config {
408                    message: error_msg,
409                    source: None,
410                });
411            }
412        };
413
414        trace!("Starting configuration validation");
415
416        // Validate new configuration BEFORE applying
417        // This is critical - invalid configs must never be loaded
418        if let Err(e) = self.validate_config(&new_config).await {
419            error!(
420                error = %e,
421                "Configuration validation failed - new configuration REJECTED"
422            );
423            self.stats
424                .failed_reloads
425                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
426            *self.stats.last_failure.write().await = Some(Instant::now());
427
428            let _ = self.reload_tx.send(ReloadEvent::Failed {
429                timestamp: Instant::now(),
430                error: e.to_string(),
431            });
432
433            return Err(e);
434        }
435
436        info!(
437            route_count = new_config.routes.len(),
438            upstream_count = new_config.upstreams.len(),
439            "Configuration validation passed, applying new configuration"
440        );
441
442        let _ = self.reload_tx.send(ReloadEvent::Validated {
443            timestamp: Instant::now(),
444        });
445
446        // Get current config for rollback
447        let old_config = self.current_config.load_full();
448
449        trace!(
450            old_routes = old_config.routes.len(),
451            new_routes = new_config.routes.len(),
452            "Preparing configuration swap"
453        );
454
455        // Listeners are bound at startup and are NOT hot-reloadable. Say so
456        // loudly instead of silently ignoring the change.
457        let old_listeners = serde_json::to_value(&old_config.listeners).ok();
458        let new_listeners = serde_json::to_value(&new_config.listeners).ok();
459        if old_listeners != new_listeners {
460            warn!(
461                "Listener configuration changed in the new config, but listeners \
462                 (addresses, ports, TLS bindings) are NOT applied by hot reload. \
463                 The proxy continues serving on the previously bound listeners; \
464                 restart zentinel to apply listener changes."
465            );
466        }
467        if serde_json::to_value(&old_config.server).ok()
468            != serde_json::to_value(&new_config.server).ok()
469        {
470            warn!(
471                "system/server configuration changed in the new config, but \
472                 worker threads and process-level settings are NOT applied by \
473                 hot reload; restart zentinel to apply them."
474            );
475        }
476
477        // Run pre-reload hooks
478        let hooks = self.reload_hooks.read().await;
479        for hook in hooks.iter() {
480            trace!(hook_name = %hook.name(), "Running pre-reload hook");
481            if let Err(e) = hook.pre_reload(&old_config, &new_config).await {
482                warn!(
483                    hook_name = %hook.name(),
484                    error = %e,
485                    "Pre-reload hook failed"
486                );
487                // Continue with reload despite hook failure
488            }
489        }
490        drop(hooks);
491
492        // Save previous config for rollback
493        trace!("Saving previous configuration for potential rollback");
494        *self.previous_config.write().await = Some(old_config.clone());
495
496        // Apply new configuration atomically
497        trace!("Applying new configuration atomically");
498        self.current_config.store(Arc::new(new_config.clone()));
499
500        // Run post-reload hooks
501        let hooks = self.reload_hooks.read().await;
502        for hook in hooks.iter() {
503            trace!(hook_name = %hook.name(), "Running post-reload hook");
504            hook.post_reload(&old_config, &new_config).await;
505        }
506        drop(hooks);
507
508        // Update statistics
509        let duration = start.elapsed();
510        let successful_count = self
511            .stats
512            .successful_reloads
513            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
514            + 1;
515        *self.stats.last_success.write().await = Some(Instant::now());
516
517        // Update average duration
518        {
519            let mut avg = self.stats.avg_duration_ms.write().await;
520            let total = successful_count as f64;
521            *avg = (*avg * (total - 1.0) + duration.as_millis() as f64) / total;
522        }
523
524        // Increment config version
525        let new_version = self
526            .stats
527            .config_version
528            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
529            + 1;
530
531        let _ = self.reload_tx.send(ReloadEvent::Applied {
532            timestamp: Instant::now(),
533            version: format!("v{}", new_version),
534        });
535
536        // Reload TLS certificates (hot-reload)
537        // This picks up any certificate file changes without restart
538        let (cert_success, cert_errors) = self.cert_reloader.reload_all();
539        if !cert_errors.is_empty() {
540            for (listener_id, error) in &cert_errors {
541                error!(
542                    listener_id = %listener_id,
543                    error = %error,
544                    "TLS certificate reload failed for listener"
545                );
546            }
547        }
548
549        info!(
550            duration_ms = duration.as_millis(),
551            successful_reloads = successful_count,
552            route_count = new_config.routes.len(),
553            upstream_count = new_config.upstreams.len(),
554            cert_reload_success = cert_success,
555            cert_reload_errors = cert_errors.len(),
556            "Configuration reload completed successfully"
557        );
558
559        Ok(())
560    }
561
562    /// Apply a programmatically-generated configuration directly.
563    ///
564    /// This is the same as `reload()` but accepts a `Config` instead of
565    /// reading from disk. Used by the Gateway API controller to push
566    /// translated Kubernetes resources into the proxy without file I/O.
567    ///
568    /// Runs the full validation → hooks → atomic swap → hooks pipeline.
569    /// Apply one incremental change to the live configuration.
570    ///
571    /// Clones the current configuration, applies the single change, and hands
572    /// the result to [`apply_config`](Self::apply_config) -- so validation,
573    /// the atomic swap, event emission and rollback all behave exactly as they
574    /// do for a full reload. The difference is only in how the new
575    /// configuration was produced.
576    ///
577    /// The change is rejected before anything is swapped if it does not apply:
578    /// adding a route that exists, removing a target that does not. Reporting
579    /// success for those would tell the caller their proxy is in a state it is
580    /// not in.
581    pub async fn apply_change(&self, change: diff::ConfigChange) -> ZentinelResult<()> {
582        let summary = change.summary();
583        let mut next = (*self.current()).clone();
584
585        if let Err(e) = change.apply_to(&mut next) {
586            warn!(change = %summary, error = %e, "Rejected configuration change");
587            return Err(e);
588        }
589
590        info!(change = %summary, "Applying configuration change");
591        self.apply_config(next, ReloadTrigger::Manual).await
592    }
593
594    pub async fn apply_config(
595        &self,
596        new_config: Config,
597        trigger: ReloadTrigger,
598    ) -> ZentinelResult<()> {
599        // Serialize reloads — only one at a time
600        let _reload_guard = self.reload_mutex.lock().await;
601
602        let start = Instant::now();
603        let reload_num = self
604            .stats
605            .total_reloads
606            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
607            + 1;
608
609        info!(
610            trigger = ?trigger,
611            reload_num = reload_num,
612            routes = new_config.routes.len(),
613            upstreams = new_config.upstreams.len(),
614            listeners = new_config.listeners.len(),
615            "Applying programmatic configuration"
616        );
617
618        let _ = self.reload_tx.send(ReloadEvent::Started {
619            timestamp: Instant::now(),
620            trigger,
621        });
622
623        // Validate
624        if let Err(e) = self.validate_config(&new_config).await {
625            error!(error = %e, "Programmatic configuration validation failed");
626            self.stats
627                .failed_reloads
628                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
629            *self.stats.last_failure.write().await = Some(Instant::now());
630
631            let _ = self.reload_tx.send(ReloadEvent::Failed {
632                timestamp: Instant::now(),
633                error: e.to_string(),
634            });
635
636            return Err(e);
637        }
638
639        let _ = self.reload_tx.send(ReloadEvent::Validated {
640            timestamp: Instant::now(),
641        });
642
643        // Get current config for rollback and hooks
644        let old_config = self.current_config.load_full();
645
646        // Run pre-reload hooks
647        let hooks = self.reload_hooks.read().await;
648        for hook in hooks.iter() {
649            if let Err(e) = hook.pre_reload(&old_config, &new_config).await {
650                warn!(hook_name = %hook.name(), error = %e, "Pre-reload hook failed");
651            }
652        }
653        drop(hooks);
654
655        // Save previous config for rollback
656        *self.previous_config.write().await = Some(old_config.clone());
657
658        // Atomic swap
659        self.current_config.store(Arc::new(new_config.clone()));
660
661        // Run post-reload hooks
662        let hooks = self.reload_hooks.read().await;
663        for hook in hooks.iter() {
664            hook.post_reload(&old_config, &new_config).await;
665        }
666        drop(hooks);
667
668        // Update statistics
669        let duration = start.elapsed();
670        let successful_count = self
671            .stats
672            .successful_reloads
673            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
674            + 1;
675        *self.stats.last_success.write().await = Some(Instant::now());
676
677        {
678            let mut avg = self.stats.avg_duration_ms.write().await;
679            let total = successful_count as f64;
680            *avg = (*avg * (total - 1.0) + duration.as_millis() as f64) / total;
681        }
682
683        let new_version = self
684            .stats
685            .config_version
686            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
687            + 1;
688
689        let _ = self.reload_tx.send(ReloadEvent::Applied {
690            timestamp: Instant::now(),
691            version: format!("v{}", new_version),
692        });
693
694        // Reload TLS certificates
695        let (cert_success, cert_errors) = self.cert_reloader.reload_all();
696        if !cert_errors.is_empty() {
697            for (listener_id, error) in &cert_errors {
698                error!(
699                    listener_id = %listener_id,
700                    error = %error,
701                    "TLS certificate reload failed for listener"
702                );
703            }
704        }
705
706        info!(
707            duration_ms = duration.as_millis(),
708            successful_reloads = successful_count,
709            route_count = new_config.routes.len(),
710            upstream_count = new_config.upstreams.len(),
711            cert_reload_success = cert_success,
712            cert_reload_errors = cert_errors.len(),
713            "Programmatic configuration applied successfully"
714        );
715
716        Ok(())
717    }
718
719    /// Get a handle to the underlying ArcSwap for direct reads.
720    ///
721    /// Used by the Gateway API controller to share the same config store.
722    pub fn config_store(&self) -> Arc<ArcSwap<Config>> {
723        Arc::clone(&self.current_config)
724    }
725
726    /// Rollback to previous configuration
727    pub async fn rollback(&self, reason: String) -> ZentinelResult<()> {
728        info!(
729            reason = %reason,
730            "Starting configuration rollback"
731        );
732
733        let previous = self.previous_config.read().await.clone();
734
735        if let Some(prev_config) = previous {
736            trace!(
737                route_count = prev_config.routes.len(),
738                "Found previous configuration for rollback"
739            );
740
741            // Validate previous config (should always pass)
742            trace!("Validating previous configuration");
743            if let Err(e) = self.validate_config(&prev_config).await {
744                error!(
745                    error = %e,
746                    "Previous configuration validation failed during rollback"
747                );
748                return Err(e);
749            }
750
751            // Apply previous configuration
752            trace!("Applying previous configuration");
753            self.current_config.store(prev_config.clone());
754            let rollback_count = self
755                .stats
756                .rollbacks
757                .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
758                + 1;
759
760            let _ = self.reload_tx.send(ReloadEvent::RolledBack {
761                timestamp: Instant::now(),
762                reason: reason.clone(),
763            });
764
765            info!(
766                reason = %reason,
767                rollback_count = rollback_count,
768                route_count = prev_config.routes.len(),
769                "Configuration rolled back successfully"
770            );
771            Ok(())
772        } else {
773            warn!("No previous configuration available for rollback");
774            Err(ZentinelError::Config {
775                message: "No previous configuration available".to_string(),
776                source: None,
777            })
778        }
779    }
780
781    /// Validate configuration
782    async fn validate_config(&self, config: &Config) -> ZentinelResult<()> {
783        trace!(
784            route_count = config.routes.len(),
785            upstream_count = config.upstreams.len(),
786            "Starting configuration validation"
787        );
788
789        // Built-in validation
790        trace!("Running built-in config validation");
791        config.validate()?;
792
793        // Run custom validators
794        let validators = self.validators.read().await;
795        trace!(
796            validator_count = validators.len(),
797            "Running custom validators"
798        );
799        for validator in validators.iter() {
800            trace!(validator_name = %validator.name(), "Running validator");
801            validator.validate(config).await.map_err(|e| {
802                error!(
803                    validator_name = %validator.name(),
804                    error = %e,
805                    "Validator failed"
806                );
807                e
808            })?;
809        }
810
811        debug!(
812            route_count = config.routes.len(),
813            upstream_count = config.upstreams.len(),
814            "Configuration validation passed"
815        );
816
817        Ok(())
818    }
819
820    /// Add configuration validator
821    pub async fn add_validator(&self, validator: Box<dyn ConfigValidator>) {
822        info!("Adding configuration validator: {}", validator.name());
823        self.validators.write().await.push(validator);
824    }
825
826    /// Add reload hook
827    pub async fn add_hook(&self, hook: Box<dyn ReloadHook>) {
828        info!("Adding reload hook: {}", hook.name());
829        self.reload_hooks.write().await.push(hook);
830    }
831
832    /// Subscribe to reload events
833    pub fn subscribe(&self) -> broadcast::Receiver<ReloadEvent> {
834        self.reload_tx.subscribe()
835    }
836
837    /// Get reload statistics
838    pub fn stats(&self) -> &ReloadStats {
839        &self.stats
840    }
841
842    /// Create a lightweight clone for async tasks
843    fn clone_for_task(&self) -> ConfigManager {
844        ConfigManager {
845            current_config: Arc::clone(&self.current_config),
846            previous_config: Arc::clone(&self.previous_config),
847            config_path: self.config_path.clone(),
848            watcher: self.watcher.clone(),
849            reload_tx: self.reload_tx.clone(),
850            stats: Arc::clone(&self.stats),
851            validators: Arc::clone(&self.validators),
852            reload_hooks: Arc::clone(&self.reload_hooks),
853            cert_reloader: Arc::clone(&self.cert_reloader),
854            reload_mutex: Arc::clone(&self.reload_mutex),
855        }
856    }
857}
858
859// ============================================================================
860// Audit Reload Hook
861// ============================================================================
862
863/// Reload hook that logs configuration changes to the audit log.
864pub struct AuditReloadHook {
865    log_manager: SharedLogManager,
866}
867
868impl AuditReloadHook {
869    /// Create a new audit reload hook with the given log manager.
870    pub fn new(log_manager: SharedLogManager) -> Self {
871        Self { log_manager }
872    }
873}
874
875#[async_trait::async_trait]
876impl ReloadHook for AuditReloadHook {
877    async fn pre_reload(&self, old_config: &Config, new_config: &Config) -> ZentinelResult<()> {
878        // Log that reload is starting
879        let trace_id = uuid::Uuid::new_v4().to_string();
880        let audit_entry = AuditLogEntry::config_change(
881            &trace_id,
882            "reload_started",
883            format!(
884                "Configuration reload starting: {} routes -> {} routes, {} upstreams -> {} upstreams",
885                old_config.routes.len(),
886                new_config.routes.len(),
887                old_config.upstreams.len(),
888                new_config.upstreams.len()
889            ),
890        );
891        self.log_manager.log_audit(&audit_entry);
892        Ok(())
893    }
894
895    async fn post_reload(&self, old_config: &Config, new_config: &Config) {
896        // Log successful reload
897        let trace_id = uuid::Uuid::new_v4().to_string();
898        let audit_entry = AuditLogEntry::config_change(
899            &trace_id,
900            "reload_success",
901            format!(
902                "Configuration reload successful: {} routes, {} upstreams, {} listeners",
903                new_config.routes.len(),
904                new_config.upstreams.len(),
905                new_config.listeners.len()
906            ),
907        )
908        .with_metadata("old_routes", old_config.routes.len().to_string())
909        .with_metadata("new_routes", new_config.routes.len().to_string())
910        .with_metadata("old_upstreams", old_config.upstreams.len().to_string())
911        .with_metadata("new_upstreams", new_config.upstreams.len().to_string());
912        self.log_manager.log_audit(&audit_entry);
913    }
914
915    async fn on_failure(&self, config: &Config, error: &ZentinelError) {
916        // Log failed reload
917        let trace_id = uuid::Uuid::new_v4().to_string();
918        let audit_entry = AuditLogEntry::config_change(
919            &trace_id,
920            "reload_failed",
921            format!("Configuration reload failed: {}", error),
922        )
923        .with_metadata("current_routes", config.routes.len().to_string())
924        .with_metadata("current_upstreams", config.upstreams.len().to_string());
925        self.log_manager.log_audit(&audit_entry);
926    }
927
928    fn name(&self) -> &str {
929        "audit_reload_hook"
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    #[tokio::test]
938    async fn test_config_reload_rejects_invalid_config() {
939        // Create valid initial config
940        let initial_config = Config::default_for_testing();
941        let initial_routes = initial_config.routes.len();
942
943        let temp_dir = tempfile::tempdir().unwrap();
944        let config_path = temp_dir.path().join("config.kdl");
945
946        // Write INVALID config (not valid KDL)
947        std::fs::write(&config_path, "this is not valid KDL { {{{{ broken").unwrap();
948
949        // Create config manager with valid initial config
950        let manager = ConfigManager::new(&config_path, initial_config)
951            .await
952            .unwrap();
953
954        // Verify initial config is loaded
955        assert_eq!(manager.current().routes.len(), initial_routes);
956
957        // Attempt reload with invalid config - should fail
958        let result = manager.reload(ReloadTrigger::Manual).await;
959        assert!(result.is_err(), "Reload should fail for invalid config");
960
961        // Verify original config is STILL loaded (not replaced)
962        assert_eq!(
963            manager.current().routes.len(),
964            initial_routes,
965            "Original config should be preserved after failed reload"
966        );
967
968        // Verify failure was recorded in stats
969        assert_eq!(
970            manager
971                .stats()
972                .failed_reloads
973                .load(std::sync::atomic::Ordering::Relaxed),
974            1,
975            "Failed reload should be recorded"
976        );
977    }
978
979    #[tokio::test]
980    async fn test_config_reload_accepts_valid_config() {
981        // Create valid initial config
982        let initial_config = Config::default_for_testing();
983        let temp_dir = tempfile::tempdir().unwrap();
984        let config_path = temp_dir.path().join("config.kdl");
985
986        // Create a static files directory for the test
987        let static_dir = temp_dir.path().join("static");
988        std::fs::create_dir_all(&static_dir).unwrap();
989
990        // Write a valid config with upstream
991        let valid_config = r#"
992server {
993    worker-threads 4
994}
995
996listeners {
997    listener "http" {
998        address "0.0.0.0:8080"
999        protocol "http"
1000    }
1001}
1002
1003upstreams {
1004    upstream "backend" {
1005        target "127.0.0.1:3000"
1006    }
1007}
1008
1009routes {
1010    route "api" {
1011        priority "high"
1012        matches {
1013            path-prefix "/api/"
1014        }
1015        upstream "backend"
1016    }
1017}
1018"#;
1019        std::fs::write(&config_path, valid_config).unwrap();
1020
1021        // Create config manager
1022        let manager = ConfigManager::new(&config_path, initial_config)
1023            .await
1024            .unwrap();
1025
1026        // Reload should succeed with valid config
1027        let result = manager.reload(ReloadTrigger::Manual).await;
1028        assert!(
1029            result.is_ok(),
1030            "Reload should succeed for valid config: {:?}",
1031            result.err()
1032        );
1033
1034        // Verify success was recorded
1035        assert_eq!(
1036            manager
1037                .stats()
1038                .successful_reloads
1039                .load(std::sync::atomic::Ordering::Relaxed),
1040            1,
1041            "Successful reload should be recorded"
1042        );
1043    }
1044
1045    // ========================================================================
1046    // Concurrent Reload Tests
1047    // ========================================================================
1048
1049    /// Helper to create a valid config file with a specified route count
1050    fn write_config_with_routes(path: &Path, route_count: usize) {
1051        let mut routes = String::new();
1052        for i in 0..route_count {
1053            routes.push_str(&format!(
1054                r#"
1055    route "route{i}" {{
1056        priority "medium"
1057        matches {{
1058            path-prefix "/route{i}/"
1059        }}
1060        upstream "backend"
1061    }}
1062"#
1063            ));
1064        }
1065
1066        let config = format!(
1067            r#"
1068server {{
1069    worker-threads 4
1070}}
1071
1072listeners {{
1073    listener "http" {{
1074        address "0.0.0.0:8080"
1075        protocol "http"
1076    }}
1077}}
1078
1079upstreams {{
1080    upstream "backend" {{
1081        target "127.0.0.1:3000"
1082    }}
1083}}
1084
1085routes {{
1086{routes}
1087}}
1088"#
1089        );
1090
1091        std::fs::write(path, config).unwrap();
1092    }
1093
1094    #[tokio::test]
1095    async fn test_concurrent_config_reads_during_reload() {
1096        // Test that config reads don't block or panic during reload
1097        let initial_config = Config::default_for_testing();
1098        let temp_dir = tempfile::tempdir().unwrap();
1099        let config_path = temp_dir.path().join("config.kdl");
1100
1101        write_config_with_routes(&config_path, 5);
1102
1103        let manager = Arc::new(
1104            ConfigManager::new(&config_path, initial_config)
1105                .await
1106                .unwrap(),
1107        );
1108
1109        // Spawn multiple readers that continuously read config
1110        let mut readers = Vec::new();
1111        for _ in 0..10 {
1112            let manager_clone = Arc::clone(&manager);
1113            readers.push(tokio::spawn(async move {
1114                let mut read_count = 0;
1115                for _ in 0..100 {
1116                    let config = manager_clone.current();
1117                    // Access config to ensure it's valid
1118                    let _ = config.routes.len();
1119                    read_count += 1;
1120                    tokio::task::yield_now().await;
1121                }
1122                read_count
1123            }));
1124        }
1125
1126        // Simultaneously trigger reload
1127        let manager_reload = Arc::clone(&manager);
1128        let reload_handle =
1129            tokio::spawn(async move { manager_reload.reload(ReloadTrigger::Manual).await });
1130
1131        // Wait for all readers and the reload
1132        let mut total_reads = 0;
1133        for reader in readers {
1134            total_reads += reader.await.unwrap();
1135        }
1136
1137        let reload_result = reload_handle.await.unwrap();
1138        assert!(reload_result.is_ok(), "Reload should succeed");
1139        assert_eq!(total_reads, 1000, "All reads should complete");
1140    }
1141
1142    #[tokio::test]
1143    async fn test_multiple_concurrent_reloads() {
1144        // Test that multiple simultaneous reloads don't cause panics or corruption
1145        let initial_config = Config::default_for_testing();
1146        let temp_dir = tempfile::tempdir().unwrap();
1147        let config_path = temp_dir.path().join("config.kdl");
1148
1149        write_config_with_routes(&config_path, 3);
1150
1151        let manager = Arc::new(
1152            ConfigManager::new(&config_path, initial_config)
1153                .await
1154                .unwrap(),
1155        );
1156
1157        // Trigger multiple reloads concurrently
1158        let mut reload_handles = Vec::new();
1159        for i in 0..5 {
1160            let manager_clone = Arc::clone(&manager);
1161            let trigger = if i % 2 == 0 {
1162                ReloadTrigger::Manual
1163            } else {
1164                ReloadTrigger::Signal
1165            };
1166            reload_handles.push(tokio::spawn(
1167                async move { manager_clone.reload(trigger).await },
1168            ));
1169        }
1170
1171        // All reloads should complete (some may fail due to racing, but no panics)
1172        let mut success_count = 0;
1173        for handle in reload_handles {
1174            if handle.await.unwrap().is_ok() {
1175                success_count += 1;
1176            }
1177        }
1178
1179        // At least one reload should succeed
1180        assert!(success_count >= 1, "At least one reload should succeed");
1181
1182        // Stats should reflect all attempts
1183        let total = manager
1184            .stats()
1185            .total_reloads
1186            .load(std::sync::atomic::Ordering::Relaxed);
1187        assert_eq!(total, 5, "All reload attempts should be counted");
1188    }
1189
1190    #[tokio::test]
1191    async fn test_config_visibility_after_reload() {
1192        // Test that new config is immediately visible after reload completes
1193        let initial_config = Config::default_for_testing();
1194        let initial_route_count = initial_config.routes.len();
1195
1196        let temp_dir = tempfile::tempdir().unwrap();
1197        let config_path = temp_dir.path().join("config.kdl");
1198
1199        // Start with 2 routes
1200        write_config_with_routes(&config_path, 2);
1201
1202        let manager = ConfigManager::new(&config_path, initial_config)
1203            .await
1204            .unwrap();
1205
1206        // Verify initial config
1207        assert_eq!(manager.current().routes.len(), initial_route_count);
1208
1209        // Reload to get 2 routes from file
1210        manager.reload(ReloadTrigger::Manual).await.unwrap();
1211        assert_eq!(manager.current().routes.len(), 2);
1212
1213        // Update file to 5 routes and reload
1214        write_config_with_routes(&config_path, 5);
1215        manager.reload(ReloadTrigger::Manual).await.unwrap();
1216        assert_eq!(
1217            manager.current().routes.len(),
1218            5,
1219            "New config should be visible immediately after reload"
1220        );
1221
1222        // Update file to 1 route and reload
1223        write_config_with_routes(&config_path, 1);
1224        manager.reload(ReloadTrigger::Manual).await.unwrap();
1225        assert_eq!(
1226            manager.current().routes.len(),
1227            1,
1228            "Config changes should be visible after each reload"
1229        );
1230    }
1231
1232    #[tokio::test]
1233    async fn test_rapid_successive_reloads() {
1234        // Test rapid-fire reloads don't cause issues
1235        let initial_config = Config::default_for_testing();
1236        let temp_dir = tempfile::tempdir().unwrap();
1237        let config_path = temp_dir.path().join("config.kdl");
1238
1239        write_config_with_routes(&config_path, 3);
1240
1241        let manager = ConfigManager::new(&config_path, initial_config)
1242            .await
1243            .unwrap();
1244
1245        // Perform 20 rapid reloads
1246        for i in 0..20 {
1247            // Alternate between different route counts
1248            write_config_with_routes(&config_path, (i % 5) + 1);
1249            let result = manager.reload(ReloadTrigger::Manual).await;
1250            assert!(result.is_ok(), "Reload {} should succeed", i);
1251        }
1252
1253        // Verify final state
1254        let stats = manager.stats();
1255        assert_eq!(
1256            stats
1257                .successful_reloads
1258                .load(std::sync::atomic::Ordering::Relaxed),
1259            20,
1260            "All 20 reloads should succeed"
1261        );
1262        assert_eq!(
1263            stats
1264                .failed_reloads
1265                .load(std::sync::atomic::Ordering::Relaxed),
1266            0,
1267            "No reloads should fail"
1268        );
1269    }
1270
1271    #[tokio::test]
1272    async fn test_rollback_preserves_previous_config() {
1273        // Test that rollback correctly restores previous configuration
1274        let initial_config = Config::default_for_testing();
1275        let temp_dir = tempfile::tempdir().unwrap();
1276        let config_path = temp_dir.path().join("config.kdl");
1277
1278        // Start with 3 routes
1279        write_config_with_routes(&config_path, 3);
1280
1281        let manager = ConfigManager::new(&config_path, initial_config)
1282            .await
1283            .unwrap();
1284
1285        // First reload to establish baseline
1286        manager.reload(ReloadTrigger::Manual).await.unwrap();
1287        assert_eq!(manager.current().routes.len(), 3);
1288
1289        // Second reload with 5 routes
1290        write_config_with_routes(&config_path, 5);
1291        manager.reload(ReloadTrigger::Manual).await.unwrap();
1292        assert_eq!(manager.current().routes.len(), 5);
1293
1294        // Rollback should restore 3 routes
1295        manager
1296            .rollback("Testing rollback".to_string())
1297            .await
1298            .unwrap();
1299        assert_eq!(
1300            manager.current().routes.len(),
1301            3,
1302            "Rollback should restore previous config"
1303        );
1304
1305        // Verify rollback was recorded
1306        assert_eq!(
1307            manager
1308                .stats()
1309                .rollbacks
1310                .load(std::sync::atomic::Ordering::Relaxed),
1311            1,
1312            "Rollback should be recorded in stats"
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn test_reload_events_broadcast() {
1318        // Test that reload events are properly broadcast to subscribers
1319        let initial_config = Config::default_for_testing();
1320        let temp_dir = tempfile::tempdir().unwrap();
1321        let config_path = temp_dir.path().join("config.kdl");
1322
1323        write_config_with_routes(&config_path, 2);
1324
1325        let manager = ConfigManager::new(&config_path, initial_config)
1326            .await
1327            .unwrap();
1328
1329        // Subscribe to reload events
1330        let mut receiver = manager.subscribe();
1331
1332        // Trigger reload
1333        manager.reload(ReloadTrigger::Manual).await.unwrap();
1334
1335        // Collect events (non-blocking with timeout)
1336        let mut events = Vec::new();
1337        while let Ok(Ok(event)) =
1338            tokio::time::timeout(Duration::from_millis(100), receiver.recv()).await
1339        {
1340            events.push(event);
1341        }
1342
1343        // Verify we received the expected events
1344        assert!(
1345            events.len() >= 2,
1346            "Should receive at least Started and Applied/Validated events"
1347        );
1348
1349        // Check for Started event
1350        assert!(
1351            events
1352                .iter()
1353                .any(|e| matches!(e, ReloadEvent::Started { .. })),
1354            "Should receive Started event"
1355        );
1356
1357        // Check for Applied event (successful reload)
1358        assert!(
1359            events
1360                .iter()
1361                .any(|e| matches!(e, ReloadEvent::Applied { .. })),
1362            "Should receive Applied event on success"
1363        );
1364    }
1365
1366    #[tokio::test]
1367    async fn test_graceful_coordinator_with_reload() {
1368        // Test that GracefulReloadCoordinator correctly tracks requests during reload
1369        let coordinator = GracefulReloadCoordinator::new(Duration::from_secs(5));
1370
1371        // Simulate requests starting
1372        coordinator.inc_requests();
1373        coordinator.inc_requests();
1374        coordinator.inc_requests();
1375        assert_eq!(coordinator.active_count(), 3);
1376
1377        // Simulate one request completing during reload prep
1378        coordinator.dec_requests();
1379        assert_eq!(coordinator.active_count(), 2);
1380
1381        // Start drain in background
1382        let coord_clone = Arc::new(coordinator);
1383        let coord_for_drain = Arc::clone(&coord_clone);
1384        let drain_handle = tokio::spawn(async move { coord_for_drain.wait_for_drain().await });
1385
1386        // Simulate remaining requests completing
1387        tokio::time::sleep(Duration::from_millis(50)).await;
1388        coord_clone.dec_requests();
1389        tokio::time::sleep(Duration::from_millis(50)).await;
1390        coord_clone.dec_requests();
1391
1392        // Drain should complete successfully
1393        let drained = drain_handle.await.unwrap();
1394        assert!(drained, "All requests should drain successfully");
1395    }
1396
1397    #[tokio::test]
1398    async fn test_graceful_coordinator_drain_timeout() {
1399        // Test that drain times out correctly when requests don't complete
1400        let coordinator = GracefulReloadCoordinator::new(Duration::from_millis(200));
1401
1402        // Simulate stuck requests
1403        coordinator.inc_requests();
1404        coordinator.inc_requests();
1405
1406        // Start drain - should timeout
1407        let drained = coordinator.wait_for_drain().await;
1408        assert!(!drained, "Drain should timeout with stuck requests");
1409        assert_eq!(
1410            coordinator.active_count(),
1411            2,
1412            "Requests should still be tracked"
1413        );
1414    }
1415}