mirror of
https://github.com/usestrix/strix.git
synced 2026-10-05 02:41:38 +00:00
fix(docker): drop the per-platform cause tables, show the endpoint and the raw error
This commit is contained in:
parent
b46fc2fb19
commit
172246b5db
3 changed files with 56 additions and 228 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue