mirror of
https://github.com/usestrix/strix.git
synced 2026-10-05 02:41:38 +00:00
fix(docker): connect like the docker CLI and explain why the daemon is unreachable
docker.from_env() ignores the current docker context, so Docker Desktop on macOS (socket under ~/.docker/run unless the default-socket option is on), OrbStack and Colima reported DOCKER NOT AVAILABLE while `docker ps` worked. Every failure also printed the same "ensure Docker Desktop is running" text followed by a RuntimeError traceback. - resolve the endpoint as the CLI does: DOCKER_HOST, then DOCKER_CONTEXT or the current context, then the default socket; the sandbox backend uses the same resolution so startup and scan talk to the same daemon - classify the SDK error (socket missing, permission denied, connection refused, Windows named pipe) and print the fix for the current platform, the endpoint that was tried and the underlying error - exit 1 cleanly instead of raising after the panel - telemetry reports docker_unavailable_<reason>
This commit is contained in:
parent
fc85e0061e
commit
b46fc2fb19
5 changed files with 463 additions and 17 deletions
|
|
@ -150,7 +150,11 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
|
|||
</ParamField>
|
||||
|
||||
<ParamField path="DOCKER_HOST" type="string">
|
||||
Docker daemon socket path. Use for remote Docker hosts or custom configurations.
|
||||
Docker daemon address, for example `unix:///var/run/docker.sock` or `tcp://10.0.0.5:2375`. When set, Strix uses it and ignores the docker context.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DOCKER_CONTEXT" type="string">
|
||||
Docker context to use. Strix connects to the same daemon as the `docker` CLI: `DOCKER_HOST` first, then this context, then the current context from `docker context use`, then the default socket. Docker Desktop, OrbStack and Colima register their sockets as contexts, so no extra configuration is needed for them.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_RUNTIME_BACKEND" default="docker" type="string">
|
||||
|
|
|
|||
|
|
@ -1599,22 +1599,25 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None)
|
|||
|
||||
|
||||
def check_docker_connection() -> Any:
|
||||
import docker
|
||||
from docker.errors import DockerException
|
||||
from strix.runtime.docker_connection import (
|
||||
DockerConnectionError,
|
||||
connect_docker,
|
||||
explain_failure,
|
||||
)
|
||||
|
||||
try:
|
||||
return docker.from_env()
|
||||
except DockerException as exc:
|
||||
report_error("docker_unavailable", exc)
|
||||
return connect_docker()
|
||||
except DockerConnectionError as exc:
|
||||
report_error(f"docker_unavailable_{exc.reason}", 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("Cannot connect to Docker daemon.\n", style="white")
|
||||
error_text.append(
|
||||
"Please ensure Docker Desktop is installed and running, and try running strix again.\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")
|
||||
|
||||
panel = Panel(
|
||||
error_text,
|
||||
|
|
@ -1624,7 +1627,7 @@ def check_docker_connection() -> Any:
|
|||
padding=(1, 2),
|
||||
)
|
||||
console.print("\n", panel, "\n")
|
||||
raise RuntimeError("Docker not available") from None
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def image_exists(client: Any, image_name: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ async def _docker_backend(
|
|||
"""Bring up a session backed by the local Docker daemon.
|
||||
|
||||
Uses :class:`StrixDockerSandboxClient` to inject NET_ADMIN /
|
||||
NET_RAW caps + ``host.docker.internal`` host-gateway. Imports
|
||||
``docker`` lazily so deployments that target a non-Docker
|
||||
backend don't need the docker-py library installed.
|
||||
NET_RAW caps + ``host.docker.internal`` host-gateway, on the same
|
||||
endpoint the startup check resolved (``DOCKER_HOST``, docker context,
|
||||
default socket). Imports ``docker`` lazily so deployments that target
|
||||
a non-Docker backend don't need the docker-py library installed.
|
||||
|
||||
``session.start()`` is what materializes the manifest into the running
|
||||
container — the SDK's ``client.create()`` only builds the inner session
|
||||
|
|
@ -37,12 +38,12 @@ async def _docker_backend(
|
|||
Strix manages session lifetime explicitly via ``client.delete()`` so we
|
||||
trigger ``start()`` ourselves.
|
||||
"""
|
||||
import docker
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
|
||||
|
||||
from strix.runtime.docker_client import StrixDockerSandboxClient
|
||||
from strix.runtime.docker_connection import connect_docker
|
||||
|
||||
client = StrixDockerSandboxClient(docker.from_env())
|
||||
client = StrixDockerSandboxClient(connect_docker())
|
||||
client.strix_bind_mounts = bind_mounts or []
|
||||
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
|
||||
session = await client.create(options=options, manifest=manifest)
|
||||
|
|
|
|||
187
strix/runtime/docker_connection.py
Normal file
187
strix/runtime/docker_connection.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Connect to the Docker daemon the way the ``docker`` CLI does, and say why it failed.
|
||||
|
||||
``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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import docker # type: ignore[import-untyped, unused-ignore]
|
||||
from docker.context import ContextAPI # type: ignore[import-untyped, unused-ignore]
|
||||
from docker.context.config import ( # type: ignore[import-untyped, unused-ignore]
|
||||
get_current_context_name,
|
||||
)
|
||||
from docker.errors import DockerException # type: ignore[import-untyped, unused-ignore]
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
host: str | None
|
||||
source: str
|
||||
tls: Any = None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.host or DEFAULT_SOURCE} ({self.source})"
|
||||
|
||||
|
||||
class DockerConnectionError(RuntimeError):
|
||||
"""The daemon at ``endpoint`` could not be reached; ``reason`` says why."""
|
||||
|
||||
def __init__(self, endpoint: DockerEndpoint, reason: str, cause: BaseException) -> None:
|
||||
super().__init__(f"Cannot connect to Docker at {endpoint.label}: {root_cause_line(cause)}")
|
||||
self.endpoint = endpoint
|
||||
self.reason = reason
|
||||
self.cause = cause
|
||||
|
||||
@property
|
||||
def detail(self) -> str:
|
||||
return root_cause_line(self.cause)
|
||||
|
||||
|
||||
def resolve_docker_endpoint(environ: dict[str, str] | None = None) -> DockerEndpoint:
|
||||
"""``DOCKER_HOST``, else the current docker context, else the SDK default."""
|
||||
env = os.environ if environ is None else environ
|
||||
host = env.get("DOCKER_HOST", "").strip()
|
||||
if host:
|
||||
return DockerEndpoint(host, "DOCKER_HOST")
|
||||
|
||||
name = env.get("DOCKER_CONTEXT", "").strip() or get_current_context_name()
|
||||
if name != DEFAULT_CONTEXT:
|
||||
try:
|
||||
context = ContextAPI.get_context(name)
|
||||
except Exception: # noqa: BLE001 - a broken context file must not hide Docker itself
|
||||
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)
|
||||
|
||||
|
||||
def connect_docker() -> Any:
|
||||
"""Return a ``docker.DockerClient`` for the resolved endpoint or raise DockerConnectionError."""
|
||||
endpoint = resolve_docker_endpoint()
|
||||
try:
|
||||
if endpoint.host is None:
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
seen: list[BaseException] = []
|
||||
stack = [exc]
|
||||
while stack:
|
||||
current = stack.pop(0)
|
||||
if any(current is s for s in seen):
|
||||
continue
|
||||
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}"
|
||||
251
tests/test_docker_connection.py
Normal file
251
tests/test_docker_connection.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""Docker endpoint resolution and the diagnostics printed when the daemon is unreachable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from docker.errors import DockerException
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
cli_utils: Any = importlib.import_module("strix.interface.utils")
|
||||
|
||||
|
||||
def _sdk_error(inner: BaseException) -> DockerException:
|
||||
"""Build the exception exactly as docker-py raises it for a dead daemon."""
|
||||
protocol = ProtocolError("Connection aborted.", inner)
|
||||
requests_exc = RequestsConnectionError(protocol)
|
||||
requests_exc.__cause__ = protocol
|
||||
sdk_exc = DockerException(f"Error while fetching server API version: {requests_exc}")
|
||||
sdk_exc.__cause__ = requests_exc
|
||||
return sdk_exc
|
||||
|
||||
|
||||
def test_docker_host_wins_over_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "desktop-linux")
|
||||
endpoint = resolve_docker_endpoint({"DOCKER_HOST": "tcp://10.0.0.5:2375"})
|
||||
assert endpoint == DockerEndpoint("tcp://10.0.0.5:2375", "DOCKER_HOST")
|
||||
|
||||
|
||||
def test_current_context_is_used_like_the_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "desktop-linux")
|
||||
monkeypatch.setattr(
|
||||
"strix.runtime.docker_connection.ContextAPI.get_context",
|
||||
lambda _name: SimpleNamespace(
|
||||
Host="unix:///Users/me/.docker/run/docker.sock", TLSConfig=None
|
||||
),
|
||||
)
|
||||
endpoint = resolve_docker_endpoint({})
|
||||
assert endpoint.host == "unix:///Users/me/.docker/run/docker.sock"
|
||||
assert endpoint.source == "docker context 'desktop-linux'"
|
||||
|
||||
|
||||
def test_docker_context_env_overrides_the_config_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "default")
|
||||
seen: list[str] = []
|
||||
|
||||
def get_context(name: str) -> SimpleNamespace:
|
||||
seen.append(name)
|
||||
return SimpleNamespace(Host="unix:///run/user/1000/orbstack.sock", TLSConfig=None)
|
||||
|
||||
monkeypatch.setattr("strix.runtime.docker_connection.ContextAPI.get_context", get_context)
|
||||
endpoint = resolve_docker_endpoint({"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:
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "default")
|
||||
assert resolve_docker_endpoint({}) == DockerEndpoint(None, "default socket")
|
||||
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "gone")
|
||||
|
||||
def boom(_name: str) -> SimpleNamespace:
|
||||
raise ValueError("bad meta.json")
|
||||
|
||||
monkeypatch.setattr("strix.runtime.docker_connection.ContextAPI.get_context", boom)
|
||||
assert resolve_docker_endpoint({}) == DockerEndpoint(None, "default socket")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("inner", "reason"),
|
||||
[
|
||||
(FileNotFoundError(2, "No such file or directory"), SOCKET_MISSING),
|
||||
(PermissionError(13, "Permission denied"), PERMISSION_DENIED),
|
||||
(ConnectionRefusedError(111, "Connection refused"), 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:
|
||||
monkeypatch.setattr(
|
||||
docker_connection,
|
||||
"resolve_docker_endpoint",
|
||||
lambda _environ=None: DockerEndpoint("unix:///nope.sock", "DOCKER_HOST"),
|
||||
)
|
||||
|
||||
def dead_client(**_kwargs: Any) -> None:
|
||||
raise _sdk_error(FileNotFoundError(2, "No such file or directory"))
|
||||
|
||||
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.endpoint.host == "unix:///nope.sock"
|
||||
assert "FileNotFoundError" in info.value.detail
|
||||
|
||||
|
||||
def test_check_docker_connection_prints_the_fix_and_exits(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
reported: list[tuple[str, type | None]] = []
|
||||
monkeypatch.setattr(
|
||||
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")),
|
||||
)
|
||||
|
||||
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 "DOCKER NOT AVAILABLE" in out
|
||||
assert "usermod -aG docker" in out
|
||||
assert "Tried: default socket (default socket)" in out
|
||||
assert "PermissionError" in out
|
||||
|
||||
|
||||
def test_check_docker_connection_returns_the_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = object()
|
||||
monkeypatch.setattr(docker_connection, "connect_docker", lambda: client)
|
||||
assert cli_utils.check_docker_connection() is client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_backend_uses_the_same_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
resolved = object()
|
||||
monkeypatch.setattr(docker_connection, "connect_docker", lambda: resolved)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class FakeSession:
|
||||
async def start(self) -> None:
|
||||
captured["started"] = True
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, docker_client: Any) -> None:
|
||||
captured["docker_client"] = docker_client
|
||||
|
||||
async def create(self, *, options: Any, **_kwargs: Any) -> FakeSession:
|
||||
captured["image"] = options.image
|
||||
return FakeSession()
|
||||
|
||||
monkeypatch.setattr("strix.runtime.docker_client.StrixDockerSandboxClient", FakeClient)
|
||||
|
||||
client, session = await backends._docker_backend(
|
||||
image="img:1", manifest=cast("Any", SimpleNamespace()), exposed_ports=(8080,)
|
||||
)
|
||||
|
||||
assert captured["docker_client"] is resolved
|
||||
assert captured["image"] == "img:1"
|
||||
assert captured["started"] is True
|
||||
assert isinstance(client, FakeClient)
|
||||
assert isinstance(session, FakeSession)
|
||||
Loading…
Add table
Reference in a new issue