1mod coordinator;
13pub mod diff;
14mod signals;
15mod validators;
16
17pub use coordinator::GracefulReloadCoordinator;
18pub use signals::{SignalManager, SignalType};
19pub use validators::{RouteValidator, UpstreamValidator};
20
21use 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#[derive(Debug, Clone)]
43pub enum ReloadEvent {
44 Started {
46 timestamp: Instant,
47 trigger: ReloadTrigger,
48 },
49 Validated { timestamp: Instant },
51 Applied { timestamp: Instant, version: String },
53 Failed { timestamp: Instant, error: String },
55 RolledBack { timestamp: Instant, reason: String },
57}
58
59#[derive(Debug, Clone)]
61pub enum ReloadTrigger {
62 Manual,
64 FileChange,
66 Signal,
68 Scheduled,
70 GatewayApi,
72}
73
74#[async_trait::async_trait]
80pub trait ConfigValidator: Send + Sync {
81 async fn validate(&self, config: &Config) -> ZentinelResult<()>;
83
84 fn name(&self) -> &str;
86}
87
88#[async_trait::async_trait]
90pub trait ReloadHook: Send + Sync {
91 async fn pre_reload(&self, old_config: &Config, new_config: &Config) -> ZentinelResult<()>;
93
94 async fn post_reload(&self, old_config: &Config, new_config: &Config);
96
97 async fn on_failure(&self, config: &Config, error: &ZentinelError);
99
100 fn name(&self) -> &str;
102}
103
104#[derive(Default)]
110pub struct ReloadStats {
111 pub total_reloads: std::sync::atomic::AtomicU64,
113 pub successful_reloads: std::sync::atomic::AtomicU64,
115 pub failed_reloads: std::sync::atomic::AtomicU64,
117 pub rollbacks: std::sync::atomic::AtomicU64,
119 pub config_version: std::sync::atomic::AtomicU64,
121 pub last_success: RwLock<Option<Instant>>,
123 pub last_failure: RwLock<Option<Instant>>,
125 pub avg_duration_ms: RwLock<f64>,
127}
128
129pub struct ConfigManager {
135 current_config: Arc<ArcSwap<Config>>,
137 previous_config: Arc<RwLock<Option<Arc<Config>>>>,
139 config_path: PathBuf,
141 watcher: Arc<RwLock<Option<notify::RecommendedWatcher>>>,
143 reload_tx: broadcast::Sender<ReloadEvent>,
145 stats: Arc<ReloadStats>,
147 validators: Arc<RwLock<Vec<Box<dyn ConfigValidator>>>>,
149 reload_hooks: Arc<RwLock<Vec<Box<dyn ReloadHook>>>>,
151 cert_reloader: Arc<CertificateReloader>,
153 reload_mutex: Arc<Mutex<()>>,
155}
156
157impl ConfigManager {
158 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 pub fn cert_reloader(&self) -> Arc<CertificateReloader> {
195 Arc::clone(&self.cert_reloader)
196 }
197
198 pub fn current(&self) -> Arc<Config> {
200 self.current_config.load_full()
201 }
202
203 pub async fn start_watching(&self) -> ZentinelResult<()> {
209 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 let notify = Arc::new(tokio::sync::Notify::new());
221 let notify_sender = Arc::clone(¬ify);
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 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 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 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 *self.watcher.write().await = Some(watcher);
277
278 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 notify.notified().await;
285
286 while let Ok(()) =
290 tokio::time::timeout(Duration::from_millis(200), notify.notified()).await
291 {
292 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 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 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 pub async fn reload(&self, trigger: ReloadTrigger) -> ZentinelResult<()> {
351 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 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 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 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 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 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 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 }
489 }
490 drop(hooks);
491
492 trace!("Saving previous configuration for potential rollback");
494 *self.previous_config.write().await = Some(old_config.clone());
495
496 trace!("Applying new configuration atomically");
498 self.current_config.store(Arc::new(new_config.clone()));
499
500 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 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 {
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 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 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 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 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 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 let old_config = self.current_config.load_full();
645
646 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 *self.previous_config.write().await = Some(old_config.clone());
657
658 self.current_config.store(Arc::new(new_config.clone()));
660
661 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 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 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 pub fn config_store(&self) -> Arc<ArcSwap<Config>> {
723 Arc::clone(&self.current_config)
724 }
725
726 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 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 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 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 trace!("Running built-in config validation");
791 config.validate()?;
792
793 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 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 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 pub fn subscribe(&self) -> broadcast::Receiver<ReloadEvent> {
834 self.reload_tx.subscribe()
835 }
836
837 pub fn stats(&self) -> &ReloadStats {
839 &self.stats
840 }
841
842 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
859pub struct AuditReloadHook {
865 log_manager: SharedLogManager,
866}
867
868impl AuditReloadHook {
869 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 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 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 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 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 std::fs::write(&config_path, "this is not valid KDL { {{{{ broken").unwrap();
948
949 let manager = ConfigManager::new(&config_path, initial_config)
951 .await
952 .unwrap();
953
954 assert_eq!(manager.current().routes.len(), initial_routes);
956
957 let result = manager.reload(ReloadTrigger::Manual).await;
959 assert!(result.is_err(), "Reload should fail for invalid config");
960
961 assert_eq!(
963 manager.current().routes.len(),
964 initial_routes,
965 "Original config should be preserved after failed reload"
966 );
967
968 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 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 let static_dir = temp_dir.path().join("static");
988 std::fs::create_dir_all(&static_dir).unwrap();
989
990 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 let manager = ConfigManager::new(&config_path, initial_config)
1023 .await
1024 .unwrap();
1025
1026 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 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 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 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 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 let _ = config.routes.len();
1119 read_count += 1;
1120 tokio::task::yield_now().await;
1121 }
1122 read_count
1123 }));
1124 }
1125
1126 let manager_reload = Arc::clone(&manager);
1128 let reload_handle =
1129 tokio::spawn(async move { manager_reload.reload(ReloadTrigger::Manual).await });
1130
1131 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 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 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 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 assert!(success_count >= 1, "At least one reload should succeed");
1181
1182 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 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 write_config_with_routes(&config_path, 2);
1201
1202 let manager = ConfigManager::new(&config_path, initial_config)
1203 .await
1204 .unwrap();
1205
1206 assert_eq!(manager.current().routes.len(), initial_route_count);
1208
1209 manager.reload(ReloadTrigger::Manual).await.unwrap();
1211 assert_eq!(manager.current().routes.len(), 2);
1212
1213 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 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 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 for i in 0..20 {
1247 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 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 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 write_config_with_routes(&config_path, 3);
1280
1281 let manager = ConfigManager::new(&config_path, initial_config)
1282 .await
1283 .unwrap();
1284
1285 manager.reload(ReloadTrigger::Manual).await.unwrap();
1287 assert_eq!(manager.current().routes.len(), 3);
1288
1289 write_config_with_routes(&config_path, 5);
1291 manager.reload(ReloadTrigger::Manual).await.unwrap();
1292 assert_eq!(manager.current().routes.len(), 5);
1293
1294 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 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 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 let mut receiver = manager.subscribe();
1331
1332 manager.reload(ReloadTrigger::Manual).await.unwrap();
1334
1335 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 assert!(
1345 events.len() >= 2,
1346 "Should receive at least Started and Applied/Validated events"
1347 );
1348
1349 assert!(
1351 events
1352 .iter()
1353 .any(|e| matches!(e, ReloadEvent::Started { .. })),
1354 "Should receive Started event"
1355 );
1356
1357 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 let coordinator = GracefulReloadCoordinator::new(Duration::from_secs(5));
1370
1371 coordinator.inc_requests();
1373 coordinator.inc_requests();
1374 coordinator.inc_requests();
1375 assert_eq!(coordinator.active_count(), 3);
1376
1377 coordinator.dec_requests();
1379 assert_eq!(coordinator.active_count(), 2);
1380
1381 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 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 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 let coordinator = GracefulReloadCoordinator::new(Duration::from_millis(200));
1401
1402 coordinator.inc_requests();
1404 coordinator.inc_requests();
1405
1406 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}