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