things3_core/
config_hot_reload.rs

1//! Configuration Hot Reloading
2//!
3//! This module provides functionality for hot-reloading configuration files
4//! without restarting the server.
5
6use crate::error::{Result, ThingsError};
7use crate::mcp_config::McpServerConfig;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::{broadcast, RwLock};
12use tokio::time::interval;
13use tracing::{debug, error, info};
14
15/// Configuration hot reloader
16#[derive(Debug)]
17pub struct ConfigHotReloader {
18    /// Current configuration
19    config: Arc<RwLock<McpServerConfig>>,
20    /// Configuration file path
21    config_path: PathBuf,
22    /// Reload interval
23    reload_interval: Duration,
24    /// Whether hot reloading is enabled
25    enabled: bool,
26    /// Broadcast channel for configuration change notifications
27    change_tx: broadcast::Sender<McpServerConfig>,
28    /// Last modification time of the config file
29    last_modified: Option<std::time::SystemTime>,
30}
31
32impl ConfigHotReloader {
33    /// Create a new configuration hot reloader
34    ///
35    /// # Arguments
36    /// * `config` - Initial configuration
37    /// * `config_path` - Path to the configuration file to watch
38    /// * `reload_interval` - How often to check for changes
39    ///
40    /// # Errors
41    /// Returns an error if the configuration file cannot be accessed
42    pub fn new(
43        config: McpServerConfig,
44        config_path: PathBuf,
45        reload_interval: Duration,
46    ) -> Result<Self> {
47        // Validate that the config file exists and is readable
48        if !config_path.exists() {
49            return Err(ThingsError::configuration(format!(
50                "Configuration file does not exist: {}",
51                config_path.display()
52            )));
53        }
54
55        let (change_tx, _) = broadcast::channel(16);
56        let last_modified = Self::get_file_modified_time(&config_path)?;
57
58        Ok(Self {
59            config: Arc::new(RwLock::new(config)),
60            config_path,
61            reload_interval,
62            enabled: true,
63            change_tx,
64            last_modified: Some(last_modified),
65        })
66    }
67
68    /// Create a hot reloader with default settings
69    ///
70    /// # Arguments
71    /// * `config_path` - Path to the configuration file to watch
72    ///
73    /// # Errors
74    /// Returns an error if the configuration file cannot be accessed
75    pub fn with_default_settings(config_path: PathBuf) -> Result<Self> {
76        let config = McpServerConfig::default();
77        Self::new(config, config_path, Duration::from_secs(5))
78    }
79
80    /// Get the current configuration
81    #[must_use]
82    pub async fn get_config(&self) -> McpServerConfig {
83        self.config.read().await.clone()
84    }
85
86    /// Update the configuration
87    ///
88    /// # Arguments
89    /// * `new_config` - New configuration to set
90    ///
91    /// # Errors
92    /// Returns an error if the configuration is invalid
93    pub async fn update_config(&self, new_config: McpServerConfig) -> Result<()> {
94        new_config.validate()?;
95
96        let mut config = self.config.write().await;
97        *config = new_config.clone();
98
99        // Broadcast the change
100        let _ = self.change_tx.send(new_config);
101
102        info!("Configuration updated successfully");
103        Ok(())
104    }
105
106    /// Get a receiver for configuration change notifications
107    #[must_use]
108    pub fn subscribe_to_changes(&self) -> broadcast::Receiver<McpServerConfig> {
109        self.change_tx.subscribe()
110    }
111
112    /// Enable or disable hot reloading
113    pub fn set_enabled(&mut self, enabled: bool) {
114        self.enabled = enabled;
115        if enabled {
116            info!("Configuration hot reloading enabled");
117        } else {
118            info!("Configuration hot reloading disabled");
119        }
120    }
121
122    /// Check if hot reloading is enabled
123    #[must_use]
124    pub fn is_enabled(&self) -> bool {
125        self.enabled
126    }
127
128    /// Start the hot reloader task
129    ///
130    /// This will spawn a background task that periodically checks for configuration changes
131    /// and reloads the configuration if changes are detected.
132    ///
133    /// # Errors
134    /// Returns an error if the configuration cannot be loaded or if there are issues
135    /// with the file system operations.
136    pub fn start(&self) -> Result<()> {
137        if !self.enabled {
138            debug!("Hot reloading is disabled, not starting reloader task");
139            return Ok(());
140        }
141
142        let config = Arc::clone(&self.config);
143        let config_path = self.config_path.clone();
144        let change_tx = self.change_tx.clone();
145        let mut interval = interval(self.reload_interval);
146        let mut last_modified = self.last_modified;
147
148        info!(
149            "Starting configuration hot reloader for: {}",
150            config_path.display()
151        );
152
153        tokio::spawn(async move {
154            loop {
155                interval.tick().await;
156
157                match Self::check_and_reload_config(
158                    &config_path,
159                    &config,
160                    &change_tx,
161                    &mut last_modified,
162                )
163                .await
164                {
165                    Ok(reloaded) => {
166                        if reloaded {
167                            debug!(
168                                "Configuration reloaded from file: {}",
169                                config_path.display()
170                            );
171                        }
172                    }
173                    Err(e) => {
174                        error!("Failed to check/reload configuration: {}", e);
175                    }
176                }
177            }
178        });
179
180        Ok(())
181    }
182
183    /// Check for configuration changes and reload if necessary
184    async fn check_and_reload_config(
185        config_path: &PathBuf,
186        config: &Arc<RwLock<McpServerConfig>>,
187        change_tx: &broadcast::Sender<McpServerConfig>,
188        last_modified: &mut Option<std::time::SystemTime>,
189    ) -> Result<bool> {
190        // Check if the file has been modified
191        let current_modified = Self::get_file_modified_time(config_path)?;
192
193        if let Some(last) = *last_modified {
194            if current_modified <= last {
195                return Ok(false); // No changes
196            }
197        }
198
199        // File has been modified, try to reload
200        debug!("Configuration file modified, attempting to reload");
201
202        match McpServerConfig::from_file(config_path) {
203            Ok(new_config) => {
204                // Validate the new configuration
205                new_config.validate()?;
206
207                // Update the configuration
208                {
209                    let mut current_config = config.write().await;
210                    *current_config = new_config.clone();
211                }
212
213                // Broadcast the change
214                let _ = change_tx.send(new_config);
215
216                // Update the last modified time
217                *last_modified = Some(current_modified);
218
219                info!(
220                    "Configuration successfully reloaded from: {}",
221                    config_path.display()
222                );
223                Ok(true)
224            }
225            Err(e) => {
226                error!(
227                    "Failed to reload configuration from {}: {}",
228                    config_path.display(),
229                    e
230                );
231                Err(e)
232            }
233        }
234    }
235
236    /// Get the last modification time of a file
237    fn get_file_modified_time(path: &PathBuf) -> Result<std::time::SystemTime> {
238        let metadata = std::fs::metadata(path).map_err(|e| {
239            ThingsError::Io(std::io::Error::other(format!(
240                "Failed to get file metadata for {}: {}",
241                path.display(),
242                e
243            )))
244        })?;
245
246        metadata.modified().map_err(|e| {
247            ThingsError::Io(std::io::Error::other(format!(
248                "Failed to get modification time for {}: {}",
249                path.display(),
250                e
251            )))
252        })
253    }
254
255    /// Manually trigger a configuration reload
256    ///
257    /// # Errors
258    /// Returns an error if the configuration cannot be reloaded
259    pub async fn reload_now(&self) -> Result<bool> {
260        let mut last_modified = self.last_modified;
261        Self::check_and_reload_config(
262            &self.config_path,
263            &self.config,
264            &self.change_tx,
265            &mut last_modified,
266        )
267        .await
268    }
269
270    /// Get the configuration file path being watched
271    #[must_use]
272    pub fn config_path(&self) -> &PathBuf {
273        &self.config_path
274    }
275
276    /// Get the reload interval
277    #[must_use]
278    pub fn reload_interval(&self) -> Duration {
279        self.reload_interval
280    }
281
282    /// Set the reload interval
283    pub fn set_reload_interval(&mut self, interval: Duration) {
284        self.reload_interval = interval;
285        debug!("Configuration reload interval set to: {:?}", interval);
286    }
287}
288
289/// Configuration change handler trait
290#[async_trait::async_trait]
291pub trait ConfigChangeHandler: Send + Sync {
292    /// Handle a configuration change
293    ///
294    /// # Arguments
295    /// * `old_config` - The previous configuration
296    /// * `new_config` - The new configuration
297    async fn handle_config_change(
298        &self,
299        old_config: &McpServerConfig,
300        new_config: &McpServerConfig,
301    );
302}
303
304/// Default configuration change handler that logs changes
305pub struct DefaultConfigChangeHandler;
306
307#[async_trait::async_trait]
308impl ConfigChangeHandler for DefaultConfigChangeHandler {
309    async fn handle_config_change(
310        &self,
311        old_config: &McpServerConfig,
312        new_config: &McpServerConfig,
313    ) {
314        info!("Configuration changed:");
315
316        if old_config.server.name != new_config.server.name {
317            info!(
318                "  Server name: {} -> {}",
319                old_config.server.name, new_config.server.name
320            );
321        }
322        if old_config.logging.level != new_config.logging.level {
323            info!(
324                "  Log level: {} -> {}",
325                old_config.logging.level, new_config.logging.level
326            );
327        }
328        if old_config.cache.enabled != new_config.cache.enabled {
329            info!(
330                "  Cache enabled: {} -> {}",
331                old_config.cache.enabled, new_config.cache.enabled
332            );
333        }
334        if old_config.performance.enabled != new_config.performance.enabled {
335            info!(
336                "  Performance monitoring: {} -> {}",
337                old_config.performance.enabled, new_config.performance.enabled
338            );
339        }
340        if old_config.security.authentication.enabled != new_config.security.authentication.enabled
341        {
342            info!(
343                "  Authentication: {} -> {}",
344                old_config.security.authentication.enabled,
345                new_config.security.authentication.enabled
346            );
347        }
348    }
349}
350
351/// Configuration hot reloader with change handler
352pub struct ConfigHotReloaderWithHandler {
353    /// The base hot reloader
354    reloader: ConfigHotReloader,
355    /// Change handler
356    handler: Arc<dyn ConfigChangeHandler>,
357}
358
359impl ConfigHotReloaderWithHandler {
360    /// Create a new hot reloader with a change handler
361    ///
362    /// # Arguments
363    /// * `config` - Initial configuration
364    /// * `config_path` - Path to the configuration file to watch
365    /// * `reload_interval` - How often to check for changes
366    /// * `handler` - Handler for configuration changes
367    ///
368    /// # Errors
369    /// Returns an error if the configuration file cannot be accessed
370    pub fn new(
371        config: McpServerConfig,
372        config_path: PathBuf,
373        reload_interval: Duration,
374        handler: Arc<dyn ConfigChangeHandler>,
375    ) -> Result<Self> {
376        let reloader = ConfigHotReloader::new(config, config_path, reload_interval)?;
377
378        Ok(Self { reloader, handler })
379    }
380
381    /// Start the hot reloader with change handling
382    ///
383    /// # Errors
384    /// Returns an error if the hot reloader cannot be started
385    pub fn start_with_handler(&self) -> Result<()> {
386        // Start the base reloader
387        self.reloader.start()?;
388
389        // Start the change handler task
390        let mut change_rx = self.reloader.subscribe_to_changes();
391        let handler = Arc::clone(&self.handler);
392        let config = Arc::clone(&self.reloader.config);
393
394        tokio::spawn(async move {
395            let mut old_config = config.read().await.clone();
396
397            while let Ok(new_config) = change_rx.recv().await {
398                handler.handle_config_change(&old_config, &new_config).await;
399                old_config = new_config;
400            }
401        });
402
403        Ok(())
404    }
405
406    /// Get the underlying hot reloader
407    #[must_use]
408    pub fn reloader(&self) -> &ConfigHotReloader {
409        &self.reloader
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use std::time::Duration;
417    use tempfile::NamedTempFile;
418
419    #[tokio::test]
420    async fn test_config_hot_reloader_creation() {
421        let temp_file = NamedTempFile::new().unwrap();
422        let config_path = temp_file.path().with_extension("json");
423
424        let config = McpServerConfig::default();
425        config.to_file(&config_path, "json").unwrap();
426
427        let reloader = ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
428        assert!(reloader.is_enabled());
429    }
430
431    #[tokio::test]
432    async fn test_config_hot_reloader_with_default_settings() {
433        let temp_file = NamedTempFile::new().unwrap();
434        let config_path = temp_file.path().with_extension("json");
435
436        let config = McpServerConfig::default();
437        config.to_file(&config_path, "json").unwrap();
438
439        let reloader = ConfigHotReloader::with_default_settings(config_path).unwrap();
440        assert!(reloader.is_enabled());
441    }
442
443    #[tokio::test]
444    async fn test_config_hot_reloader_enable_disable() {
445        let temp_file = NamedTempFile::new().unwrap();
446        let config_path = temp_file.path().with_extension("json");
447
448        let config = McpServerConfig::default();
449        config.to_file(&config_path, "json").unwrap();
450
451        let mut reloader =
452            ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
453        assert!(reloader.is_enabled());
454
455        reloader.set_enabled(false);
456        assert!(!reloader.is_enabled());
457
458        reloader.set_enabled(true);
459        assert!(reloader.is_enabled());
460    }
461
462    #[tokio::test]
463    async fn test_config_hot_reloader_get_config() {
464        let temp_file = NamedTempFile::new().unwrap();
465        let config_path = temp_file.path().with_extension("json");
466
467        let mut config = McpServerConfig::default();
468        config.server.name = "test-server".to_string();
469        config.to_file(&config_path, "json").unwrap();
470
471        let reloader = ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
472        let loaded_config = reloader.get_config().await;
473        assert_eq!(loaded_config.server.name, "test-server");
474    }
475
476    #[tokio::test]
477    async fn test_config_hot_reloader_update_config() {
478        let temp_file = NamedTempFile::new().unwrap();
479        let config_path = temp_file.path().with_extension("json");
480
481        let config = McpServerConfig::default();
482        config.to_file(&config_path, "json").unwrap();
483
484        let reloader = ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
485
486        let mut new_config = McpServerConfig::default();
487        new_config.server.name = "updated-server".to_string();
488
489        reloader.update_config(new_config).await.unwrap();
490
491        let loaded_config = reloader.get_config().await;
492        assert_eq!(loaded_config.server.name, "updated-server");
493    }
494
495    #[tokio::test]
496    async fn test_config_hot_reloader_subscribe_to_changes() {
497        let temp_file = NamedTempFile::new().unwrap();
498        let config_path = temp_file.path().with_extension("json");
499
500        let config = McpServerConfig::default();
501        config.to_file(&config_path, "json").unwrap();
502
503        let reloader = ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
504        let mut change_rx = reloader.subscribe_to_changes();
505
506        let mut new_config = McpServerConfig::default();
507        new_config.server.name = "changed-server".to_string();
508
509        reloader.update_config(new_config).await.unwrap();
510
511        let received_config = change_rx.recv().await.unwrap();
512        assert_eq!(received_config.server.name, "changed-server");
513    }
514
515    #[tokio::test]
516    async fn test_config_hot_reloader_with_handler() {
517        let temp_file = NamedTempFile::new().unwrap();
518        let config_path = temp_file.path().with_extension("json");
519
520        let config = McpServerConfig::default();
521        config.to_file(&config_path, "json").unwrap();
522
523        let handler = Arc::new(DefaultConfigChangeHandler);
524        let reloader =
525            ConfigHotReloaderWithHandler::new(config, config_path, Duration::from_secs(1), handler)
526                .unwrap();
527
528        assert!(reloader.reloader().is_enabled());
529    }
530
531    #[tokio::test]
532    async fn test_config_hot_reloader_nonexistent_file() {
533        let config_path = PathBuf::from("/nonexistent/config.json");
534        let config = McpServerConfig::default();
535
536        let result = ConfigHotReloader::new(config, config_path, Duration::from_secs(1));
537        assert!(result.is_err());
538        let error = result.unwrap_err();
539        assert!(matches!(error, ThingsError::Configuration { .. }));
540    }
541
542    #[tokio::test]
543    async fn test_config_hot_reloader_invalid_config_file() {
544        let temp_file = NamedTempFile::new().unwrap();
545        let config_path = temp_file.path().with_extension("json");
546
547        // Write invalid JSON directly
548        std::fs::write(&config_path, "{ invalid json }").unwrap();
549
550        // Test that McpServerConfig::from_file fails with invalid JSON
551        let result = McpServerConfig::from_file(&config_path);
552        assert!(result.is_err());
553    }
554
555    #[tokio::test]
556    async fn test_config_hot_reloader_file_permission_error() {
557        let temp_file = NamedTempFile::new().unwrap();
558        let config_path = temp_file.path().with_extension("json");
559
560        let config = McpServerConfig::default();
561        config.to_file(&config_path, "json").unwrap();
562
563        // Create reloader first
564        let reloader =
565            ConfigHotReloader::new(config, config_path.clone(), Duration::from_secs(1)).unwrap();
566
567        // Remove the file to simulate permission error
568        std::fs::remove_file(&config_path).unwrap();
569
570        // Try to reload - should handle the error gracefully
571        let result = reloader.reload_now().await;
572        assert!(result.is_err());
573    }
574
575    #[tokio::test]
576    async fn test_config_hot_reloader_concurrent_updates() {
577        let temp_file = NamedTempFile::new().unwrap();
578        let config_path = temp_file.path().with_extension("json");
579
580        let config = McpServerConfig::default();
581        config.to_file(&config_path, "json").unwrap();
582
583        let reloader =
584            ConfigHotReloader::new(config, config_path.clone(), Duration::from_secs(1)).unwrap();
585        let mut change_rx = reloader.subscribe_to_changes();
586
587        // Update config multiple times concurrently
588        let mut config1 = McpServerConfig::default();
589        config1.server.name = "config1".to_string();
590
591        let mut config2 = McpServerConfig::default();
592        config2.server.name = "config2".to_string();
593
594        // Update configs concurrently
595        let reloader_clone = Arc::new(reloader);
596        let reloader1 = Arc::clone(&reloader_clone);
597        let reloader2 = Arc::clone(&reloader_clone);
598
599        let handle1 = tokio::spawn(async move { reloader1.update_config(config1).await });
600
601        let handle2 = tokio::spawn(async move { reloader2.update_config(config2).await });
602
603        // Wait for both updates
604        let _ = handle1.await.unwrap();
605        let _ = handle2.await.unwrap();
606
607        // Should receive at least one change notification
608        let _received_config = change_rx.recv().await.unwrap();
609    }
610
611    #[tokio::test]
612    async fn test_config_hot_reloader_validation_error() {
613        let temp_file = NamedTempFile::new().unwrap();
614        let config_path = temp_file.path().with_extension("json");
615
616        let config = McpServerConfig::default();
617        config.to_file(&config_path, "json").unwrap();
618
619        let reloader = ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
620
621        // Create an invalid config (empty server name should fail validation)
622        let mut invalid_config = McpServerConfig::default();
623        invalid_config.server.name = String::new(); // This should fail validation
624
625        let result = reloader.update_config(invalid_config).await;
626        assert!(result.is_err());
627    }
628
629    #[tokio::test]
630    async fn test_config_hot_reloader_disabled_start() {
631        let temp_file = NamedTempFile::new().unwrap();
632        let config_path = temp_file.path().with_extension("json");
633
634        let config = McpServerConfig::default();
635        config.to_file(&config_path, "json").unwrap();
636
637        let mut reloader =
638            ConfigHotReloader::new(config, config_path, Duration::from_secs(1)).unwrap();
639        reloader.set_enabled(false);
640
641        // Start should succeed even when disabled
642        let result = reloader.start();
643        assert!(result.is_ok());
644    }
645
646    #[tokio::test]
647    async fn test_config_hot_reloader_reload_interval() {
648        let temp_file = NamedTempFile::new().unwrap();
649        let config_path = temp_file.path().with_extension("json");
650
651        let config = McpServerConfig::default();
652        config.to_file(&config_path, "json").unwrap();
653
654        let mut reloader =
655            ConfigHotReloader::new(config, config_path, Duration::from_secs(5)).unwrap();
656
657        assert_eq!(reloader.reload_interval(), Duration::from_secs(5));
658
659        reloader.set_reload_interval(Duration::from_secs(10));
660        assert_eq!(reloader.reload_interval(), Duration::from_secs(10));
661    }
662
663    #[tokio::test]
664    async fn test_config_hot_reloader_metadata_error() {
665        let temp_file = NamedTempFile::new().unwrap();
666        let config_path = temp_file.path().with_extension("json");
667
668        let config = McpServerConfig::default();
669        config.to_file(&config_path, "json").unwrap();
670
671        let reloader =
672            ConfigHotReloader::new(config, config_path.clone(), Duration::from_secs(1)).unwrap();
673
674        // Remove the file to cause metadata error
675        std::fs::remove_file(&config_path).unwrap();
676
677        // This should handle the error gracefully
678        let result = reloader.reload_now().await;
679        assert!(result.is_err());
680    }
681
682    #[tokio::test]
683    async fn test_config_hot_reloader_with_handler_start() {
684        let temp_file = NamedTempFile::new().unwrap();
685        let config_path = temp_file.path().with_extension("json");
686
687        let config = McpServerConfig::default();
688        config.to_file(&config_path, "json").unwrap();
689
690        let handler = Arc::new(DefaultConfigChangeHandler);
691        let reloader =
692            ConfigHotReloaderWithHandler::new(config, config_path, Duration::from_secs(1), handler)
693                .unwrap();
694
695        // Start with handler should succeed
696        let result = reloader.start_with_handler();
697        assert!(result.is_ok());
698    }
699
700    #[tokio::test]
701    async fn test_config_hot_reloader_file_modified_time() {
702        let temp_file = NamedTempFile::new().unwrap();
703        let config_path = temp_file.path().with_extension("json");
704
705        let config = McpServerConfig::default();
706        config.to_file(&config_path, "json").unwrap();
707
708        // Test getting file modified time
709        let modified_time = ConfigHotReloader::get_file_modified_time(&config_path);
710        assert!(modified_time.is_ok());
711    }
712
713    #[tokio::test]
714    async fn test_config_hot_reloader_file_modified_time_nonexistent() {
715        let config_path = PathBuf::from("/nonexistent/file.json");
716
717        // Test getting file modified time for nonexistent file
718        let result = ConfigHotReloader::get_file_modified_time(&config_path);
719        assert!(result.is_err());
720    }
721
722    #[tokio::test]
723    async fn test_config_hot_reloader_config_path() {
724        let temp_file = NamedTempFile::new().unwrap();
725        let config_path = temp_file.path().with_extension("json");
726
727        let config = McpServerConfig::default();
728        config.to_file(&config_path, "json").unwrap();
729
730        let reloader =
731            ConfigHotReloader::new(config, config_path.clone(), Duration::from_secs(1)).unwrap();
732
733        assert_eq!(reloader.config_path(), &config_path);
734    }
735}