diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 092ed691..7ab55d8e 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -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, diff --git a/strix/runtime/docker_connection.py b/strix/runtime/docker_connection.py index ab6af27c..6791a36c 100644 --- a/strix/runtime/docker_connection.py +++ b/strix/runtime/docker_connection.py @@ -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 diff --git a/tests/test_docker_connection.py b/tests/test_docker_connection.py index c6abd09c..c8ff4a93 100644 --- a/tests/test_docker_connection.py +++ b/tests/test_docker_connection.py @@ -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)