"""Collector: container statuses over a READ-ONLY ``docker.sock`` (D1, D2, FR-5). Raw HTTP-over-unix-socket via stdlib (``socket.AF_UNIX`` + ``http.client.HTTPConnection`` subclass) — NO ``docker`` pip package. The client issues ``GET`` requests ONLY (``GET /containers/json``, ``GET /containers//json``) — it is read-only **by construction**: there is no method that POSTs / starts / stops / restarts / execs (AC-6, TC-09). The mount is additionally ``:ro``, a second guarantee. ``classify_container`` is a pure function (Up / healthy / restarting / exited / unhealthy) and ``container_alarm`` decides whether the status is alerting — both testable without a live Docker. """ from __future__ import annotations import http.client import json import logging import socket logger = logging.getLogger("watchdog.collectors.containers") # A container is "healthy" (no alarm) only in these states. _OK_STATES = frozenset({"running", "healthy"}) class _UnixHTTPConnection(http.client.HTTPConnection): """``HTTPConnection`` over an ``AF_UNIX`` socket (stdlib only, GET-only use).""" def __init__(self, sock_path: str, timeout: float): super().__init__("localhost", timeout=timeout) self._sock_path = sock_path def connect(self) -> None: # noqa: D401 - override sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(self.timeout) sock.connect(self._sock_path) self.sock = sock class DockerSockReader: """Read-only Docker API client over the unix socket. EXPOSES READ METHODS ONLY (``list_containers`` / ``inspect``); the single private primitive ``_get`` hard-codes the ``GET`` HTTP method, so no caller can ever mutate the Docker state (AC-6 / TC-09). never-raise: any socket / HTTP / parse error degrades to ``None`` / ``[]``. """ def __init__(self, sock_path: str = "/var/run/docker.sock", timeout_s: float = 3.0): self._sock_path = sock_path self._timeout = timeout_s def _get(self, path: str) -> object | None: """Issue a single ``GET `` over the socket. never-raise. This is the ONLY request primitive and it is GET-only — the read-only guarantee is structural, not policy. """ conn = None try: conn = _UnixHTTPConnection(self._sock_path, self._timeout) conn.request("GET", path) resp = conn.getresponse() body = resp.read() if resp.status >= 400: logger.warning("watchdog: docker GET %s -> %s", path, resp.status) return None return json.loads(body.decode("utf-8", errors="replace")) except Exception as e: # noqa: BLE001 - docker unreachable -> degrade logger.warning("watchdog: docker GET %s failed: %s", path, e) return None finally: if conn is not None: try: conn.close() except Exception: # noqa: BLE001 pass def list_containers(self) -> list: """``GET /containers/json?all=1`` — every container (read-only).""" data = self._get("/containers/json?all=1") return data if isinstance(data, list) else [] def inspect(self, name: str) -> dict | None: """``GET /containers//json`` — one container's detail (read-only).""" data = self._get(f"/containers/{name}/json") return data if isinstance(data, dict) else None def classify_container(inspect: dict | None) -> str: """Pure classifier: inspect-JSON -> a coarse status token (D5). Returns one of ``running`` / ``healthy`` / ``unhealthy`` / ``restarting`` / ``exited`` / ``created`` / ``paused`` / ``dead`` / ``unknown``. When a healthcheck is present its verdict (``healthy`` / ``unhealthy``) takes precedence over the bare ``running`` state. Never raises. """ try: if not inspect: return "unknown" state = inspect.get("State") if not isinstance(state, dict): return "unknown" status = (state.get("Status") or "").strip().lower() health = state.get("Health") if isinstance(health, dict): hstatus = (health.get("Status") or "").strip().lower() if hstatus in ("healthy", "unhealthy"): return hstatus return status or "unknown" except Exception as e: # noqa: BLE001 - classification must never crash logger.warning("watchdog: classify_container error: %s", e) return "unknown" def container_alarm(status: str) -> bool: """True when ``status`` is NOT a healthy state (restarting/exited/unhealthy/...).""" return (status or "").strip().lower() not in _OK_STATES