Files
nfoguard/monitoring/health.py
T
sbcrumb 6622da7b84
Local Docker Build (Dev) / build-dev (pull_request) Successful in 24s
feat: metrics
2025-09-29 09:23:08 -04:00

474 lines
18 KiB
Python

"""
Health Check System for NFOGuard
Provides health and readiness endpoints for monitoring and orchestration
"""
import time
import asyncio
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from config.runtime_validator import RuntimeValidator, HealthCheckResult
from monitoring.metrics import metrics
class HealthStatus(Enum):
"""Health check status levels"""
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
@dataclass
class HealthCheck:
"""Individual health check result"""
name: str
status: HealthStatus
message: str
duration_ms: float
details: Dict[str, Any] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"status": self.status.value,
"message": self.message,
"duration_ms": round(self.duration_ms, 2),
"details": self.details or {}
}
@dataclass
class OverallHealth:
"""Overall system health status"""
status: HealthStatus
checks: List[HealthCheck]
timestamp: float
uptime_seconds: float
version: str = "2.0.0"
def to_dict(self) -> Dict[str, Any]:
return {
"status": self.status.value,
"timestamp": self.timestamp,
"uptime_seconds": round(self.uptime_seconds, 2),
"version": self.version,
"checks": [check.to_dict() for check in self.checks],
"summary": {
"total_checks": len(self.checks),
"healthy_checks": len([c for c in self.checks if c.status == HealthStatus.HEALTHY]),
"degraded_checks": len([c for c in self.checks if c.status == HealthStatus.DEGRADED]),
"unhealthy_checks": len([c for c in self.checks if c.status == HealthStatus.UNHEALTHY])
}
}
class HealthChecker:
"""Comprehensive health checking system"""
def __init__(self):
self.start_time = time.time()
self._last_health_check = None
self._health_check_cache_ttl = 30 # Cache for 30 seconds
self._runtime_validator = None
def _get_runtime_validator(self):
"""Get runtime validator instance"""
if self._runtime_validator is None:
try:
from config.settings import config
self._runtime_validator = RuntimeValidator(config)
except Exception as e:
# Create a dummy validator if config fails
self._runtime_validator = None
return self._runtime_validator
async def check_basic_health(self) -> HealthCheck:
"""Basic health check - always succeeds if service is running"""
start_time = time.time()
try:
# Basic service availability
uptime = time.time() - self.start_time
if uptime < 30:
status = HealthStatus.DEGRADED
message = f"Service starting up (uptime: {uptime:.1f}s)"
else:
status = HealthStatus.HEALTHY
message = f"Service running normally (uptime: {uptime:.1f}s)"
return HealthCheck(
name="basic",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={"uptime_seconds": uptime}
)
except Exception as e:
return HealthCheck(
name="basic",
status=HealthStatus.UNHEALTHY,
message=f"Basic health check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def check_filesystem_health(self) -> HealthCheck:
"""Check filesystem access for media paths"""
start_time = time.time()
try:
from config.settings import config
accessible_paths = 0
total_paths = len(config.tv_paths) + len(config.movie_paths)
issues = []
# Check TV paths
for path in config.tv_paths:
try:
if path.exists() and path.is_dir():
# Try to read directory
list(path.iterdir())
accessible_paths += 1
else:
issues.append(f"TV path not accessible: {path}")
except PermissionError:
issues.append(f"TV path permission denied: {path}")
except Exception as e:
issues.append(f"TV path error {path}: {e}")
# Check movie paths
for path in config.movie_paths:
try:
if path.exists() and path.is_dir():
list(path.iterdir())
accessible_paths += 1
else:
issues.append(f"Movie path not accessible: {path}")
except PermissionError:
issues.append(f"Movie path permission denied: {path}")
except Exception as e:
issues.append(f"Movie path error {path}: {e}")
# Determine status
if accessible_paths == total_paths:
status = HealthStatus.HEALTHY
message = f"All {total_paths} media paths accessible"
elif accessible_paths > 0:
status = HealthStatus.DEGRADED
message = f"{accessible_paths}/{total_paths} media paths accessible"
else:
status = HealthStatus.UNHEALTHY
message = "No media paths accessible"
return HealthCheck(
name="filesystem",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={
"accessible_paths": accessible_paths,
"total_paths": total_paths,
"issues": issues[:5] # Limit to first 5 issues
}
)
except Exception as e:
return HealthCheck(
name="filesystem",
status=HealthStatus.UNHEALTHY,
message=f"Filesystem check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def check_database_health(self) -> HealthCheck:
"""Check database connectivity and performance"""
start_time = time.time()
try:
import sqlite3
from config.settings import config
# Test local database
db_path = config.db_path
def test_db():
with sqlite3.connect(str(db_path), timeout=5) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")
table_count = cursor.fetchone()[0]
return table_count
# Run database test
table_count = await asyncio.get_event_loop().run_in_executor(None, test_db)
duration = (time.time() - start_time) * 1000
if duration < 100: # < 100ms is good
status = HealthStatus.HEALTHY
message = f"Database responsive ({duration:.1f}ms, {table_count} tables)"
elif duration < 1000: # < 1s is acceptable
status = HealthStatus.DEGRADED
message = f"Database slow ({duration:.1f}ms, {table_count} tables)"
else:
status = HealthStatus.UNHEALTHY
message = f"Database very slow ({duration:.1f}ms)"
return HealthCheck(
name="database",
status=status,
message=message,
duration_ms=duration,
details={
"db_path": str(db_path),
"table_count": table_count,
"response_time_category": "fast" if duration < 100 else "slow" if duration < 1000 else "very_slow"
}
)
except Exception as e:
return HealthCheck(
name="database",
status=HealthStatus.UNHEALTHY,
message=f"Database check failed: {e}",
duration_ms=(time.time() - start_time) * 1000,
details={"error": str(e)}
)
async def check_external_apis_health(self) -> HealthCheck:
"""Check external API connectivity"""
start_time = time.time()
try:
import aiohttp
from config.settings import config
api_results = []
apis_tested = 0
apis_healthy = 0
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
# Test Radarr API if configured
if hasattr(config, 'radarr_url') and config.radarr_url:
apis_tested += 1
try:
test_url = f"{config.radarr_url.rstrip('/')}/api/v3/health"
async with session.get(test_url) as response:
if response.status == 200:
apis_healthy += 1
api_results.append({"api": "radarr", "status": "healthy"})
else:
api_results.append({"api": "radarr", "status": f"unhealthy (HTTP {response.status})"})
except Exception as e:
api_results.append({"api": "radarr", "status": f"error: {str(e)[:50]}"})
# Test Sonarr API if configured
if hasattr(config, 'sonarr_url') and config.sonarr_url:
apis_tested += 1
try:
test_url = f"{config.sonarr_url.rstrip('/')}/api/v3/health"
async with session.get(test_url) as response:
if response.status == 200:
apis_healthy += 1
api_results.append({"api": "sonarr", "status": "healthy"})
else:
api_results.append({"api": "sonarr", "status": f"unhealthy (HTTP {response.status})"})
except Exception as e:
api_results.append({"api": "sonarr", "status": f"error: {str(e)[:50]}"})
# Determine overall API health
if apis_tested == 0:
status = HealthStatus.HEALTHY
message = "No external APIs configured"
elif apis_healthy == apis_tested:
status = HealthStatus.HEALTHY
message = f"All {apis_tested} external APIs healthy"
elif apis_healthy > 0:
status = HealthStatus.DEGRADED
message = f"{apis_healthy}/{apis_tested} external APIs healthy"
else:
status = HealthStatus.UNHEALTHY
message = "No external APIs responding"
return HealthCheck(
name="external_apis",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={
"apis_tested": apis_tested,
"apis_healthy": apis_healthy,
"api_results": api_results
}
)
except Exception as e:
return HealthCheck(
name="external_apis",
status=HealthStatus.UNHEALTHY,
message=f"API health check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def check_performance_health(self) -> HealthCheck:
"""Check system performance metrics"""
start_time = time.time()
try:
system_metrics = metrics.get_system_metrics()
processing_metrics = metrics.get_processing_metrics()
issues = []
warnings = []
# Check CPU usage
cpu_percent = system_metrics.get("cpu_percent", 0)
if cpu_percent > 90:
issues.append(f"High CPU usage: {cpu_percent:.1f}%")
elif cpu_percent > 70:
warnings.append(f"Elevated CPU usage: {cpu_percent:.1f}%")
# Check memory usage
memory_percent = system_metrics.get("memory_percent", 0)
if memory_percent > 90:
issues.append(f"High memory usage: {memory_percent:.1f}%")
elif memory_percent > 80:
warnings.append(f"Elevated memory usage: {memory_percent:.1f}%")
# Check disk space
if "db_disk_free" in system_metrics and system_metrics["db_disk_free"]:
free_space_gb = system_metrics["db_disk_free"] / (1024**3)
if free_space_gb < 1:
issues.append(f"Low disk space: {free_space_gb:.1f}GB free")
elif free_space_gb < 5:
warnings.append(f"Low disk space: {free_space_gb:.1f}GB free")
# Check active operations
active_ops = system_metrics.get("active_operations", 0)
if active_ops > 10:
warnings.append(f"High concurrent operations: {active_ops}")
# Determine status
if issues:
status = HealthStatus.UNHEALTHY
message = f"Performance issues detected: {', '.join(issues[:2])}"
elif warnings:
status = HealthStatus.DEGRADED
message = f"Performance warnings: {', '.join(warnings[:2])}"
else:
status = HealthStatus.HEALTHY
message = "System performance normal"
return HealthCheck(
name="performance",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={
"cpu_percent": cpu_percent,
"memory_percent": memory_percent,
"active_operations": active_ops,
"issues": issues,
"warnings": warnings
}
)
except Exception as e:
return HealthCheck(
name="performance",
status=HealthStatus.DEGRADED,
message=f"Performance check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def get_full_health_status(self) -> OverallHealth:
"""Get comprehensive health status"""
start_time = time.time()
# Run all health checks concurrently
checks = await asyncio.gather(
self.check_basic_health(),
self.check_filesystem_health(),
self.check_database_health(),
self.check_external_apis_health(),
self.check_performance_health(),
return_exceptions=True
)
# Filter out any exceptions and convert to HealthCheck objects
valid_checks = []
for check in checks:
if isinstance(check, HealthCheck):
valid_checks.append(check)
elif isinstance(check, Exception):
valid_checks.append(HealthCheck(
name="unknown",
status=HealthStatus.UNHEALTHY,
message=f"Health check exception: {check}",
duration_ms=0
))
# Determine overall status
unhealthy_count = len([c for c in valid_checks if c.status == HealthStatus.UNHEALTHY])
degraded_count = len([c for c in valid_checks if c.status == HealthStatus.DEGRADED])
if unhealthy_count > 0:
overall_status = HealthStatus.UNHEALTHY
elif degraded_count > 0:
overall_status = HealthStatus.DEGRADED
else:
overall_status = HealthStatus.HEALTHY
return OverallHealth(
status=overall_status,
checks=valid_checks,
timestamp=start_time,
uptime_seconds=time.time() - self.start_time
)
async def get_readiness_status(self) -> Dict[str, Any]:
"""Get readiness status for Kubernetes readiness probes"""
# Readiness is simpler - just check critical components
checks = await asyncio.gather(
self.check_basic_health(),
self.check_filesystem_health(),
self.check_database_health(),
return_exceptions=True
)
critical_failures = 0
for check in checks:
if isinstance(check, HealthCheck) and check.status == HealthStatus.UNHEALTHY:
critical_failures += 1
is_ready = critical_failures == 0
return {
"ready": is_ready,
"timestamp": time.time(),
"critical_failures": critical_failures,
"message": "Service ready" if is_ready else f"{critical_failures} critical failures"
}
async def get_liveness_status(self) -> Dict[str, Any]:
"""Get liveness status for Kubernetes liveness probes"""
# Liveness is even simpler - just check if service is responsive
basic_check = await self.check_basic_health()
is_alive = basic_check.status != HealthStatus.UNHEALTHY
return {
"alive": is_alive,
"timestamp": time.time(),
"uptime_seconds": time.time() - self.start_time,
"message": basic_check.message
}
# Global health checker instance
health_checker = HealthChecker()