Skip to content

docker_engine

tit.jobs.docker_engine

Dependency-free Docker Engine API client — stdlib http.client over AF_UNIX.

The Node-side twin of this module is desktop/src/main/docker/engine.ts; both talk the same Docker Engine REST API subset directly over the Unix socket, never the docker CLI and never the third-party docker PyPI package (r5-docker-integration.md §5 — that package is not installed in idossha/ti-toolbox:v3.0.0 today, and adding it would only grow a 19 GB image further for something ~15 lines of stdlib already does).

Scope, and why it is narrower than the TypeScript client: this module is used from inside the tit container, where QSIPrep/QSIRecon are spawned as sibling containers via Docker-outside-of-Docker (DooD) — the root docker-compose.yml mounts the host's /var/run/docker.sock straight through, a Unix path, regardless of what OS the host actually is. So unlike the Electron client (which must speak to a real Windows named pipe on a Windows host), this module never needs npipe transport at all — Windows support here would mean supporting it as a pywin32 dependency for a case that cannot occur in this architecture, which is why it is explicitly out of scope, not merely unimplemented. If a future redesign ever has Electron spawn QSIPrep containers directly instead of via DooD (r5 §5's "bigger architectural change, out of scope"), that would need a real npipe-capable Python client; this one is not it.

Discovery: DOCKER_HOST (unix:// only — tcp:///npipe:// are parsed for completeness but this module never drives the tcp/npipe internals http.client would need), else the well-known mount path /var/run/docker.sock. No docker context inspect shell-out here (unlike discover.ts): the Python side always runs inside the tit container, where the socket location is a known, fixed bind mount, not an environment to be discovered the way a user's host machine is (r5 §5's "the Python client only ever needs the Unix-socket transport").

API version negotiation: identical strategy to engine.ts — :meth:DockerEngineClient.version hits the always-unversioned GET /version, reads ApiVersion from the response, and every later call is prefixed /v<ApiVersion>/....

What it covers: only the read-and-stop subset production calls -- :meth:DockerEngineClient.version, :meth:~DockerEngineClient.system_df and :meth:~DockerEngineClient.list_images (the System page's Docker panel, tit/server/routes/system.py), :meth:~DockerEngineClient.list_containers and :meth:~DockerEngineClient.stop_container (tit/jobs/runner.py's stop_docker_siblings_via_engine, which stops a stranded job's QSIPrep/QSIRecon siblings by their tit.job_id label at startup) and :meth:~DockerEngineClient.inspect_container (tit/host_path.py and the System page, reading the container's own mounts). QSIPrep/QSIRecon themselves are still started through the docker CLI by tit/pre/qsi/docker_builder.py, which sets the same tit.job_id label.

DockerEngineError

DockerEngineError(kind: str, message: str, status_code: int | None = None)

Bases: Exception

Carries a stable kind (mirrors DockerErrorKind in engine.ts) and, for an HTTP- level failure, the status_code that produced it.

Source code in tit/jobs/docker_engine.py
def __init__(self, kind: str, message: str, status_code: int | None = None) -> None:
    super().__init__(message)
    self.kind = kind
    self.status_code = status_code

DockerEngineClient

DockerEngineClient(conn: DockerConnection, timeout_s: float | None = DEFAULT_TIMEOUT_S)

Version, disk usage, images, containers (list/inspect/stop) over the Engine REST API.

Source code in tit/jobs/docker_engine.py
def __init__(
    self, conn: DockerConnection, timeout_s: float | None = DEFAULT_TIMEOUT_S
) -> None:
    self._conn = conn
    self._timeout_s = timeout_s
    self._api_version: str | None = None

system_df

system_df(timeout_s: float | None | object = 'default') -> dict[str, Any]

GET /system/df -- the Engine-API equivalent of docker system df.

Read-only, used by the System page's Docker-health panel. It is a genuinely expensive call on the daemon side (it walks the image graph), which is why the caller polls it on a multi-second TTL and passes an explicit timeout rather than the client default.

Source code in tit/jobs/docker_engine.py
def system_df(self, timeout_s: float | None | object = "default") -> dict[str, Any]:
    """``GET /system/df`` -- the Engine-API equivalent of ``docker system df``.

    Read-only, used by the System page's Docker-health panel. It is a genuinely expensive call
    on the daemon side (it walks the image graph), which is why the caller polls it on a
    multi-second TTL and passes an explicit timeout rather than the client default.
    """
    self._ensure_negotiated()
    return self._json_request("GET", self._vpath("/system/df"), timeout_s=timeout_s)

list_images

list_images(timeout_s: float | None | object = 'default') -> list[dict[str, Any]]

GET /images/json -- docker images. Read-only; see :meth:system_df.

Source code in tit/jobs/docker_engine.py
def list_images(
    self, timeout_s: float | None | object = "default"
) -> list[dict[str, Any]]:
    """``GET /images/json`` -- ``docker images``. Read-only; see :meth:`system_df`."""
    self._ensure_negotiated()
    result = self._json_request(
        "GET", self._vpath("/images/json"), timeout_s=timeout_s
    )
    return list(result or [])

list_containers

list_containers(*, filters: dict[str, list[str]] | None = None, all_containers: bool = False, timeout_s: float | None | object = 'default') -> list[dict[str, Any]]

GET /containers/json — the Engine-API equivalent of docker ps [--filter …].

filters is the Engine's own filter map (e.g. {"label": ["tit.job_id=abc"]}); it is JSON-encoded into the query string by :meth:_request. Every caller in this repo passes an explicit timeout_s (startup reconciliation must never block on a wedged daemon).

Source code in tit/jobs/docker_engine.py
def list_containers(
    self,
    *,
    filters: dict[str, list[str]] | None = None,
    all_containers: bool = False,
    timeout_s: float | None | object = "default",
) -> list[dict[str, Any]]:
    """``GET /containers/json`` — the Engine-API equivalent of ``docker ps [--filter …]``.

    *filters* is the Engine's own filter map (e.g. ``{"label": ["tit.job_id=abc"]}``); it is
    JSON-encoded into the query string by :meth:`_request`. Every caller in this repo passes
    an explicit *timeout_s* (startup reconciliation must never block on a wedged daemon).
    """
    self._ensure_negotiated()
    result = self._json_request(
        "GET",
        self._vpath("/containers/json"),
        query={
            "all": "1" if all_containers else "0",
            "filters": filters or None,
        },
        timeout_s=timeout_s,
    )
    return list(result or [])

discover

discover(env: dict[str, str] | None = None) -> DockerConnection

DOCKER_HOST=unix://... if set, else the well-known DooD bind-mount path. See the module docstring for why this is intentionally narrower than discover.ts's multi-candidate, CLI-assisted search — this module always runs inside a container with a known socket mount.

Source code in tit/jobs/docker_engine.py
def discover(env: dict[str, str] | None = None) -> DockerConnection:
    """``DOCKER_HOST=unix://...`` if set, else the well-known DooD bind-mount path. See the module
    docstring for why this is intentionally narrower than ``discover.ts``'s multi-candidate,
    CLI-assisted search — this module always runs inside a container with a known socket mount.
    """
    resolved_env = os.environ if env is None else env
    host = resolved_env.get("DOCKER_HOST")
    if host and host.startswith("unix://"):
        path = host[len("unix://") :] or DEFAULT_SOCKET_PATH
        return DockerConnection(socket_path=path)
    return DockerConnection(socket_path=DEFAULT_SOCKET_PATH)