"""Singleton docker-py client wrapper. All Docker access goes through this module. The Docker socket is NEVER exposed to the frontend. """ from __future__ import annotations import functools from typing import Any, Optional import docker import requests from docker.errors import APIError, DockerException, ImageNotFound, NotFound from config import settings class DockerError(Exception): """Structured error surfaced to API callers.""" def __init__(self, error: str, detail: str = ""): self.error = error self.detail = detail super().__init__(f"{error}: {detail}" if detail else error) # Read timeout for get_long_client(). docker-py's default of 60s is fine for # interactive calls, but a prune that deletes gigabytes of volume data or image # layers routinely takes longer. Kept below nginx's proxy_read_timeout (600s) # so the caller still gets our error instead of a bare gateway timeout. LONG_TIMEOUT_SECONDS = 540 def _connect(**kwargs) -> docker.DockerClient: try: base_url = f"unix://{settings.DOCKER_SOCKET}" client = docker.DockerClient(base_url=base_url, **kwargs) client.ping() return client except DockerException as exc: # pragma: no cover - environment dependent raise DockerError("docker_unavailable", str(exc)) from exc @functools.lru_cache(maxsize=1) def get_client() -> docker.DockerClient: return _connect() @functools.lru_cache(maxsize=1) def get_long_client() -> docker.DockerClient: """A client for calls that can legitimately run for minutes (prunes).""" return _connect(timeout=LONG_TIMEOUT_SECONDS) def reset_client() -> None: get_client.cache_clear() get_long_client.cache_clear() #: Label on every throwaway container StackPilot starts (backup helpers, NFS #: I/O, the scanner), so leftovers can be found and removed. HELPER_LABEL = "stackpilot.helper" def run_container( client: docker.DockerClient, image: str, command: Any, *, volumes: Optional[dict] = None, timeout: Optional[float] = None, ) -> bytes: """``docker run --rm`` that also cleans up a container that cannot start. docker-py's ``containers.run(remove=True)`` only removes a container after it ran. One whose start fails — a volume that cannot be mounted, say an NFS share that refuses the connection — stays behind in "Created" and keeps its volumes in use; every retry adds another. Returns stdout; a non-zero exit raises ``DockerError("container_failed", )``. """ kwargs = {"command": command, "volumes": volumes or {}, "labels": {HELPER_LABEL: "1"}} try: container = client.containers.create(image, **kwargs) except ImageNotFound: safe_call(client.images.pull, image) container = safe_call(client.containers.create, image, **kwargs) except DockerException as exc: raise DockerError("docker_api_error", str(exc)) from exc try: safe_call(container.start) code = safe_call(container.wait, timeout=timeout).get("StatusCode", 1) out = container.logs(stdout=True, stderr=False) or b"" if code != 0: err = (container.logs(stdout=False, stderr=True) or b"").decode("utf-8", "replace") raise DockerError("container_failed", err.strip() or f"exit status {code}") return out finally: try: container.remove(force=True) except Exception: # noqa: BLE001 - best effort, never mask the real error pass def remove_stale_helpers(client: docker.DockerClient) -> int: """Remove StackPilot's helper containers that are not running. Called at startup, when nothing of ours can be in flight. Returns how many were removed. """ removed = 0 for container in safe_call( client.containers.list, all=True, filters={"label": HELPER_LABEL} ): if container.status == "running": continue try: container.remove(force=True) removed += 1 except Exception: # noqa: BLE001 pass return removed def safe_call(fn, *args, **kwargs) -> Any: """Run a docker-py call, converting exceptions into DockerError.""" try: return fn(*args, **kwargs) except NotFound as exc: raise DockerError("not_found", str(exc)) from exc except APIError as exc: raise DockerError("docker_api_error", str(exc)) from exc except DockerException as exc: raise DockerError("docker_error", str(exc)) from exc # docker-py lets transport errors from requests through unwrapped; without # these a slow daemon surfaced as a bare 500 with no message. except requests.exceptions.Timeout as exc: raise DockerError( "docker_timeout", "Docker did not answer in time; the operation may still be running on the host", ) from exc except requests.exceptions.RequestException as exc: raise DockerError("docker_unavailable", str(exc)) from exc