fix(docker): drop the per-platform cause tables, show the endpoint and the raw error

This commit is contained in:
Ahmed Allam 2026-10-04 22:05:37 +00:00 • committed by Ahmed Allam
parent b46fc2fb19
commit 172246b5db
3 changed files with 56 additions and 228 deletions

View file

@ -1599,25 +1599,23 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None)
def check_docker_connection() -> Any:
from strix.runtime.docker_connection import (
DockerConnectionError,
connect_docker,
explain_failure,
)
from strix.runtime.docker_connection import DockerConnectionError, connect_docker
try:
return connect_docker()
except DockerConnectionError as exc:
report_error(f"docker_unavailable_{exc.reason}", exc.cause)
report_error("docker_unavailable", exc.cause)
console = Console()
cause, fix = explain_failure(exc)
error_text = Text()
error_text.append("DOCKER NOT AVAILABLE", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(f"{cause}\n", style="white")
error_text.append(f"{fix}\n\n", style="white")
error_text.append(f"Tried: {exc.endpoint.label}\n", style="dim")
error_text.append(exc.detail, style="dim red")
error_text.append(f"Cannot connect to Docker at {exc.endpoint.label}.\n", style="white")
error_text.append(f"{exc.detail}\n\n", style="dim red")
error_text.append(
"Make sure Docker is running. If `docker info` works in this shell, strix uses "
"the same daemon. Otherwise set DOCKER_HOST.\n",
style="white",
)
panel = Panel(
error_text,

View file

@ -1,18 +1,14 @@
"""Connect to the Docker daemon the way the ``docker`` CLI does, and say why it failed.
"""Connect to the Docker daemon the way the ``docker`` CLI does.
``docker.from_env()`` only looks at ``DOCKER_HOST`` and otherwise assumes
``/var/run/docker.sock`` (a named pipe on Windows). The CLI also honours the
current docker context, which is where Docker Desktop on macOS (without the
"default socket" option), OrbStack, Colima and Rancher Desktop register their
sockets. Resolving the endpoint the same way means ``docker ps`` working
implies strix works.
"default socket" option), OrbStack and Colima register their sockets.
"""
from __future__ import annotations
import errno
import os
import sys
from dataclasses import dataclass
from typing import Any
@ -25,31 +21,11 @@ from docker.errors import DockerException # type: ignore[import-untyped, unused
DEFAULT_CONTEXT = "default"
DEFAULT_SOURCE = "default socket"
SOCKET_MISSING = "socket_missing"
PERMISSION_DENIED = "permission_denied"
CONNECTION_REFUSED = "connection_refused"
UNKNOWN = "unknown"
_ERRNO_REASONS = {
errno.EACCES: PERMISSION_DENIED,
errno.EPERM: PERMISSION_DENIED,
errno.ENOENT: SOCKET_MISSING,
errno.ECONNREFUSED: CONNECTION_REFUSED,
}
# pywintypes.error from the npipe transport: ERROR_FILE_NOT_FOUND, ERROR_ACCESS_DENIED
_WINERROR_REASONS = {2: SOCKET_MISSING, 5: PERMISSION_DENIED}
_TEXT_REASONS = (
(("permission denied", "operation not permitted", "access is denied"), PERMISSION_DENIED),
(("no such file or directory", "cannot find the file specified"), SOCKET_MISSING),
(("connection refused", "max retries exceeded"), CONNECTION_REFUSED),
)
@dataclass(frozen=True)
class DockerEndpoint:
"""Where strix will talk to Docker and how that address was chosen."""
"""Where strix talks to Docker and how that address was chosen."""
host: str | None
source: str
@ -57,21 +33,22 @@ class DockerEndpoint:
@property
def label(self) -> str:
return f"{self.host or DEFAULT_SOURCE} ({self.source})"
return f"{self.host or 'default socket'} ({self.source})"
class DockerConnectionError(RuntimeError):
"""The daemon at ``endpoint`` could not be reached; ``reason`` says why."""
"""The daemon at ``endpoint`` could not be reached."""
def __init__(self, endpoint: DockerEndpoint, reason: str, cause: BaseException) -> None:
super().__init__(f"Cannot connect to Docker at {endpoint.label}: {root_cause_line(cause)}")
def __init__(self, endpoint: DockerEndpoint, cause: BaseException) -> None:
self.endpoint = endpoint
self.reason = reason
self.cause = cause
self.cause = root_cause(cause)
super().__init__(f"Cannot connect to Docker at {endpoint.label}: {self.detail}")
@property
def detail(self) -> str:
return root_cause_line(self.cause)
text = str(self.cause).strip()
name = type(self.cause).__name__
return text if name in text else f"{name}: {text}" if text else name
def resolve_docker_endpoint(environ: dict[str, str] | None = None) -> DockerEndpoint:
@ -89,7 +66,7 @@ def resolve_docker_endpoint(environ: dict[str, str] | None = None) -> DockerEndp
context = None
if context is not None and context.Host:
return DockerEndpoint(context.Host, f"docker context '{name}'", context.TLSConfig)
return DockerEndpoint(None, DEFAULT_SOURCE)
return DockerEndpoint(None, "default socket")
def connect_docker() -> Any:
@ -100,88 +77,18 @@ def connect_docker() -> Any:
return docker.from_env()
return docker.DockerClient(base_url=endpoint.host, tls=endpoint.tls or False)
except DockerException as exc:
raise DockerConnectionError(endpoint, classify_failure(exc), exc) from exc
raise DockerConnectionError(endpoint, exc) from exc
def classify_failure(exc: BaseException) -> str:
"""Map the SDK's wrapped exception onto one of the reasons above."""
for inner in walk_exceptions(exc):
code = getattr(inner, "errno", None)
if isinstance(code, int) and code in _ERRNO_REASONS:
return _ERRNO_REASONS[code]
winerror = getattr(inner, "winerror", None)
if isinstance(winerror, int) and winerror in _WINERROR_REASONS:
return _WINERROR_REASONS[winerror]
text = str(exc).lower()
return next(
(reason for needles, reason in _TEXT_REASONS if any(n in text for n in needles)),
UNKNOWN,
)
def explain_failure(error: DockerConnectionError, platform: str | None = None) -> tuple[str, str]:
"""Plain-language cause and the fix for this platform."""
platform = platform or sys.platform
host = error.endpoint.host
source = error.endpoint.source
reason = error.reason
if reason == PERMISSION_DENIED:
cause = "Your user is not allowed to use the Docker socket."
fix = (
"sudo usermod -aG docker $USER, then log out and back in (or run: newgrp docker)."
if platform == "linux"
else "Run strix as the user who installed Docker, or check the socket permissions."
)
elif reason == CONNECTION_REFUSED:
cause = f"Nothing is listening at {host}."
fix = f"Start the Docker daemon there, or fix {source}."
elif reason == SOCKET_MISSING and source != DEFAULT_SOURCE:
cause = f"{source} points at a socket that does not exist."
fix = "Start that Docker daemon, or run: docker context ls and pick one that is running."
elif reason == SOCKET_MISSING:
cause, fix = _NOT_RUNNING[platform if platform in _NOT_RUNNING else "linux"]
else:
cause = "Cannot connect to the Docker daemon."
fix = "Run: docker info in this shell. If it works, set DOCKER_HOST to the host it prints."
return cause, f"Fix: {fix}"
_NOT_RUNNING = {
"darwin": (
"Docker is not running, or it only listens on its own socket.",
"Start Docker Desktop, OrbStack or Colima. If it is already running, select its "
"context: docker context use desktop-linux (or orbstack, colima).",
),
"win32": (
"Docker Desktop is not running.",
"Start Docker Desktop and wait until it reports running. From WSL, enable "
"Settings > Resources > WSL integration for this distro.",
),
"linux": (
"The Docker daemon is not running.",
"sudo systemctl start docker (Docker Desktop for Linux: "
"systemctl --user start docker-desktop).",
),
}
def walk_exceptions(exc: BaseException) -> list[BaseException]:
"""Every exception reachable through causes, contexts and args, outermost first."""
def root_cause(exc: BaseException) -> BaseException:
"""The innermost exception: the SDK wraps the OSError in requests/urllib3 errors."""
current = exc
seen: list[BaseException] = []
stack = [exc]
while stack:
current = stack.pop(0)
if any(current is s for s in seen):
continue
while not any(current is s for s in seen):
seen.append(current)
stack.extend(link for link in (current.__cause__, current.__context__) if link is not None)
stack.extend(arg for arg in current.args if isinstance(arg, BaseException))
return seen
def root_cause_line(exc: BaseException) -> str:
"""The innermost exception as one line, e.g. ``FileNotFoundError: [Errno 2] ...``."""
root = walk_exceptions(exc)[-1]
text = str(root).strip() or type(root).__name__
return text if type(root).__name__ in text else f"{type(root).__name__}: {text}"
nested = [arg for arg in current.args if isinstance(arg, BaseException)]
following = current.__cause__ or current.__context__ or (nested[-1] if nested else None)
if following is None:
break
current = following
return current

View file

@ -1,4 +1,4 @@
"""Docker endpoint resolution and the diagnostics printed when the daemon is unreachable."""
"""Docker endpoint resolution and the panel printed when the daemon is unreachable."""
from __future__ import annotations
@ -13,14 +13,8 @@ from urllib3.exceptions import ProtocolError
from strix.runtime import backends, docker_connection
from strix.runtime.docker_connection import (
CONNECTION_REFUSED,
PERMISSION_DENIED,
SOCKET_MISSING,
UNKNOWN,
DockerConnectionError,
DockerEndpoint,
classify_failure,
explain_failure,
resolve_docker_endpoint,
)
@ -63,17 +57,17 @@ def test_docker_context_env_overrides_the_config_file(monkeypatch: pytest.Monkey
def get_context(name: str) -> SimpleNamespace:
seen.append(name)
return SimpleNamespace(Host="unix:///run/user/1000/orbstack.sock", TLSConfig=None)
return SimpleNamespace(Host="unix:///run/orbstack.sock", TLSConfig=None)
monkeypatch.setattr("strix.runtime.docker_connection.ContextAPI.get_context", get_context)
endpoint = resolve_docker_endpoint({"DOCKER_CONTEXT": "orbstack"})
assert (
resolve_docker_endpoint({"DOCKER_CONTEXT": "orbstack"}).source
== "docker context 'orbstack'"
)
assert seen == ["orbstack"]
assert endpoint.source == "docker context 'orbstack'"
def test_default_context_and_broken_context_fall_back_to_the_sdk(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_default_and_broken_contexts_fall_back_to_the_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "default")
assert resolve_docker_endpoint({}) == DockerEndpoint(None, "default socket")
@ -87,83 +81,16 @@ def test_default_context_and_broken_context_fall_back_to_the_sdk(
@pytest.mark.parametrize(
("inner", "reason"),
"inner",
[
(FileNotFoundError(2, "No such file or directory"), SOCKET_MISSING),
(PermissionError(13, "Permission denied"), PERMISSION_DENIED),
(ConnectionRefusedError(111, "Connection refused"), CONNECTION_REFUSED),
FileNotFoundError(2, "No such file or directory"),
PermissionError(13, "Permission denied"),
ConnectionRefusedError(111, "Connection refused"),
],
)
def test_classifies_the_wrapped_os_error(inner: OSError, reason: str) -> None:
exc = _sdk_error(inner)
assert classify_failure(exc) == reason
assert docker_connection.root_cause_line(exc).startswith(type(inner).__name__)
def test_classifies_windows_named_pipe_errors() -> None:
class PyWinError(Exception):
def __init__(self, winerror: int) -> None:
super().__init__(winerror, "CreateFile", "The system cannot find the file specified.")
self.winerror = winerror
assert classify_failure(_sdk_error(PyWinError(2))) == SOCKET_MISSING
assert classify_failure(_sdk_error(PyWinError(5))) == PERMISSION_DENIED
def test_falls_back_to_the_message_text_then_unknown() -> None:
assert (
classify_failure(
DockerException("Error while fetching server API version: Permission denied")
)
== PERMISSION_DENIED
)
assert classify_failure(DockerException("something else entirely")) == UNKNOWN
@pytest.mark.parametrize(
("platform", "needle"),
[
("darwin", "docker context use desktop-linux"),
("win32", "Start Docker Desktop"),
("linux", "systemctl start docker"),
],
)
def test_not_running_fix_is_per_platform(platform: str, needle: str) -> None:
error = DockerConnectionError(
DockerEndpoint(None, "default socket"), SOCKET_MISSING, FileNotFoundError(2, "x")
)
cause, fix = explain_failure(error, platform=platform)
assert "not running" in cause
assert needle in fix
def test_permission_fix_names_the_docker_group_on_linux() -> None:
error = DockerConnectionError(
DockerEndpoint(None, "default socket"), PERMISSION_DENIED, PermissionError(13, "x")
)
assert "usermod -aG docker" in explain_failure(error, platform="linux")[1]
assert "usermod" not in explain_failure(error, platform="darwin")[1]
def test_explicit_endpoint_failures_name_their_source() -> None:
missing = DockerConnectionError(
DockerEndpoint("unix:///x.sock", "docker context 'colima'"),
SOCKET_MISSING,
FileNotFoundError(2, "x"),
)
assert explain_failure(missing, platform="darwin")[0].startswith("docker context 'colima'")
refused = DockerConnectionError(
DockerEndpoint("tcp://127.0.0.1:1", "DOCKER_HOST"),
CONNECTION_REFUSED,
ConnectionRefusedError(111, "x"),
)
cause, fix = explain_failure(refused, platform="linux")
assert "tcp://127.0.0.1:1" in cause
assert "DOCKER_HOST" in fix
def test_connect_docker_raises_a_classified_error(monkeypatch: pytest.MonkeyPatch) -> None:
def test_connect_docker_surfaces_the_root_cause(
monkeypatch: pytest.MonkeyPatch, inner: OSError
) -> None:
monkeypatch.setattr(
docker_connection,
"resolve_docker_endpoint",
@ -171,19 +98,19 @@ def test_connect_docker_raises_a_classified_error(monkeypatch: pytest.MonkeyPatc
)
def dead_client(**_kwargs: Any) -> None:
raise _sdk_error(FileNotFoundError(2, "No such file or directory"))
raise _sdk_error(inner)
monkeypatch.setattr("strix.runtime.docker_connection.docker.DockerClient", dead_client)
with pytest.raises(DockerConnectionError) as info:
docker_connection.connect_docker()
assert info.value.reason == SOCKET_MISSING
assert info.value.cause is inner
assert info.value.detail.startswith(type(inner).__name__)
assert info.value.endpoint.host == "unix:///nope.sock"
assert "FileNotFoundError" in info.value.detail
def test_check_docker_connection_prints_the_fix_and_exits(
def test_check_docker_connection_prints_endpoint_and_error_then_exits(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
reported: list[tuple[str, type | None]] = []
@ -191,27 +118,24 @@ def test_check_docker_connection_prints_the_fix_and_exits(
cli_utils, "report_error", lambda name, exc=None: reported.append((name, type(exc)))
)
error = DockerConnectionError(
DockerEndpoint(None, "default socket"),
PERMISSION_DENIED,
_sdk_error(PermissionError(13, "Permission denied")),
DockerEndpoint(None, "default socket"), _sdk_error(PermissionError(13, "Permission denied"))
)
def failing_connect() -> None:
raise error
monkeypatch.setattr(docker_connection, "connect_docker", failing_connect)
monkeypatch.setattr("strix.runtime.docker_connection.sys.platform", "linux")
with pytest.raises(SystemExit) as exit_info:
cli_utils.check_docker_connection()
out = capsys.readouterr().out
assert exit_info.value.code == 1
assert reported == [("docker_unavailable_permission_denied", DockerException)]
assert reported == [("docker_unavailable", PermissionError)]
assert "DOCKER NOT AVAILABLE" in out
assert "usermod -aG docker" in out
assert "Tried: default socket (default socket)" in out
assert "PermissionError" in out
assert "default socket (default socket)" in out
assert "PermissionError: [Errno 13] Permission denied" in out
assert "docker info" in out
def test_check_docker_connection_returns_the_client(monkeypatch: pytest.MonkeyPatch) -> None:
@ -245,7 +169,6 @@ async def test_sandbox_backend_uses_the_same_endpoint(monkeypatch: pytest.Monkey
)
assert captured["docker_client"] is resolved
assert captured["image"] == "img:1"
assert captured["started"] is True
assert captured == {"docker_client": resolved, "image": "img:1", "started": True}
assert isinstance(client, FakeClient)
assert isinstance(session, FakeSession)