docker-py's containers.run(remove=True) only removes a container after it ran. A helper whose start failed - an NFS destination refusing the mount - stayed behind in "Created", holding the destination's volume. Every Test click and scheduled backup added one (a user found 18), the volume could not be deleted even with everything stopped, and changed mount options never took effect because the volume could not be recreated with them. - docker_client.run_container: create, start, wait, collect output and always remove, also when the start fails; pulls a missing image. Used for NFS destination I/O, bind-path inspection, the volume wipe before a restore and the Trivy scanner. - Every helper container is labelled stackpilot.helper; on startup the stopped ones are removed, together with anything left on an NFS destination volume by older versions. - When an NFS destination's options change, stopped containers holding its volume are removed before it is recreated; a running one is now a clear error instead of silently keeping the old options. - A refused mount explains itself: NFS enabled and reachable from the Docker host, NFSv3 needing rpcbind and mountd, and nfsvers=4 needing only port 2049. - The scanner waits for Trivy's own 10-minute timeout instead of giving up after the default client's 60 seconds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""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", <stderr>)``.
|
|
"""
|
|
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
|