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