""" Runtime Configuration Validator for NFOGuard Provides validation of configuration at runtime with health checks """ import asyncio import time from pathlib import Path from typing import Dict, List, Any, Optional, Tuple from dataclasses import dataclass, field from contextlib import asynccontextmanager import aiohttp import sqlite3 import psycopg2 from config.validator import ValidationIssue, ValidationResult, ValidationSeverity from utils.exceptions import ConfigurationError, NetworkRetryableError, DatabaseError @dataclass class HealthCheckResult: """Result of a runtime health check""" component: str is_healthy: bool response_time_ms: Optional[float] = None message: str = "" details: Dict[str, Any] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: return { "component": self.component, "is_healthy": self.is_healthy, "response_time_ms": self.response_time_ms, "message": self.message, "details": self.details, "timestamp": time.time() } class RuntimeValidator: """Runtime configuration and system health validator""" def __init__(self, config): self.config = config self._health_cache = {} self._cache_ttl = 60 # Cache health results for 60 seconds async def validate_runtime_config(self) -> ValidationResult: """Perform runtime validation of configuration""" result = ValidationResult(is_valid=True) # Validate filesystem access await self._validate_filesystem_access(result) # Validate database connectivity await self._validate_database_connectivity(result) # Validate external API connectivity await self._validate_api_connectivity(result) # Validate permissions await self._validate_permissions(result) # Validate resource availability await self._validate_resources(result) return result async def _validate_filesystem_access(self, result: ValidationResult) -> None: """Validate filesystem access for media paths""" all_paths = [] # Collect all media paths all_paths.extend(self.config.tv_paths) all_paths.extend(self.config.movie_paths) for path in all_paths: try: # Check if path exists and is accessible if not path.exists(): result.add_issue(ValidationIssue( setting="media_paths", severity=ValidationSeverity.ERROR, message=f"Media path does not exist: {path}", current_value=str(path) )) continue # Check if path is readable test_file = None try: # Try to list directory contents list(path.iterdir()) except PermissionError: result.add_issue(ValidationIssue( setting="media_paths", severity=ValidationSeverity.ERROR, message=f"No read permission for media path: {path}", current_value=str(path) )) continue except OSError as e: result.add_issue(ValidationIssue( setting="media_paths", severity=ValidationSeverity.WARNING, message=f"Error accessing media path: {e}", current_value=str(path) )) continue # Check write permissions (needed for NFO files) try: test_file = path / ".nfoguard_write_test" test_file.write_text("test") test_file.unlink() except PermissionError: result.add_issue(ValidationIssue( setting="media_paths", severity=ValidationSeverity.ERROR, message=f"No write permission for media path: {path}", current_value=str(path), details={"required_for": "NFO file creation"} )) except OSError as e: result.add_issue(ValidationIssue( setting="media_paths", severity=ValidationSeverity.WARNING, message=f"Write test failed for media path: {e}", current_value=str(path) )) finally: # Cleanup test file if it exists if test_file and test_file.exists(): try: test_file.unlink() except: pass except Exception as e: result.add_issue(ValidationIssue( setting="media_paths", severity=ValidationSeverity.ERROR, message=f"Unexpected error accessing path: {e}", current_value=str(path) )) # Validate database directory db_path = Path(self.config.db_path) db_dir = db_path.parent if not db_dir.exists(): try: db_dir.mkdir(parents=True, exist_ok=True) except PermissionError: result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.ERROR, message=f"Cannot create database directory: {db_dir}", current_value=str(db_path) )) except OSError as e: result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.ERROR, message=f"Error creating database directory: {e}", current_value=str(db_path) )) # Test database file access if not db_path.exists(): try: # Try to create the database with sqlite3.connect(str(db_path)) as conn: conn.execute("SELECT 1") except PermissionError: result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.ERROR, message=f"Cannot create database file: {db_path}", current_value=str(db_path) )) except sqlite3.Error as e: result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.ERROR, message=f"Database error: {e}", current_value=str(db_path) )) async def _validate_database_connectivity(self, result: ValidationResult) -> None: """Validate database connectivity""" # Test Radarr database if configured if hasattr(self.config, 'db_type') or 'RADARR_DB_TYPE' in os.environ: import os db_type = getattr(self.config, 'db_type', os.environ.get('RADARR_DB_TYPE', '')).lower() if db_type == 'postgresql': await self._test_postgresql_connection(result) elif db_type == 'sqlite': await self._test_sqlite_connection(result) # Test local SQLite database await self._test_local_database(result) async def _test_postgresql_connection(self, result: ValidationResult) -> None: """Test PostgreSQL connection""" import os try: host = os.environ.get('RADARR_DB_HOST') port = int(os.environ.get('RADARR_DB_PORT', 5432)) database = os.environ.get('RADARR_DB_NAME') user = os.environ.get('RADARR_DB_USER') password = os.environ.get('RADARR_DB_PASSWORD', '') start_time = time.time() # Use asyncio to run blocking DB call def test_connection(): conn = psycopg2.connect( host=host, port=port, database=database, user=user, password=password, connect_timeout=10 ) with conn: with conn.cursor() as cur: cur.execute("SELECT 1") return conn conn = await asyncio.get_event_loop().run_in_executor(None, test_connection) conn.close() response_time = (time.time() - start_time) * 1000 if response_time > 5000: # 5 seconds result.add_issue(ValidationIssue( setting="RADARR_DB", severity=ValidationSeverity.WARNING, message=f"Slow database connection ({response_time:.0f}ms)", details={"response_time_ms": response_time} )) except psycopg2.OperationalError as e: result.add_issue(ValidationIssue( setting="RADARR_DB", severity=ValidationSeverity.ERROR, message=f"Cannot connect to PostgreSQL database: {e}", details={"error_type": "connection_failed"} )) except Exception as e: result.add_issue(ValidationIssue( setting="RADARR_DB", severity=ValidationSeverity.ERROR, message=f"Database connection error: {e}", details={"error_type": "unexpected_error"} )) async def _test_local_database(self, result: ValidationResult) -> None: """Test local SQLite database""" try: db_path = Path(self.config.db_path) start_time = time.time() def test_db(): with sqlite3.connect(str(db_path), timeout=10) as conn: conn.execute("SELECT 1") return True await asyncio.get_event_loop().run_in_executor(None, test_db) response_time = (time.time() - start_time) * 1000 if response_time > 1000: # 1 second is slow for SQLite result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.WARNING, message=f"Slow local database access ({response_time:.0f}ms)", current_value=str(db_path), details={"response_time_ms": response_time} )) except sqlite3.OperationalError as e: result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.ERROR, message=f"Local database error: {e}", current_value=str(self.config.db_path) )) async def _validate_api_connectivity(self, result: ValidationResult) -> None: """Validate external API connectivity""" apis = [] if hasattr(self.config, 'radarr_url') and self.config.radarr_url: apis.append(("Radarr", self.config.radarr_url)) if hasattr(self.config, 'sonarr_url') and self.config.sonarr_url: apis.append(("Sonarr", self.config.sonarr_url)) for api_name, base_url in apis: await self._test_api_connectivity(result, api_name, base_url) async def _test_api_connectivity(self, result: ValidationResult, api_name: str, base_url: str) -> None: """Test connectivity to a specific API""" try: timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession(timeout=timeout) as session: start_time = time.time() # Test basic connectivity test_url = f"{base_url.rstrip('/')}/api/v1/health" async with session.get(test_url) as response: response_time = (time.time() - start_time) * 1000 if response.status == 200: if response_time > 5000: # 5 seconds result.add_issue(ValidationIssue( setting=f"{api_name.upper()}_URL", severity=ValidationSeverity.WARNING, message=f"Slow {api_name} API response ({response_time:.0f}ms)", current_value=base_url, details={"response_time_ms": response_time} )) else: result.add_issue(ValidationIssue( setting=f"{api_name.upper()}_URL", severity=ValidationSeverity.WARNING, message=f"{api_name} API returned HTTP {response.status}", current_value=base_url, details={"status_code": response.status} )) except asyncio.TimeoutError: result.add_issue(ValidationIssue( setting=f"{api_name.upper()}_URL", severity=ValidationSeverity.WARNING, message=f"{api_name} API connection timeout", current_value=base_url, details={"error_type": "timeout"} )) except Exception as e: result.add_issue(ValidationIssue( setting=f"{api_name.upper()}_URL", severity=ValidationSeverity.WARNING, message=f"{api_name} API connection error: {e}", current_value=base_url, details={"error_type": "connection_error"} )) async def _validate_permissions(self, result: ValidationResult) -> None: """Validate file system permissions""" # This is partially covered in filesystem validation # Additional permission checks can be added here pass async def _validate_resources(self, result: ValidationResult) -> None: """Validate system resources""" import shutil try: # Check disk space for database directory db_path = Path(self.config.db_path) db_dir = db_path.parent if not db_path.is_dir() else db_path if db_dir.exists(): free_space = shutil.disk_usage(str(db_dir)).free free_space_mb = free_space / (1024 * 1024) if free_space_mb < 100: # Less than 100MB result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.ERROR, message=f"Low disk space in database directory ({free_space_mb:.1f}MB free)", current_value=str(db_path), details={"free_space_mb": free_space_mb} )) elif free_space_mb < 1000: # Less than 1GB result.add_issue(ValidationIssue( setting="DB_PATH", severity=ValidationSeverity.WARNING, message=f"Low disk space in database directory ({free_space_mb:.1f}MB free)", current_value=str(db_path), details={"free_space_mb": free_space_mb} )) except Exception as e: result.add_issue(ValidationIssue( setting="system_resources", severity=ValidationSeverity.WARNING, message=f"Could not check disk space: {e}", details={"error_type": "resource_check_failed"} )) async def get_system_health(self) -> Dict[str, HealthCheckResult]: """Get comprehensive system health status""" health_checks = {} # Database health health_checks["database"] = await self._check_database_health() # Filesystem health health_checks["filesystem"] = await self._check_filesystem_health() # API health health_checks["external_apis"] = await self._check_api_health() return health_checks async def _check_database_health(self) -> HealthCheckResult: """Check database health""" try: start_time = time.time() def test_db(): with sqlite3.connect(str(self.config.db_path), timeout=5) as conn: conn.execute("SELECT COUNT(*) FROM sqlite_master") return True await asyncio.get_event_loop().run_in_executor(None, test_db) response_time = (time.time() - start_time) * 1000 return HealthCheckResult( component="database", is_healthy=True, response_time_ms=response_time, message="Database accessible", details={"db_path": str(self.config.db_path)} ) except Exception as e: return HealthCheckResult( component="database", is_healthy=False, message=f"Database error: {e}", details={"db_path": str(self.config.db_path), "error": str(e)} ) async def _check_filesystem_health(self) -> HealthCheckResult: """Check filesystem health""" try: accessible_paths = 0 total_paths = len(self.config.tv_paths) + len(self.config.movie_paths) for path in list(self.config.tv_paths) + list(self.config.movie_paths): if path.exists() and path.is_dir(): try: # Quick access test next(path.iterdir(), None) accessible_paths += 1 except: pass is_healthy = accessible_paths == total_paths health_percentage = (accessible_paths / total_paths * 100) if total_paths > 0 else 0 return HealthCheckResult( component="filesystem", is_healthy=is_healthy, message=f"{accessible_paths}/{total_paths} media paths accessible ({health_percentage:.1f}%)", details={ "accessible_paths": accessible_paths, "total_paths": total_paths, "health_percentage": health_percentage } ) except Exception as e: return HealthCheckResult( component="filesystem", is_healthy=False, message=f"Filesystem check error: {e}", details={"error": str(e)} ) async def _check_api_health(self) -> HealthCheckResult: """Check external API health""" apis_tested = 0 apis_healthy = 0 api_details = {} try: # Test configured APIs if hasattr(self.config, 'radarr_url') and self.config.radarr_url: apis_tested += 1 healthy = await self._test_single_api("Radarr", self.config.radarr_url) if healthy: apis_healthy += 1 api_details["radarr"] = {"healthy": healthy} if hasattr(self.config, 'sonarr_url') and self.config.sonarr_url: apis_tested += 1 healthy = await self._test_single_api("Sonarr", self.config.sonarr_url) if healthy: apis_healthy += 1 api_details["sonarr"] = {"healthy": healthy} if apis_tested == 0: return HealthCheckResult( component="external_apis", is_healthy=True, message="No external APIs configured", details={"apis_configured": 0} ) is_healthy = apis_healthy == apis_tested health_percentage = (apis_healthy / apis_tested * 100) if apis_tested > 0 else 0 return HealthCheckResult( component="external_apis", is_healthy=is_healthy, message=f"{apis_healthy}/{apis_tested} APIs healthy ({health_percentage:.1f}%)", details={ "healthy_apis": apis_healthy, "total_apis": apis_tested, "health_percentage": health_percentage, "api_status": api_details } ) except Exception as e: return HealthCheckResult( component="external_apis", is_healthy=False, message=f"API health check error: {e}", details={"error": str(e)} ) async def _test_single_api(self, api_name: str, base_url: str) -> bool: """Test a single API for health""" try: timeout = aiohttp.ClientTimeout(total=5) async with aiohttp.ClientSession(timeout=timeout) as session: test_url = f"{base_url.rstrip('/')}/api/v1/health" async with session.get(test_url) as response: return response.status == 200 except: return False # Import needed for database validation import os