mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 05:19:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f143148cb5 | ||
|
|
c08614b659 | ||
|
|
8bc63659b7 | ||
|
|
1389ca2e33 | ||
|
|
bc5178ac67 |
No files matched your search
@@ -150,7 +150,7 @@ 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 not set, Strix connects to the same daemon as the `docker` CLI: the current docker context (`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,20 +1599,21 @@ 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
|
||||
|
||||
try:
|
||||
return docker.from_env()
|
||||
except DockerException as exc:
|
||||
report_error("docker_unavailable", exc)
|
||||
return connect_docker()
|
||||
except DockerConnectionError as exc:
|
||||
report_error("docker_unavailable", exc.cause)
|
||||
console = Console()
|
||||
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(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(
|
||||
"Please ensure Docker Desktop is installed and running, and try running strix again.\n",
|
||||
"Make sure Docker is running. If `docker info` works in this shell, strix uses "
|
||||
"the same daemon. Otherwise set DOCKER_HOST.\n",
|
||||
style="white",
|
||||
)
|
||||
|
||||
@@ -1624,7 +1625,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)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DockerEndpoint:
|
||||
"""Where strix talks 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} ({self.source})" if self.host else self.source
|
||||
|
||||
|
||||
class DockerConnectionError(RuntimeError):
|
||||
"""The daemon at ``endpoint`` could not be reached."""
|
||||
|
||||
def __init__(self, endpoint: DockerEndpoint, cause: BaseException) -> None:
|
||||
self.endpoint = endpoint
|
||||
self.cause = root_cause(cause)
|
||||
super().__init__(f"Cannot connect to Docker at {endpoint.label}: {self.detail}")
|
||||
|
||||
@property
|
||||
def detail(self) -> str:
|
||||
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:
|
||||
"""``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 = get_current_context_name()
|
||||
if name == DEFAULT_CONTEXT:
|
||||
return DockerEndpoint(None, "default socket")
|
||||
|
||||
source = f"docker context '{name}'"
|
||||
try:
|
||||
context = ContextAPI.get_context(name)
|
||||
except DockerException as exc:
|
||||
raise DockerConnectionError(DockerEndpoint(None, source), exc) from exc
|
||||
if context is None:
|
||||
missing = DockerException(f"{source} is selected but does not exist")
|
||||
raise DockerConnectionError(DockerEndpoint(None, source), missing)
|
||||
return DockerEndpoint(context.Host, source, context.TLSConfig)
|
||||
|
||||
|
||||
def connect_docker() -> Any:
|
||||
"""Return a ``docker.DockerClient`` for the resolved endpoint or raise DockerConnectionError."""
|
||||
endpoint = resolve_docker_endpoint()
|
||||
try:
|
||||
if endpoint.source == "DOCKER_HOST" or endpoint.host is None:
|
||||
return docker.from_env() # also reads DOCKER_TLS_VERIFY and DOCKER_CERT_PATH
|
||||
return docker.DockerClient(base_url=endpoint.host, tls=endpoint.tls or False)
|
||||
except DockerException as exc:
|
||||
raise DockerConnectionError(endpoint, exc) from exc
|
||||
|
||||
|
||||
def root_cause(exc: BaseException) -> BaseException:
|
||||
"""The innermost exception: the SDK wraps the OSError in requests/urllib3 errors."""
|
||||
current = exc
|
||||
seen: list[BaseException] = []
|
||||
while not any(current is s for s in seen):
|
||||
seen.append(current)
|
||||
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
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Docker endpoint resolution and the panel 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 (
|
||||
DockerConnectionError,
|
||||
DockerEndpoint,
|
||||
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_default_context_uses_the_sdk_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "default")
|
||||
assert resolve_docker_endpoint({}) == DockerEndpoint(None, "default socket")
|
||||
|
||||
|
||||
def test_missing_or_broken_current_context_is_an_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(docker_connection, "get_current_context_name", lambda: "gone")
|
||||
monkeypatch.setattr(
|
||||
"strix.runtime.docker_connection.ContextAPI.get_context", lambda _name: None
|
||||
)
|
||||
with pytest.raises(
|
||||
DockerConnectionError, match="docker context 'gone' is selected but does not exist"
|
||||
):
|
||||
resolve_docker_endpoint({})
|
||||
|
||||
def boom(_name: str) -> None:
|
||||
raise DockerException("bad meta.json")
|
||||
|
||||
monkeypatch.setattr("strix.runtime.docker_connection.ContextAPI.get_context", boom)
|
||||
with pytest.raises(DockerConnectionError, match="bad meta") as info:
|
||||
resolve_docker_endpoint({})
|
||||
assert info.value.endpoint.label == "docker context 'gone'"
|
||||
|
||||
|
||||
def test_docker_host_goes_through_from_env_so_tls_settings_apply(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
docker_connection,
|
||||
"resolve_docker_endpoint",
|
||||
lambda _environ=None: DockerEndpoint("tcp://10.0.0.5:2376", "DOCKER_HOST"),
|
||||
)
|
||||
client = object()
|
||||
monkeypatch.setattr("strix.runtime.docker_connection.docker.from_env", lambda: client)
|
||||
|
||||
def unexpected(**_kwargs: Any) -> None:
|
||||
raise AssertionError("DOCKER_HOST must not bypass from_env")
|
||||
|
||||
monkeypatch.setattr("strix.runtime.docker_connection.docker.DockerClient", unexpected)
|
||||
assert docker_connection.connect_docker() is client
|
||||
|
||||
|
||||
def test_context_endpoint_keeps_its_tls_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
tls = object()
|
||||
monkeypatch.setattr(
|
||||
docker_connection,
|
||||
"resolve_docker_endpoint",
|
||||
lambda _environ=None: DockerEndpoint("tcp://remote:2376", "docker context 'remote'", tls),
|
||||
)
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def client(**kwargs: Any) -> str:
|
||||
seen.update(kwargs)
|
||||
return "client"
|
||||
|
||||
monkeypatch.setattr("strix.runtime.docker_connection.docker.DockerClient", client)
|
||||
assert docker_connection.connect_docker() == "client"
|
||||
assert seen == {"base_url": "tcp://remote:2376", "tls": tls}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"inner",
|
||||
[
|
||||
FileNotFoundError(2, "No such file or directory"),
|
||||
PermissionError(13, "Permission denied"),
|
||||
ConnectionRefusedError(111, "Connection refused"),
|
||||
],
|
||||
)
|
||||
def test_connect_docker_surfaces_the_root_cause(
|
||||
monkeypatch: pytest.MonkeyPatch, inner: OSError
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
docker_connection,
|
||||
"resolve_docker_endpoint",
|
||||
lambda _environ=None: DockerEndpoint("unix:///nope.sock", "docker context 'dead'"),
|
||||
)
|
||||
|
||||
def dead_client(**_kwargs: Any) -> None:
|
||||
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.cause is inner
|
||||
assert info.value.detail.startswith(type(inner).__name__)
|
||||
assert info.value.endpoint.host == "unix:///nope.sock"
|
||||
|
||||
|
||||
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]] = []
|
||||
monkeypatch.setattr(
|
||||
cli_utils, "report_error", lambda name, exc=None: reported.append((name, type(exc)))
|
||||
)
|
||||
error = DockerConnectionError(
|
||||
DockerEndpoint(None, "default socket"), _sdk_error(PermissionError(13, "Permission denied"))
|
||||
)
|
||||
|
||||
def failing_connect() -> None:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(docker_connection, "connect_docker", failing_connect)
|
||||
|
||||
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", PermissionError)]
|
||||
assert "DOCKER NOT AVAILABLE" in out
|
||||
assert "Cannot connect to Docker at 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:
|
||||
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 == {"docker_client": resolved, "image": "img:1", "started": True}
|
||||
assert isinstance(client, FakeClient)
|
||||
assert isinstance(session, FakeSession)
|
||||
Reference in new issue
Block a user