"""Configuration persistence service for managing application settings. This service handles: - Loading and saving configuration to JSON files - Configuration validation - Backup and restore functionality - Configuration version management """ import json import logging import shutil from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult logger = logging.getLogger(__name__) class ConfigServiceError(Exception): """Base exception for configuration service errors.""" class ConfigNotFoundError(ConfigServiceError): """Raised when configuration file is not found.""" class ConfigValidationError(ConfigServiceError): """Raised when configuration validation fails.""" class ConfigBackupError(ConfigServiceError): """Raised when backup operations fail.""" class ConfigService: """Service for managing application configuration persistence. Handles loading, saving, validation, backup, and version management of configuration files. Uses JSON format for human-readable and version-control friendly storage. """ # Current configuration schema version CONFIG_VERSION = "1.0.1" def __init__( self, config_path: Path = Path("data/config.json"), backup_dir: Path = Path("data/config_backups"), max_backups: int = 10 ): """Initialize configuration service. Args: config_path: Path to main configuration file backup_dir: Directory for storing configuration backups max_backups: Maximum number of backups to keep """ self.config_path = config_path self.backup_dir = backup_dir self.max_backups = max_backups # Ensure directories exist self.config_path.parent.mkdir(parents=True, exist_ok=True) self.backup_dir.mkdir(parents=True, exist_ok=True) def load_config(self) -> AppConfig: """Load configuration from file. Returns: AppConfig: Loaded configuration Raises: ConfigNotFoundError: If config file doesn't exist ConfigValidationError: If config validation fails """ if not self.config_path.exists(): # Create default configuration default_config = self._create_default_config() self.save_config(default_config) return default_config try: with open(self.config_path, "r", encoding="utf-8") as f: data = json.load(f) # Remove version key before constructing AppConfig data.pop("version", None) config = AppConfig(**data) # Validate configuration validation = config.validate_config() if not validation.valid: errors = ', '.join(validation.errors or []) raise ConfigValidationError( f"Invalid configuration: {errors}" ) return config except json.JSONDecodeError as e: raise ConfigValidationError( f"Invalid JSON in config file: {e}" ) from e except Exception as e: if isinstance(e, ConfigServiceError): raise raise ConfigValidationError( f"Failed to load config: {e}" ) from e def save_config( self, config: AppConfig, create_backup: bool = True ) -> None: """Save configuration to file. Args: config: Configuration to save create_backup: Whether to create backup before saving Raises: ConfigValidationError: If config validation fails """ # Validate before saving validation = config.validate_config() if not validation.valid: errors = ', '.join(validation.errors or []) raise ConfigValidationError( f"Cannot save invalid configuration: {errors}" ) # Create backup if requested and file exists if create_backup and self.config_path.exists(): try: self.create_backup() except ConfigBackupError as e: # Log but don't fail save operation logger.warning("Failed to create backup: %s", e) # Save configuration with version data = config.model_dump() data["version"] = self.CONFIG_VERSION # Re-serialize SchedulerConfig through its overridden model_dump so # that None legacy alias fields are stripped before writing to disk. # Pydantic converts nested models to plain dicts in model_dump() output, # so we call the override explicitly on the scheduler field. data["scheduler"] = config.scheduler.model_dump() # Write to temporary file first for atomic operation temp_path = self.config_path.with_suffix(".tmp") try: with open(temp_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) # Atomic replace temp_path.replace(self.config_path) except Exception as e: # Clean up temp file on error if temp_path.exists(): temp_path.unlink() raise ConfigServiceError(f"Failed to save config: {e}") from e def update_config(self, update: ConfigUpdate) -> AppConfig: """Update configuration with partial changes. Args: update: Configuration update to apply Returns: AppConfig: Updated configuration """ current = self.load_config() updated = update.apply_to(current) self.save_config(updated) return updated def validate_config(self, config: AppConfig) -> ValidationResult: """Validate configuration without saving. Args: config: Configuration to validate Returns: ValidationResult: Validation result with errors if any """ return config.validate_config() def create_backup(self, name: Optional[str] = None) -> Path: """Create backup of current configuration. Args: name: Optional custom backup name (timestamp used if not provided) Returns: Path: Path to created backup file Raises: ConfigBackupError: If backup creation fails """ if not self.config_path.exists(): raise ConfigBackupError("Cannot backup non-existent config file") # Generate backup filename if name is None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") name = f"config_backup_{timestamp}.json" elif not name.endswith(".json"): name = f"{name}.json" backup_path = self.backup_dir / name try: shutil.copy2(self.config_path, backup_path) # Clean up old backups self._cleanup_old_backups() return backup_path except Exception as e: raise ConfigBackupError(f"Failed to create backup: {e}") from e def restore_backup(self, backup_name: str) -> AppConfig: """Restore configuration from backup. Args: backup_name: Name of backup file to restore Returns: AppConfig: Restored configuration Raises: ConfigBackupError: If restore fails """ backup_path = self.backup_dir / backup_name if not backup_path.exists(): raise ConfigBackupError(f"Backup not found: {backup_name}") try: # Create backup of current config before restoring if self.config_path.exists(): self.create_backup("pre_restore") # Restore backup shutil.copy2(backup_path, self.config_path) # Load and validate restored config return self.load_config() except Exception as e: raise ConfigBackupError( f"Failed to restore backup: {e}" ) from e def list_backups(self) -> List[Dict[str, object]]: """List available configuration backups. Returns: List of backup metadata dictionaries with name, size, and created timestamp """ backups: List[Dict[str, object]] = [] if not self.backup_dir.exists(): return backups for backup_file in sorted( self.backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True ): stat = backup_file.stat() created_timestamp = datetime.fromtimestamp(stat.st_mtime) backups.append({ "name": backup_file.name, "size_bytes": stat.st_size, "created_at": created_timestamp.isoformat(), }) return backups def delete_backup(self, backup_name: str) -> None: """Delete a configuration backup. Args: backup_name: Name of backup file to delete Raises: ConfigBackupError: If deletion fails """ backup_path = self.backup_dir / backup_name if not backup_path.exists(): raise ConfigBackupError(f"Backup not found: {backup_name}") try: backup_path.unlink() except OSError as e: raise ConfigBackupError(f"Failed to delete backup: {e}") from e def _create_default_config(self) -> AppConfig: """Create default configuration. Returns: AppConfig: Default configuration """ return AppConfig() def _cleanup_old_backups(self) -> None: """Remove old backups exceeding max_backups limit.""" if not self.backup_dir.exists(): return # Get all backups sorted by modification time (oldest first) backups = sorted( self.backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime ) # Remove oldest backups if limit exceeded while len(backups) > self.max_backups: oldest = backups.pop(0) try: oldest.unlink() except (OSError, IOError): # Ignore errors during cleanup continue # Singleton instance _config_service: Optional[ConfigService] = None def get_config_service() -> ConfigService: """Get singleton ConfigService instance. Returns: ConfigService: Singleton instance """ global _config_service if _config_service is None: _config_service = ConfigService() return _config_service