"""Docker service health checks ensuring core containers stay operational.""" from __future__ import annotations import json import os import subprocess from typing import Any try: import docker from docker.errors import DockerException except ImportError: # pragma: no cover - gracefully handle missing dependency. docker = None # type: ignore DockerException = Exception # type: ignore DEFAULT_ESSENTIAL_CONTAINERS = [ "guacamole_guacd", "guacamole_guacamole", "jellyfin", "navidrome", "webdav", ] def _load_additional_essentials() -> list[str]: """Load extra essential containers from environment configuration if provided.""" env_value = os.environ.get("FLOTTA_EXTRA_ESSENTIAL_CONTAINERS", "") if not env_value: return [] return [ container.strip() for container in env_value.split(",") if container.strip() ] def _resolve_essential_containers() -> list[str]: """ Return the final list of essential containers. Combines the baked-in defaults with any additional entries supplied via the `FLOTTA_EXTRA_ESSENTIAL_CONTAINERS` environment variable. """ essentials = DEFAULT_ESSENTIAL_CONTAINERS.copy() extras = _load_additional_essentials() if extras: essentials.extend( name for name in extras if name not in essentials ) return essentials def check_docker_services() -> dict[str, Any]: """ Inspect Docker containers for common failure modes. Returns: A dictionary describing: - missing_essentials: essential containers not in the running state. - unhealthy_containers: containers with health check status != 'healthy'. - restarting_containers: containers currently reported as 'restarting'. - error (optional): textual description when Docker is unreachable. """ essential_containers = _resolve_essential_containers() report: dict[str, Any] = { "missing_essentials": [], "unhealthy_containers": [], "restarting_containers": [], } if docker is None: return _check_via_cli(essential_containers, report) try: client = docker.from_env() containers = client.containers.list(all=True) except DockerException as exc: report["error"] = f"docker connection failed: {exc}" report["missing_essentials"] = list(essential_containers) return report # Index containers by name for quick lookup of essentials. status_by_name: dict[str, str] = {} health_by_name: dict[str, str] = {} restarting_containers: list[str] = [] for container in containers: status = (container.status or "").lower() status_by_name[container.name] = status if status == "restarting": restarting_containers.append(container.name) health_state = ( container.attrs.get("State", {}) .get("Health", {}) .get("Status") ) if health_state: health_by_name[container.name] = str(health_state).lower() report["missing_essentials"] = [ name for name in essential_containers if status_by_name.get(name) != "running" ] report["unhealthy_containers"] = [ name for name, status in health_by_name.items() if status != "healthy" ] report["restarting_containers"] = restarting_containers return report def _check_via_cli( essential_containers: list[str], report: dict[str, Any] ) -> dict[str, Any]: """ Fallback when the python docker SDK is unavailable. Relies on `docker ps --format '{{json .}}'` and targeted `docker inspect`. """ try: ps_result = subprocess.run( ["docker", "ps", "--all", "--format", "{{json .}}"], capture_output=True, text=True, check=True, timeout=20, ) except FileNotFoundError: report["error"] = "docker CLI not available" report["missing_essentials"] = list(essential_containers) return report except subprocess.CalledProcessError as exc: stderr = (exc.stderr or "").strip() report["error"] = f"docker CLI failed: {stderr or exc}" report["missing_essentials"] = list(essential_containers) return report status_by_name: dict[str, str] = {} health_by_name: dict[str, str] = {} restarting_containers: list[str] = [] for line in ps_result.stdout.splitlines(): line = line.strip() if not line: continue try: data = json.loads(line) except json.JSONDecodeError: continue name = data.get("Names") status_str = str(data.get("Status", "")).lower() if not name: continue status_by_name[name] = status_str if "restarting" in status_str: restarting_containers.append(name) if "(" in status_str and ")" in status_str: health_state = status_str[status_str.find("(") + 1 : status_str.find(")")] health_by_name[name] = health_state.lower() report["missing_essentials"] = [ name for name in essential_containers if "up" not in status_by_name.get(name, "") ] report["unhealthy_containers"] = [ name for name, status in health_by_name.items() if status != "healthy" ] report["restarting_containers"] = restarting_containers # For essentials without health info in `docker ps`, fetch targeted inspect. needs_inspect = [ name for name in essential_containers if name in status_by_name and name not in health_by_name ] for container in needs_inspect: try: inspect = subprocess.run( ["docker", "inspect", "--format", "{{json .State}}", container], capture_output=True, text=True, check=True, timeout=15, ) except subprocess.CalledProcessError: continue try: state = json.loads(inspect.stdout.strip() or "{}") except json.JSONDecodeError: continue health = str( ((state or {}).get("Health") or {}).get("Status") or "" ).lower() if health: if health != "healthy": if container not in report["unhealthy_containers"]: report["unhealthy_containers"].append(container) return report