diff --git a/src/agent_harness/adapters/docker.py b/src/agent_harness/adapters/docker.py index 65ed2e4..950d36a 100644 --- a/src/agent_harness/adapters/docker.py +++ b/src/agent_harness/adapters/docker.py @@ -177,28 +177,62 @@ def close(self) -> None: self.started = False +def _diagnosis(stderr: str) -> str: + """The line a person can act on, not the last line the CLI printed. + + Go template noise is dropped: it is a consequence of the daemon being + absent, never the reason, and letting it win means readiness blames + reflection for a stopped service. + """ + lines = [line.strip() for line in stderr.splitlines() if line.strip()] + useful = [ + line + for line in lines + if not line.startswith("template:") and "reflect:" not in line and line != "ERROR:" + ] + return (useful or lines or ["no reason given"])[0].removeprefix("ERROR: ").strip() + + +def _server_version(stdout: str) -> str: + for line in stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("Server Version:"): + return stripped.split(":", 1)[1].strip() + return "" + + class DockerEnvironmentFactory: name = "docker" api_version = API_VERSION version = "docker-cli" def check(self) -> tuple[bool, str]: + """Is a daemon reachable, and what should an operator be told? + + `docker info` is asked **without** `--format`. With a format string, + an unreachable daemon does not produce the message a person needs: the + CLI still renders a mostly-nil `Info` struct, so the last line of + stderr is a Go template error about "indirection through nil pointer + to embedded struct field Info", and the line that actually says the + daemon could not be reached is buried above it. Readiness then reports + a reflect error, which names the wrong component — the failure mode + this repository keeps paying for. + """ if shutil.which("docker") is None: return False, "docker is not installed or is not on PATH" try: result = subprocess.run( - ["docker", "info", "--format", "{{.ServerVersion}}"], + ["docker", "info"], capture_output=True, text=True, - timeout=5, + timeout=10, check=False, ) except (OSError, subprocess.SubprocessError) as exc: return False, f"Docker daemon check failed: {exc}" if result.returncode != 0: - detail = result.stderr.strip().splitlines() - return False, detail[-1] if detail else "Docker daemon is unavailable" - return True, f"Docker daemon {result.stdout.strip() or 'available'}" + return False, f"Docker daemon is unreachable: {_diagnosis(result.stderr)}" + return True, f"Docker daemon {_server_version(result.stdout) or 'available'}" def reap(self, worktree: Path) -> None: """Remove containers left by a killed controller for one item tree.""" diff --git a/tests/test_execution_environment.py b/tests/test_execution_environment.py index 4514caf..528b5d0 100644 --- a/tests/test_execution_environment.py +++ b/tests/test_execution_environment.py @@ -8,7 +8,12 @@ import pytest -from agent_harness.adapters.docker import DockerEnvironmentFactory, DockerItemEnvironment +from agent_harness.adapters.docker import ( + DockerEnvironmentFactory, + DockerItemEnvironment, + _diagnosis, + _server_version, +) from agent_harness.execution_environment import EnvironmentMount, EnvironmentSpec from agent_harness.execution_environments import names, probe, resolve @@ -21,6 +26,39 @@ def test_docker_backend_is_selected_by_installed_metadata() -> None: assert backend.name == "docker" +def test_readiness_names_the_daemon_not_a_go_template_error() -> None: + """What an operator reads when the fleet will not start. + + Asked with `--format`, an unreachable daemon makes the CLI render a nil + `Info` struct, so the LAST line of stderr is a Go template error and the + line that says the daemon could not be reached is above it. Reporting the + last line blamed reflection for a stopped service -- found when this image + was first built, where the CLI is installed and no daemon answers. + """ + stderr = ( + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. " + "Is the docker daemon running?\n" + 'template: :1:2: executing "" at <.ServerVersion>: reflect: ' + "indirection through nil pointer to embedded struct field Info\n" + ) + + assert _diagnosis(stderr).startswith("Cannot connect to the Docker daemon") + assert "reflect:" not in _diagnosis(stderr) + assert "template:" not in _diagnosis(stderr) + # Nothing usable at all still says which component is being reported on. + assert _diagnosis("") == "no reason given" + assert _diagnosis("template: bad\n") == "template: bad" + + +def test_readiness_reports_the_server_version_when_a_daemon_answers() -> None: + info = ( + "Client:\n Version: 28.0.1\nServer:\n Server Version: 28.0.1\n Storage Driver: overlay2\n" + ) + + assert _server_version(info) == "28.0.1" + assert _server_version("Client:\n Version: 28.0.1\n") == "" + + def test_environment_spec_rejects_host_networking_and_unsafe_mounts(tmp_path: Path) -> None: with pytest.raises(ValueError, match="host networking"): EnvironmentSpec(image="rust:1", worktree=tmp_path.resolve(), network="host")