Merge pull request 'feat: add config validation' (#20) from improvments into dev
Local Docker Build (Dev) / build-dev (push) Successful in 9s
Local Docker Build (Dev) / build-dev (push) Successful in 9s
Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,547 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
+239
-21
@@ -1,10 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
NFOGuard Configuration Module
|
NFOGuard Configuration Module
|
||||||
Handles all configuration loading and validation
|
Handles all configuration loading and validation with comprehensive error reporting
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
from utils.exceptions import ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _bool_env(name: str, default: bool) -> bool:
|
def _bool_env(name: str, default: bool) -> bool:
|
||||||
@@ -16,20 +23,30 @@ def _bool_env(name: str, default: bool) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class NFOGuardConfig:
|
class NFOGuardConfig:
|
||||||
"""Configuration class for NFOGuard"""
|
"""Configuration class for NFOGuard with integrated validation"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, validate_on_init: bool = True, strict_validation: bool = False):
|
||||||
# Paths - No hardcoded defaults, must be configured via environment
|
"""
|
||||||
tv_paths_env = os.environ.get("TV_PATHS", "")
|
Initialize NFOGuard configuration
|
||||||
movie_paths_env = os.environ.get("MOVIE_PATHS", "")
|
|
||||||
|
|
||||||
if not tv_paths_env:
|
Args:
|
||||||
raise ValueError("TV_PATHS environment variable is required but not set")
|
validate_on_init: Run validation during initialization
|
||||||
if not movie_paths_env:
|
strict_validation: Treat warnings as errors
|
||||||
raise ValueError("MOVIE_PATHS environment variable is required but not set")
|
"""
|
||||||
|
self.strict_validation = strict_validation
|
||||||
|
self._validation_issues = []
|
||||||
|
|
||||||
self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()]
|
# Initialize configuration
|
||||||
self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()]
|
self._load_configuration()
|
||||||
|
|
||||||
|
# Run validation if requested
|
||||||
|
if validate_on_init:
|
||||||
|
self._validate_configuration()
|
||||||
|
|
||||||
|
def _load_configuration(self) -> None:
|
||||||
|
"""Load all configuration from environment variables"""
|
||||||
|
# Core paths - Required
|
||||||
|
self._load_paths()
|
||||||
|
|
||||||
# Core settings
|
# Core settings
|
||||||
self.manage_nfo = _bool_env("MANAGE_NFO", True)
|
self.manage_nfo = _bool_env("MANAGE_NFO", True)
|
||||||
@@ -38,28 +55,229 @@ class NFOGuardConfig:
|
|||||||
self.debug = _bool_env("DEBUG", False)
|
self.debug = _bool_env("DEBUG", False)
|
||||||
self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
|
self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
|
||||||
|
|
||||||
# Batching
|
# Batching and performance
|
||||||
self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0"))
|
self.batch_delay = self._get_float_env("BATCH_DELAY", 5.0, 0.1, 300.0)
|
||||||
self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3"))
|
self.max_concurrent = self._get_int_env("MAX_CONCURRENT_SERIES", 3, 1, 10)
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
|
self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
|
||||||
|
|
||||||
|
# External connections
|
||||||
|
self._load_external_connections()
|
||||||
|
|
||||||
# Movie processing
|
# Movie processing
|
||||||
|
self._load_movie_settings()
|
||||||
|
|
||||||
|
# TV processing
|
||||||
|
self._load_tv_settings()
|
||||||
|
|
||||||
|
def _load_paths(self) -> None:
|
||||||
|
"""Load and validate path configuration"""
|
||||||
|
tv_paths_env = os.environ.get("TV_PATHS", "")
|
||||||
|
movie_paths_env = os.environ.get("MOVIE_PATHS", "")
|
||||||
|
|
||||||
|
if not tv_paths_env:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="TV_PATHS",
|
||||||
|
reason="TV_PATHS environment variable is required but not set"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not movie_paths_env:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="MOVIE_PATHS",
|
||||||
|
reason="MOVIE_PATHS environment variable is required but not set"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse paths
|
||||||
|
self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()]
|
||||||
|
self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()]
|
||||||
|
|
||||||
|
if not self.tv_paths:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="TV_PATHS",
|
||||||
|
reason="No valid TV paths found after parsing",
|
||||||
|
current_value=tv_paths_env
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.movie_paths:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="MOVIE_PATHS",
|
||||||
|
reason="No valid movie paths found after parsing",
|
||||||
|
current_value=movie_paths_env
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_external_connections(self) -> None:
|
||||||
|
"""Load external API and database connection settings"""
|
||||||
|
# API URLs
|
||||||
|
self.radarr_url = os.environ.get("RADARR_URL", "")
|
||||||
|
self.sonarr_url = os.environ.get("SONARR_URL", "")
|
||||||
|
self.jellyseerr_url = os.environ.get("JELLYSEERR_URL", "")
|
||||||
|
|
||||||
|
# Database settings
|
||||||
|
self.db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||||
|
self.db_host = os.environ.get("RADARR_DB_HOST", "")
|
||||||
|
self.db_port = self._get_int_env("RADARR_DB_PORT", 5432, 1, 65535)
|
||||||
|
self.db_name = os.environ.get("RADARR_DB_NAME", "")
|
||||||
|
self.db_user = os.environ.get("RADARR_DB_USER", "")
|
||||||
|
|
||||||
|
# Timeout settings
|
||||||
|
self.timeout_seconds = self._get_int_env("TIMEOUT_SECONDS", 45, 10, 300)
|
||||||
|
|
||||||
|
def _load_movie_settings(self) -> None:
|
||||||
|
"""Load movie processing settings"""
|
||||||
self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower()
|
self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower()
|
||||||
self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True)
|
self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True)
|
||||||
self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False)
|
self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False)
|
||||||
self.release_date_priority = [p.strip() for p in os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical").split(",")]
|
|
||||||
|
# Release date settings
|
||||||
|
release_priority_env = os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical")
|
||||||
|
self.release_date_priority = [p.strip() for p in release_priority_env.split(",") if p.strip()]
|
||||||
|
|
||||||
self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True)
|
self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True)
|
||||||
self.max_release_date_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10"))
|
self.max_release_date_gap_years = self._get_int_env("MAX_RELEASE_DATE_GAP_YEARS", 10, 1, 50)
|
||||||
self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower()
|
self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower()
|
||||||
self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower()
|
self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower()
|
||||||
|
|
||||||
# TV processing
|
def _load_tv_settings(self) -> None:
|
||||||
|
"""Load TV processing settings"""
|
||||||
self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
|
self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
|
||||||
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
||||||
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
|
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
|
||||||
|
|
||||||
|
def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int:
|
||||||
|
"""Get integer environment variable with validation"""
|
||||||
|
value_str = os.environ.get(name)
|
||||||
|
if not value_str:
|
||||||
|
return default
|
||||||
|
|
||||||
# Global config instance
|
try:
|
||||||
config = NFOGuardConfig()
|
value = int(value_str)
|
||||||
|
if value < min_val or value > max_val:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Value must be between {min_val} and {max_val}",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Invalid integer value",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_float_env(self, name: str, default: float, min_val: float, max_val: float) -> float:
|
||||||
|
"""Get float environment variable with validation"""
|
||||||
|
value_str = os.environ.get(name)
|
||||||
|
if not value_str:
|
||||||
|
return default
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = float(value_str)
|
||||||
|
if value < min_val or value > max_val:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Value must be between {min_val} and {max_val}",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting=name,
|
||||||
|
reason=f"Invalid float value",
|
||||||
|
current_value=value_str
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_configuration(self) -> None:
|
||||||
|
"""Validate configuration using the validator"""
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from config.validator import validate_configuration_and_raise
|
||||||
|
validate_configuration_and_raise()
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Fallback to basic validation if validator not available
|
||||||
|
logger.warning("Configuration validator not available, using basic validation")
|
||||||
|
self._basic_validation()
|
||||||
|
except ConfigurationError:
|
||||||
|
if self.strict_validation:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Log warning but continue
|
||||||
|
logger.warning("Configuration validation found issues", exc_info=True)
|
||||||
|
|
||||||
|
def _basic_validation(self) -> None:
|
||||||
|
"""Basic fallback validation"""
|
||||||
|
# Validate that paths exist (basic check)
|
||||||
|
for path_list, path_type in [(self.tv_paths, "TV"), (self.movie_paths, "Movie")]:
|
||||||
|
for path in path_list:
|
||||||
|
if not path.is_absolute():
|
||||||
|
logger.warning(f"{path_type} path should be absolute: {path}")
|
||||||
|
|
||||||
|
def get_configuration_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Get a summary of current configuration"""
|
||||||
|
return {
|
||||||
|
"tv_paths": [str(p) for p in self.tv_paths],
|
||||||
|
"movie_paths": [str(p) for p in self.movie_paths],
|
||||||
|
"database_path": str(self.db_path),
|
||||||
|
"external_apis": {
|
||||||
|
"radarr": bool(self.radarr_url),
|
||||||
|
"sonarr": bool(self.sonarr_url),
|
||||||
|
"jellyseerr": bool(self.jellyseerr_url)
|
||||||
|
},
|
||||||
|
"database_connection": {
|
||||||
|
"type": self.db_type,
|
||||||
|
"configured": bool(self.db_type and self.db_host)
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"batch_delay": self.batch_delay,
|
||||||
|
"max_concurrent": self.max_concurrent,
|
||||||
|
"timeout_seconds": self.timeout_seconds
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"manage_nfo": self.manage_nfo,
|
||||||
|
"fix_dir_mtimes": self.fix_dir_mtimes,
|
||||||
|
"lock_metadata": self.lock_metadata,
|
||||||
|
"debug": self.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_runtime_access(self) -> Dict[str, bool]:
|
||||||
|
"""Quick runtime validation of critical paths"""
|
||||||
|
results = {
|
||||||
|
"tv_paths_accessible": True,
|
||||||
|
"movie_paths_accessible": True,
|
||||||
|
"database_writable": True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test TV paths
|
||||||
|
for path in self.tv_paths:
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
results["tv_paths_accessible"] = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Test movie paths
|
||||||
|
for path in self.movie_paths:
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
results["movie_paths_accessible"] = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Test database directory
|
||||||
|
db_dir = self.db_path.parent
|
||||||
|
try:
|
||||||
|
if not db_dir.exists():
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Test write access
|
||||||
|
test_file = db_dir / ".nfoguard_write_test"
|
||||||
|
test_file.write_text("test")
|
||||||
|
test_file.unlink()
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
results["database_writable"] = False
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Global config instance - Initialize with validation disabled by default for backwards compatibility
|
||||||
|
# Applications can enable validation by creating their own instance with validate_on_init=True
|
||||||
|
config = NFOGuardConfig(validate_on_init=False)
|
||||||
Executable
+293
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Configuration Validation CLI for NFOGuard
|
||||||
|
Provides command-line validation and reporting of configuration issues
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config.validator import validate_configuration, ValidationSeverity
|
||||||
|
from config.runtime_validator import RuntimeValidator
|
||||||
|
from config.settings import NFOGuardConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationReporter:
|
||||||
|
"""Formats and displays validation results"""
|
||||||
|
|
||||||
|
def __init__(self, verbose: bool = False, json_output: bool = False):
|
||||||
|
self.verbose = verbose
|
||||||
|
self.json_output = json_output
|
||||||
|
|
||||||
|
# Color codes for terminal output
|
||||||
|
self.colors = {
|
||||||
|
'error': '\033[91m', # Red
|
||||||
|
'warning': '\033[93m', # Yellow
|
||||||
|
'info': '\033[94m', # Blue
|
||||||
|
'success': '\033[92m', # Green
|
||||||
|
'reset': '\033[0m', # Reset
|
||||||
|
'bold': '\033[1m' # Bold
|
||||||
|
}
|
||||||
|
|
||||||
|
def report_validation_results(self, result, runtime_result=None) -> int:
|
||||||
|
"""
|
||||||
|
Report validation results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code (0 for success, 1 for warnings, 2 for errors)
|
||||||
|
"""
|
||||||
|
if self.json_output:
|
||||||
|
return self._report_json(result, runtime_result)
|
||||||
|
else:
|
||||||
|
return self._report_human_readable(result, runtime_result)
|
||||||
|
|
||||||
|
def _report_json(self, result, runtime_result=None) -> int:
|
||||||
|
"""Report results in JSON format"""
|
||||||
|
output = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"validation": result.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime_result:
|
||||||
|
output["runtime_validation"] = runtime_result.to_dict()
|
||||||
|
|
||||||
|
print(json.dumps(output, indent=2))
|
||||||
|
|
||||||
|
if result.errors_count > 0:
|
||||||
|
return 2
|
||||||
|
elif result.warnings_count > 0:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _report_human_readable(self, result, runtime_result=None) -> int:
|
||||||
|
"""Report results in human-readable format"""
|
||||||
|
print(f"{self.colors['bold']}NFOGuard Configuration Validation Report{self.colors['reset']}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Overall status
|
||||||
|
if result.is_valid:
|
||||||
|
status_color = self.colors['success']
|
||||||
|
status_text = "✓ VALID"
|
||||||
|
else:
|
||||||
|
status_color = self.colors['error']
|
||||||
|
status_text = "✗ INVALID"
|
||||||
|
|
||||||
|
print(f"Status: {status_color}{status_text}{self.colors['reset']}")
|
||||||
|
print(f"Errors: {result.errors_count}")
|
||||||
|
print(f"Warnings: {result.warnings_count}")
|
||||||
|
print(f"Total Issues: {len(result.issues)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Report issues by severity
|
||||||
|
if result.issues:
|
||||||
|
self._report_issues_by_severity(result.issues)
|
||||||
|
|
||||||
|
# Report runtime validation if available
|
||||||
|
if runtime_result:
|
||||||
|
print(f"\n{self.colors['bold']}Runtime Validation{self.colors['reset']}")
|
||||||
|
print("-" * 20)
|
||||||
|
if runtime_result.issues:
|
||||||
|
self._report_issues_by_severity(runtime_result.issues, "Runtime")
|
||||||
|
else:
|
||||||
|
print(f"{self.colors['success']}✓ All runtime checks passed{self.colors['reset']}")
|
||||||
|
|
||||||
|
# Summary and recommendations
|
||||||
|
self._report_summary_and_recommendations(result)
|
||||||
|
|
||||||
|
# Return appropriate exit code
|
||||||
|
if result.errors_count > 0 or (runtime_result and runtime_result.errors_count > 0):
|
||||||
|
return 2
|
||||||
|
elif result.warnings_count > 0 or (runtime_result and runtime_result.warnings_count > 0):
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _report_issues_by_severity(self, issues: List, context: str = "Configuration") -> None:
|
||||||
|
"""Report issues grouped by severity"""
|
||||||
|
errors = [issue for issue in issues if issue.severity == ValidationSeverity.ERROR]
|
||||||
|
warnings = [issue for issue in issues if issue.severity == ValidationSeverity.WARNING]
|
||||||
|
info_issues = [issue for issue in issues if issue.severity == ValidationSeverity.INFO]
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"{self.colors['error']}{self.colors['bold']}ERRORS ({len(errors)}):{self.colors['reset']}")
|
||||||
|
for issue in errors:
|
||||||
|
self._format_issue(issue)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
print(f"{self.colors['warning']}{self.colors['bold']}WARNINGS ({len(warnings)}):{self.colors['reset']}")
|
||||||
|
for issue in warnings:
|
||||||
|
self._format_issue(issue)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if info_issues and self.verbose:
|
||||||
|
print(f"{self.colors['info']}{self.colors['bold']}INFO ({len(info_issues)}):{self.colors['reset']}")
|
||||||
|
for issue in info_issues:
|
||||||
|
self._format_issue(issue)
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _format_issue(self, issue) -> None:
|
||||||
|
"""Format a single validation issue"""
|
||||||
|
severity_colors = {
|
||||||
|
ValidationSeverity.ERROR: self.colors['error'],
|
||||||
|
ValidationSeverity.WARNING: self.colors['warning'],
|
||||||
|
ValidationSeverity.INFO: self.colors['info']
|
||||||
|
}
|
||||||
|
|
||||||
|
color = severity_colors.get(issue.severity, '')
|
||||||
|
|
||||||
|
print(f" {color}• {issue.setting}:{self.colors['reset']} {issue.message}")
|
||||||
|
|
||||||
|
if issue.current_value is not None:
|
||||||
|
print(f" Current: {issue.current_value}")
|
||||||
|
|
||||||
|
if issue.suggested_value is not None:
|
||||||
|
print(f" Suggested: {issue.suggested_value}")
|
||||||
|
|
||||||
|
if self.verbose and issue.details:
|
||||||
|
print(f" Details: {issue.details}")
|
||||||
|
|
||||||
|
def _report_summary_and_recommendations(self, result) -> None:
|
||||||
|
"""Report summary and general recommendations"""
|
||||||
|
print(f"\n{self.colors['bold']}Summary{self.colors['reset']}")
|
||||||
|
print("-" * 10)
|
||||||
|
|
||||||
|
if result.is_valid:
|
||||||
|
print(f"{self.colors['success']}✓ Your configuration is valid and ready to use!{self.colors['reset']}")
|
||||||
|
else:
|
||||||
|
print(f"{self.colors['error']}✗ Your configuration has issues that need to be resolved.{self.colors['reset']}")
|
||||||
|
|
||||||
|
# Provide specific recommendations based on issue types
|
||||||
|
recommendations = self._generate_recommendations(result)
|
||||||
|
if recommendations:
|
||||||
|
print(f"\n{self.colors['bold']}Recommendations{self.colors['reset']}")
|
||||||
|
print("-" * 15)
|
||||||
|
for rec in recommendations:
|
||||||
|
print(f" {self.colors['info']}• {rec}{self.colors['reset']}")
|
||||||
|
|
||||||
|
def _generate_recommendations(self, result) -> List[str]:
|
||||||
|
"""Generate recommendations based on validation results"""
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Check for common patterns
|
||||||
|
path_issues = [issue for issue in result.issues if 'path' in issue.setting.lower()]
|
||||||
|
if path_issues:
|
||||||
|
recommendations.append("Verify all file paths are correct for your environment")
|
||||||
|
recommendations.append("Ensure paths are absolute when using Docker")
|
||||||
|
|
||||||
|
db_issues = [issue for issue in result.issues if 'db' in issue.setting.lower()]
|
||||||
|
if db_issues:
|
||||||
|
recommendations.append("Check database connection settings and credentials")
|
||||||
|
|
||||||
|
url_issues = [issue for issue in result.issues if 'url' in issue.setting.lower()]
|
||||||
|
if url_issues:
|
||||||
|
recommendations.append("Verify API URLs are reachable and include the correct port")
|
||||||
|
|
||||||
|
perf_issues = [issue for issue in result.issues if any(perf in issue.setting.lower()
|
||||||
|
for perf in ['concurrent', 'delay', 'timeout'])]
|
||||||
|
if perf_issues:
|
||||||
|
recommendations.append("Consider adjusting performance settings based on your system resources")
|
||||||
|
|
||||||
|
# General recommendations
|
||||||
|
if result.errors_count > 0:
|
||||||
|
recommendations.append("Fix all ERROR-level issues before starting NFOGuard")
|
||||||
|
|
||||||
|
if result.warnings_count > 0:
|
||||||
|
recommendations.append("Review WARNING-level issues to optimize performance and reliability")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
|
||||||
|
async def run_validation(args) -> int:
|
||||||
|
"""Run configuration validation"""
|
||||||
|
reporter = ValidationReporter(verbose=args.verbose, json_output=args.json)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run static validation
|
||||||
|
print("Running configuration validation..." if not args.json else "", file=sys.stderr)
|
||||||
|
result = validate_configuration()
|
||||||
|
|
||||||
|
runtime_result = None
|
||||||
|
|
||||||
|
# Run runtime validation if requested
|
||||||
|
if args.runtime:
|
||||||
|
print("Running runtime validation..." if not args.json else "", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
config = NFOGuardConfig()
|
||||||
|
runtime_validator = RuntimeValidator(config)
|
||||||
|
runtime_result = await runtime_validator.validate_runtime_config()
|
||||||
|
except Exception as e:
|
||||||
|
if not args.json:
|
||||||
|
print(f"Runtime validation failed: {e}", file=sys.stderr)
|
||||||
|
# Continue with static validation results
|
||||||
|
|
||||||
|
# Report results
|
||||||
|
return reporter.report_validation_results(result, runtime_result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if args.json:
|
||||||
|
error_output = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"error": {
|
||||||
|
"message": str(e),
|
||||||
|
"type": type(e).__name__
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(json.dumps(error_output, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"Validation failed: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main CLI entry point"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate NFOGuard configuration",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s # Basic validation
|
||||||
|
%(prog)s --runtime # Include runtime checks
|
||||||
|
%(prog)s --verbose # Show detailed information
|
||||||
|
%(prog)s --json # Output JSON format
|
||||||
|
%(prog)s --runtime --json # Runtime validation with JSON output
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime",
|
||||||
|
action="store_true",
|
||||||
|
help="Perform runtime validation (tests actual connectivity and permissions)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", "-v",
|
||||||
|
action="store_true",
|
||||||
|
help="Show verbose output including info-level messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output results in JSON format"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Run validation
|
||||||
|
import asyncio
|
||||||
|
try:
|
||||||
|
exit_code = asyncio.run(run_validation(args))
|
||||||
|
sys.exit(exit_code)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Validation interrupted", file=sys.stderr)
|
||||||
|
sys.exit(130)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""
|
||||||
|
Configuration Validator for NFOGuard
|
||||||
|
Provides comprehensive validation of all configuration settings with detailed error reporting
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any, Optional, Union, Type, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from utils.exceptions import ConfigurationError
|
||||||
|
from utils.validation import validate_url_format
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationSeverity(Enum):
|
||||||
|
"""Severity levels for validation issues"""
|
||||||
|
ERROR = "error" # Configuration is invalid, will cause failures
|
||||||
|
WARNING = "warning" # Configuration may cause issues but is workable
|
||||||
|
INFO = "info" # Configuration could be improved
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationIssue:
|
||||||
|
"""Represents a configuration validation issue"""
|
||||||
|
setting: str
|
||||||
|
severity: ValidationSeverity
|
||||||
|
message: str
|
||||||
|
current_value: Any = None
|
||||||
|
suggested_value: Any = None
|
||||||
|
details: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for structured logging"""
|
||||||
|
return {
|
||||||
|
"setting": self.setting,
|
||||||
|
"severity": self.severity.value,
|
||||||
|
"message": self.message,
|
||||||
|
"current_value": str(self.current_value) if self.current_value is not None else None,
|
||||||
|
"suggested_value": str(self.suggested_value) if self.suggested_value is not None else None,
|
||||||
|
"details": self.details
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""Results of configuration validation"""
|
||||||
|
is_valid: bool
|
||||||
|
issues: List[ValidationIssue] = field(default_factory=list)
|
||||||
|
warnings_count: int = 0
|
||||||
|
errors_count: int = 0
|
||||||
|
|
||||||
|
def add_issue(self, issue: ValidationIssue) -> None:
|
||||||
|
"""Add a validation issue"""
|
||||||
|
self.issues.append(issue)
|
||||||
|
if issue.severity == ValidationSeverity.ERROR:
|
||||||
|
self.errors_count += 1
|
||||||
|
self.is_valid = False
|
||||||
|
elif issue.severity == ValidationSeverity.WARNING:
|
||||||
|
self.warnings_count += 1
|
||||||
|
|
||||||
|
def get_errors(self) -> List[ValidationIssue]:
|
||||||
|
"""Get only error-level issues"""
|
||||||
|
return [issue for issue in self.issues if issue.severity == ValidationSeverity.ERROR]
|
||||||
|
|
||||||
|
def get_warnings(self) -> List[ValidationIssue]:
|
||||||
|
"""Get only warning-level issues"""
|
||||||
|
return [issue for issue in self.issues if issue.severity == ValidationSeverity.WARNING]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for structured logging"""
|
||||||
|
return {
|
||||||
|
"is_valid": self.is_valid,
|
||||||
|
"errors_count": self.errors_count,
|
||||||
|
"warnings_count": self.warnings_count,
|
||||||
|
"issues": [issue.to_dict() for issue in self.issues]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidator:
|
||||||
|
"""Comprehensive configuration validator for NFOGuard"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.result = ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
# Define validation rules
|
||||||
|
self._path_settings = {
|
||||||
|
"TV_PATHS", "MOVIE_PATHS", "RADARR_ROOT_FOLDERS",
|
||||||
|
"SONARR_ROOT_FOLDERS", "DB_PATH", "LOG_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._url_settings = {
|
||||||
|
"RADARR_URL", "SONARR_URL", "JELLYSEERR_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._required_settings = {
|
||||||
|
"TV_PATHS", "MOVIE_PATHS"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._numeric_settings = {
|
||||||
|
"BATCH_DELAY": (float, 0.1, 300.0),
|
||||||
|
"MAX_CONCURRENT_SERIES": (int, 1, 10),
|
||||||
|
"TIMEOUT_SECONDS": (int, 10, 300),
|
||||||
|
"PORT": (int, 1024, 65535),
|
||||||
|
"RADARR_DB_PORT": (int, 1, 65535),
|
||||||
|
"MAX_RELEASE_DATE_GAP_YEARS": (int, 1, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
self._boolean_settings = {
|
||||||
|
"MANAGE_NFO", "FIX_DIR_MTIMES", "LOCK_METADATA", "DEBUG",
|
||||||
|
"PREFER_RELEASE_DATES_OVER_FILE_DATES", "ALLOW_FILE_DATE_FALLBACK",
|
||||||
|
"ENABLE_SMART_DATE_VALIDATION", "PATH_DEBUG", "SUPPRESS_TVDB_WARNINGS"
|
||||||
|
}
|
||||||
|
|
||||||
|
self._choice_settings = {
|
||||||
|
"MOVIE_PRIORITY": ["import_then_digital", "digital_first", "file_date_only"],
|
||||||
|
"MOVIE_POLL_MODE": ["always", "missing_only", "never"],
|
||||||
|
"MOVIE_DATE_UPDATE_MODE": ["overwrite", "backfill_only", "preserve_existing"],
|
||||||
|
"TV_WEBHOOK_PROCESSING_MODE": ["targeted", "full_scan", "hybrid"],
|
||||||
|
"UPDATE_MODE": ["always", "missing_only", "never"],
|
||||||
|
"MTIME_BEHAVIOR": ["update", "leave_alone"],
|
||||||
|
"RADARR_DB_TYPE": ["postgresql", "sqlite"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_all(self) -> ValidationResult:
|
||||||
|
"""Validate all configuration settings"""
|
||||||
|
self.result = ValidationResult(is_valid=True)
|
||||||
|
|
||||||
|
# Validate required settings
|
||||||
|
self._validate_required_settings()
|
||||||
|
|
||||||
|
# Validate paths
|
||||||
|
self._validate_paths()
|
||||||
|
|
||||||
|
# Validate URLs
|
||||||
|
self._validate_urls()
|
||||||
|
|
||||||
|
# Validate numeric settings
|
||||||
|
self._validate_numeric_settings()
|
||||||
|
|
||||||
|
# Validate boolean settings
|
||||||
|
self._validate_boolean_settings()
|
||||||
|
|
||||||
|
# Validate choice settings
|
||||||
|
self._validate_choice_settings()
|
||||||
|
|
||||||
|
# Validate database configuration
|
||||||
|
self._validate_database_config()
|
||||||
|
|
||||||
|
# Validate release date configuration
|
||||||
|
self._validate_release_date_config()
|
||||||
|
|
||||||
|
# Validate performance settings
|
||||||
|
self._validate_performance_settings()
|
||||||
|
|
||||||
|
# Validate cross-setting dependencies
|
||||||
|
self._validate_dependencies()
|
||||||
|
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
def _validate_required_settings(self) -> None:
|
||||||
|
"""Validate required environment variables are set"""
|
||||||
|
for setting in self._required_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if not value:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Required setting {setting} is not set",
|
||||||
|
current_value=value
|
||||||
|
))
|
||||||
|
elif not value.strip():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Required setting {setting} is empty",
|
||||||
|
current_value=value
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_paths(self) -> None:
|
||||||
|
"""Validate all path-related settings"""
|
||||||
|
for setting in self._path_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if not value:
|
||||||
|
if setting in self._required_settings:
|
||||||
|
continue # Already handled in required validation
|
||||||
|
else:
|
||||||
|
# Optional path settings
|
||||||
|
continue
|
||||||
|
|
||||||
|
if setting in {"TV_PATHS", "MOVIE_PATHS", "RADARR_ROOT_FOLDERS", "SONARR_ROOT_FOLDERS"}:
|
||||||
|
# Multi-path settings
|
||||||
|
paths = [p.strip() for p in value.split(",") if p.strip()]
|
||||||
|
if not paths:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"No valid paths found in {setting}",
|
||||||
|
current_value=value
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for path_str in paths:
|
||||||
|
self._validate_single_path(setting, path_str)
|
||||||
|
else:
|
||||||
|
# Single path settings
|
||||||
|
self._validate_single_path(setting, value)
|
||||||
|
|
||||||
|
def _validate_single_path(self, setting: str, path_str: str) -> None:
|
||||||
|
"""Validate a single path"""
|
||||||
|
try:
|
||||||
|
path = Path(path_str)
|
||||||
|
|
||||||
|
# Check if path is absolute (recommended for Docker)
|
||||||
|
if not path.is_absolute():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Path should be absolute for reliable Docker operation",
|
||||||
|
current_value=path_str,
|
||||||
|
suggested_value=f"Use absolute path like /media/..."
|
||||||
|
))
|
||||||
|
|
||||||
|
# For media paths, check existence if not in container
|
||||||
|
if setting in {"TV_PATHS", "MOVIE_PATHS"} and not self._is_likely_container_path(path_str):
|
||||||
|
if not path.exists():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Path does not exist (may be valid in container)",
|
||||||
|
current_value=path_str,
|
||||||
|
details={"path_type": "media"}
|
||||||
|
))
|
||||||
|
elif not path.is_dir():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Path exists but is not a directory",
|
||||||
|
current_value=path_str
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for database directory
|
||||||
|
if setting == "DB_PATH":
|
||||||
|
parent_dir = path.parent
|
||||||
|
if not self._is_likely_container_path(str(parent_dir)) and not parent_dir.exists():
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message=f"Database directory does not exist: {parent_dir}",
|
||||||
|
current_value=path_str
|
||||||
|
))
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid path format: {e}",
|
||||||
|
current_value=path_str
|
||||||
|
))
|
||||||
|
|
||||||
|
def _is_likely_container_path(self, path: str) -> bool:
|
||||||
|
"""Check if path looks like a container path"""
|
||||||
|
container_indicators = ["/app/", "/media/", "/config/", "/data/"]
|
||||||
|
return any(indicator in path for indicator in container_indicators)
|
||||||
|
|
||||||
|
def _validate_urls(self) -> None:
|
||||||
|
"""Validate URL settings"""
|
||||||
|
for setting in self._url_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if value and not validate_url_format(value):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid URL format",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value="Use format: http://hostname:port or https://hostname:port"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_numeric_settings(self) -> None:
|
||||||
|
"""Validate numeric settings"""
|
||||||
|
for setting, (type_class, min_val, max_val) in self._numeric_settings.items():
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
numeric_value = type_class(value)
|
||||||
|
if numeric_value < min_val or numeric_value > max_val:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Value must be between {min_val} and {max_val}",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value=f"Use value between {min_val}-{max_val}"
|
||||||
|
))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid {type_class.__name__} value",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value=f"Use a valid {type_class.__name__} between {min_val}-{max_val}"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_boolean_settings(self) -> None:
|
||||||
|
"""Validate boolean settings"""
|
||||||
|
valid_true = {"1", "true", "yes", "y", "on"}
|
||||||
|
valid_false = {"0", "false", "no", "n", "off"}
|
||||||
|
valid_values = valid_true | valid_false
|
||||||
|
|
||||||
|
for setting in self._boolean_settings:
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if value and value.lower() not in valid_values:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid boolean value",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value="Use: true/false, 1/0, yes/no, y/n, on/off"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_choice_settings(self) -> None:
|
||||||
|
"""Validate settings with predefined choices"""
|
||||||
|
for setting, valid_choices in self._choice_settings.items():
|
||||||
|
value = os.environ.get(setting)
|
||||||
|
if value and value.lower() not in [choice.lower() for choice in valid_choices]:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid choice",
|
||||||
|
current_value=value,
|
||||||
|
suggested_value=f"Use one of: {', '.join(valid_choices)}"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_database_config(self) -> None:
|
||||||
|
"""Validate database configuration"""
|
||||||
|
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||||
|
|
||||||
|
if db_type == "postgresql":
|
||||||
|
# Check required PostgreSQL settings
|
||||||
|
required_pg_settings = ["RADARR_DB_HOST", "RADARR_DB_NAME", "RADARR_DB_USER"]
|
||||||
|
for setting in required_pg_settings:
|
||||||
|
if not os.environ.get(setting):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting=setting,
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Required for PostgreSQL database connection",
|
||||||
|
current_value=os.environ.get(setting)
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_release_date_config(self) -> None:
|
||||||
|
"""Validate release date processing configuration"""
|
||||||
|
priority = os.environ.get("RELEASE_DATE_PRIORITY", "")
|
||||||
|
if priority:
|
||||||
|
priorities = [p.strip().lower() for p in priority.split(",")]
|
||||||
|
valid_priorities = {"digital", "physical", "theatrical"}
|
||||||
|
|
||||||
|
invalid_priorities = [p for p in priorities if p not in valid_priorities]
|
||||||
|
if invalid_priorities:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="RELEASE_DATE_PRIORITY",
|
||||||
|
severity=ValidationSeverity.ERROR,
|
||||||
|
message=f"Invalid release date priorities: {', '.join(invalid_priorities)}",
|
||||||
|
current_value=priority,
|
||||||
|
suggested_value="Use: digital, physical, theatrical"
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(set(priorities)) != len(priorities):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="RELEASE_DATE_PRIORITY",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="Duplicate priorities found",
|
||||||
|
current_value=priority
|
||||||
|
))
|
||||||
|
|
||||||
|
def _validate_performance_settings(self) -> None:
|
||||||
|
"""Validate performance-related settings"""
|
||||||
|
batch_delay = os.environ.get("BATCH_DELAY")
|
||||||
|
max_concurrent = os.environ.get("MAX_CONCURRENT_SERIES")
|
||||||
|
|
||||||
|
# Performance recommendations
|
||||||
|
if batch_delay:
|
||||||
|
try:
|
||||||
|
delay = float(batch_delay)
|
||||||
|
if delay < 1.0:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="BATCH_DELAY",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="Very low batch delay may increase system load",
|
||||||
|
current_value=batch_delay,
|
||||||
|
suggested_value="Consider using 1.0 or higher for better stability"
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
pass # Already caught in numeric validation
|
||||||
|
|
||||||
|
if max_concurrent:
|
||||||
|
try:
|
||||||
|
concurrent = int(max_concurrent)
|
||||||
|
if concurrent > 5:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="MAX_CONCURRENT_SERIES",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="High concurrency may overload system resources",
|
||||||
|
current_value=max_concurrent,
|
||||||
|
suggested_value="Consider using 3-5 for optimal balance"
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
pass # Already caught in numeric validation
|
||||||
|
|
||||||
|
def _validate_dependencies(self) -> None:
|
||||||
|
"""Validate cross-setting dependencies"""
|
||||||
|
# If database is configured, recommend using database over API
|
||||||
|
db_type = os.environ.get("RADARR_DB_TYPE")
|
||||||
|
radarr_url = os.environ.get("RADARR_URL")
|
||||||
|
|
||||||
|
if db_type and radarr_url:
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="RADARR_DB_TYPE",
|
||||||
|
severity=ValidationSeverity.INFO,
|
||||||
|
message="Database connection preferred over API for better performance",
|
||||||
|
details={"recommendation": "Database access is faster and more reliable"}
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check path mapping consistency
|
||||||
|
tv_paths = os.environ.get("TV_PATHS", "").split(",")
|
||||||
|
sonarr_paths = os.environ.get("SONARR_ROOT_FOLDERS", "").split(",")
|
||||||
|
|
||||||
|
if len([p for p in tv_paths if p.strip()]) != len([p for p in sonarr_paths if p.strip()]):
|
||||||
|
self.result.add_issue(ValidationIssue(
|
||||||
|
setting="TV_PATHS",
|
||||||
|
severity=ValidationSeverity.WARNING,
|
||||||
|
message="TV_PATHS and SONARR_ROOT_FOLDERS should have matching number of paths",
|
||||||
|
details={
|
||||||
|
"tv_paths_count": len([p for p in tv_paths if p.strip()]),
|
||||||
|
"sonarr_paths_count": len([p for p in sonarr_paths if p.strip()])
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration() -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Validate the complete NFOGuard configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult with all validation issues found
|
||||||
|
"""
|
||||||
|
validator = ConfigValidator()
|
||||||
|
return validator.validate_all()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration_and_raise() -> None:
|
||||||
|
"""
|
||||||
|
Validate configuration and raise ConfigurationError if invalid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If configuration validation fails
|
||||||
|
"""
|
||||||
|
result = validate_configuration()
|
||||||
|
|
||||||
|
if not result.is_valid:
|
||||||
|
error_messages = []
|
||||||
|
for error in result.get_errors():
|
||||||
|
error_messages.append(f"{error.setting}: {error.message}")
|
||||||
|
|
||||||
|
raise ConfigurationError(
|
||||||
|
setting="configuration",
|
||||||
|
reason=f"Configuration validation failed with {result.errors_count} errors",
|
||||||
|
current_value={
|
||||||
|
"errors": error_messages,
|
||||||
|
"warnings_count": result.warnings_count,
|
||||||
|
"validation_details": result.to_dict()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_configuration_summary() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get a summary of current configuration status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with configuration summary
|
||||||
|
"""
|
||||||
|
result = validate_configuration()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_valid": result.is_valid,
|
||||||
|
"errors_count": result.errors_count,
|
||||||
|
"warnings_count": result.warnings_count,
|
||||||
|
"total_issues": len(result.issues),
|
||||||
|
"critical_issues": [
|
||||||
|
issue.to_dict() for issue in result.issues
|
||||||
|
if issue.severity == ValidationSeverity.ERROR
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
issue.to_dict() for issue in result.issues
|
||||||
|
if issue.severity == ValidationSeverity.WARNING
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user