From 56009523c6f349f6e20a48b2b94f951462dac882 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 28 Sep 2026 11:28:35 +0200 Subject: [PATCH] Report an unreachable sbx SSH endpoint in diagnose The gateway tunnel runs `sbx ssh proxy`. In a container without the sbx cache, the CLI reads no feature flags and sees the daemon's SSH endpoint as off. The tunnel then fails, but diagnose still passed. diagnose now runs `sbx ssh proxy` and requires the daemon's SSH banner. The deployment guide mounts the directory of the daemon socket, not the socket file, and it gives the container the sbx cache and state paths. Refs #61. --- docs/deploy.md | 50 ++++++++++++++----- src/providers/docker_sbx/api.py | 8 +++ src/providers/docker_sbx/provider.py | 4 +- src/providers/docker_sbx/tests/test_api.py | 34 +++++++++++++ .../docker_sbx/tests/test_diagnose.py | 17 ++++++- 5 files changed, 98 insertions(+), 15 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index e332bed..baa4843 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -153,26 +153,50 @@ In this mode, no mounts and no extra variables are necessary. The CLI finds the daemon socket automatically. drukbox can also run as a container adjacent to the daemon. Docker does -not document this mode; drukbox uses the CLI's own daemon-endpoint -variable, `DOCKER_SANDBOXES_API`. Mount the daemon socket, the `sbx` -binary of the host (then the CLI version and the daemon version always -agree), the CLI auth store, and the workspace root: +not document this mode. Run the container with the uid of the daemon +owner. Mount the `sbx` binary of the host, so that the CLI version and +the daemon version always agree. Mount the sbx directories and the +drukbox directory at the same paths as on the host: ```bash docker run --rm --network host \ - --mount type=bind,src=$HOME/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock,dst=/run/sandboxd.sock \ + --user "$(id -u):$(id -g)" \ --mount type=bind,src=$(command -v sbx),dst=/usr/local/bin/sbx,readonly \ - --mount type=bind,src=$HOME/.config/com.docker.sandboxes,dst=/root/.config/com.docker.sandboxes,readonly \ - --mount type=bind,src=$HOME/.drukbox/sbx-workspaces,dst=$HOME/.drukbox/sbx-workspaces \ - --env DOCKER_SANDBOXES_API=unix:///run/sandboxd.sock \ + --mount type=bind,src=$HOME/.local/state/sandboxes/sandboxes/sandboxd,dst=$HOME/.local/state/sandboxes/sandboxes/sandboxd \ + --mount type=bind,src=$HOME/.cache/sandboxes,dst=$HOME/.cache/sandboxes \ + --mount type=bind,src=$HOME/.config/com.docker.sandboxes,dst=$HOME/.config/com.docker.sandboxes \ + --mount type=bind,src=$HOME/.config/sandboxes,dst=$HOME/.config/sandboxes \ + --mount type=bind,src=$HOME/.drukbox,dst=$HOME/.drukbox \ + --env XDG_CONFIG_HOME=$HOME/.config \ + --env XDG_CACHE_HOME=$HOME/.cache \ + --env XDG_STATE_HOME=$HOME/.local/state \ + --env DOCKER_SANDBOXES_API=unix://$HOME/.local/state/sandboxes/sandboxes/sandboxd/sandboxd.sock \ --env DOCKER_SBX_WORKSPACE_ROOT=$HOME/.drukbox/sbx-workspaces \ --env-file drukbox.env \ ghcr.io/czpython/drukbox:latest ``` -The daemon reads workspace paths on its own filesystem. Thus the -workspace mount must have the same path on the host and in the -container. The janitor and pool containers need the same mounts and +The container has no home directory for the daemon owner. Thus the +`XDG_*` variables point the CLI to the mounted directories. The mounts +have these reasons: + +- The daemon reads workspace paths on its own filesystem. Thus the + workspace root must have the same path on the host and in the + container. +- The mount holds the directory of the daemon socket, not the socket + file. A daemon restart makes a new socket, and a file mount keeps the + old one. +- `sbx ssh proxy` finds the socket through `XDG_STATE_HOME` only, and + it ignores `DOCKER_SANDBOXES_API`. The other commands use + `DOCKER_SANDBOXES_API`. +- The CLI reads its feature flags from the cache. Without the cache, + it sees the SSH endpoint of the daemon as off, and the gateway tunnel + fails. `/doctor` reports this failure. +- The auth store and the settings store must be writable. The CLI takes + a lock file in the auth store also for reads, and it writes the + settings store on first use. + +The janitor, pool, and gateway containers need the same mounts and variables. Callers reach the sandboxes through @@ -608,6 +632,6 @@ Docker Sandboxes provider: | `DOCKER_SBX_WORKSPACE_ROOT` | `~/.drukbox/sbx-workspaces` | Directory with one temporary workspace for each sandbox, and a `secrets` directory with the value files sbx reads. The path must be the same for drukbox and for the daemon. | The published image does not contain the `sbx` CLI. Mount the binary and -the auth store of the host, as +the sbx directories of the host, as [Local microVMs with Docker Sandboxes](#local-microvms-with-docker-sandboxes) -shows. Set `DOCKER_SANDBOXES_API` to the mounted daemon socket. +shows. diff --git a/src/providers/docker_sbx/api.py b/src/providers/docker_sbx/api.py index 8d40212..4ca5861 100644 --- a/src/providers/docker_sbx/api.py +++ b/src/providers/docker_sbx/api.py @@ -137,6 +137,14 @@ async def sandbox_count(self) -> int: f"sbx ls returned an unreadable sandbox list: {output.strip()!r}" ) from error + async def check_ssh_endpoint(self) -> None: + """The gateway tunnel needs the daemon's SSH endpoint. The CLI reads the + endpoint's feature flag from its cache, so a container without the + cache sees it as off. The daemon sends its banner for any name.""" + output = await self._run("ssh", "proxy", "drukbox-diagnose.sbx") + if not output.startswith("SSH-"): + raise DockerSbxTransportError(f"sbx ssh proxy sent no SSH banner: {output.strip()!r}") + async def _run(self, *args: str, stdin: str | None = None) -> str: try: process = await asyncio.create_subprocess_exec( diff --git a/src/providers/docker_sbx/provider.py b/src/providers/docker_sbx/provider.py index ee089c0..05ae75e 100644 --- a/src/providers/docker_sbx/provider.py +++ b/src/providers/docker_sbx/provider.py @@ -204,7 +204,9 @@ async def delete_template_image(self, image: str) -> None: raise ProviderTransportError(str(exc)) from exc async def diagnose(self) -> str: - return f"sandboxd reachable, {await self.api.sandbox_count()} sandbox(es)" + count = await self.api.sandbox_count() + await self.api.check_ssh_endpoint() + return f"sandboxd reachable, {count} sandbox(es), SSH endpoint ready" async def aclose(self) -> None: await self.docker.aclose() diff --git a/src/providers/docker_sbx/tests/test_api.py b/src/providers/docker_sbx/tests/test_api.py index 1de9bf1..06b43c9 100644 --- a/src/providers/docker_sbx/tests/test_api.py +++ b/src/providers/docker_sbx/tests/test_api.py @@ -184,6 +184,40 @@ async def test_sandbox_count_treats_a_null_list_as_empty(monkeypatch): assert await SbxCLI().sandbox_count() == 0 +@pytest.mark.asyncio +async def test_check_ssh_endpoint_accepts_the_daemon_banner(monkeypatch): + captured: dict = {} + + async def fake_exec(*args, **kwargs): + captured["args"] = args + return _process(stdout=b"SSH-2.0-Go\r\n") + + monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", fake_exec) + + await SbxCLI().check_ssh_endpoint() + + assert captured["args"][:3] == ("sbx", "ssh", "proxy") + + +@pytest.mark.asyncio +async def test_check_ssh_endpoint_reports_an_endpoint_the_cli_sees_as_off(monkeypatch): + disabled = b"error: the sandboxd SSH endpoint is disabled; restart sandboxd after enabling it" + create = AsyncMock(return_value=_process(returncode=1, stderr=disabled)) + monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", create) + + with pytest.raises(DockerSbxTransportError, match="SSH endpoint is disabled"): + await SbxCLI().check_ssh_endpoint() + + +@pytest.mark.asyncio +async def test_check_ssh_endpoint_rejects_output_without_a_banner(monkeypatch): + create = AsyncMock(return_value=_process(stdout=b"")) + monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", create) + + with pytest.raises(DockerSbxTransportError, match="no SSH banner"): + await SbxCLI().check_ssh_endpoint() + + @pytest.mark.asyncio async def test_remove_sandbox_forces_removal_of_an_attached_sandbox(monkeypatch): captured: dict = {} diff --git a/src/providers/docker_sbx/tests/test_diagnose.py b/src/providers/docker_sbx/tests/test_diagnose.py index 9228d32..7b0fa64 100644 --- a/src/providers/docker_sbx/tests/test_diagnose.py +++ b/src/providers/docker_sbx/tests/test_diagnose.py @@ -15,8 +15,23 @@ def _provider(api: MagicMock) -> DockerSbxProvider: async def test_diagnose_reports_daemon_reachability(): api = MagicMock() api.sandbox_count = AsyncMock(return_value=2) + api.check_ssh_endpoint = AsyncMock() - assert await _provider(api).diagnose() == "sandboxd reachable, 2 sandbox(es)" + assert ( + await _provider(api).diagnose() == "sandboxd reachable, 2 sandbox(es), SSH endpoint ready" + ) + + +@pytest.mark.asyncio +async def test_diagnose_raises_when_the_ssh_endpoint_is_unreachable(): + api = MagicMock() + api.sandbox_count = AsyncMock(return_value=2) + api.check_ssh_endpoint = AsyncMock( + side_effect=DockerSbxTransportError("the sandboxd SSH endpoint is disabled") + ) + + with pytest.raises(DockerSbxTransportError): + await _provider(api).diagnose() @pytest.mark.asyncio