Files
nfoguard/config/runtime_validator.py
T
2025-09-29 08:59:47 -04:00

547 lines
22 KiB
Python

"""
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