From 15ecada0224a5ae18e4bfe97be908ad3efddb33e Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 22:27:59 +0000 Subject: [PATCH] fix(docker): surface enumeration failures --- bot_bottle/backend/docker/compose.py | 37 ++++++++++++++-------- bot_bottle/backend/docker/enumerate.py | 22 ++++++------- tests/unit/test_compose.py | 13 ++++++++ tests/unit/test_docker_enumerate_active.py | 14 ++++++++ 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 7cfe940b..b7ed2d39 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -52,19 +52,20 @@ def slug_from_compose_project(project: str) -> str: def list_compose_projects( - *, include_stopped: bool = True, warn_on_error: bool = True, + *, + include_stopped: bool = True, + warn_on_error: bool = True, + raise_on_error: bool = False, ) -> list[str]: """All compose project names starting with `bot-bottle-`. `include_stopped=True` (default) runs `docker compose ls --all` so exited projects appear too; pass False to get only projects with at least one running container. - Returns [] on docker daemon errors or malformed output rather - than raising — callers should treat the empty list as "no - projects discoverable", not "no projects exist". `warn_on_error` - stays true for explicit operator commands like cleanup, but active - discovery paths set it false so dashboard refreshes don't spam - stderr while Docker Desktop is stopped.""" + Best-effort callers get ``[]`` on Docker errors or malformed output. + Enumeration callers pass ``raise_on_error=True`` so a failed query is not + reported as an authoritative empty result. + """ argv = ["docker", "compose", "ls", "--format", "json"] if include_stopped: argv.insert(3, "--all") @@ -72,19 +73,25 @@ def list_compose_projects( result = subprocess.run( argv, capture_output=True, text=True, check=False, ) - except FileNotFoundError: - # docker binary not on PATH — same shape as a daemon-down - # error from the caller's POV: no projects discoverable. + except FileNotFoundError as exc: + if raise_on_error: + raise RuntimeError("docker compose ls failed: docker not found") from exc return [] if result.returncode != 0: + message = f"docker compose ls failed: {result.stderr.strip()}" + if raise_on_error: + raise RuntimeError(message) if warn_on_error: - warn(f"docker compose ls failed: {result.stderr.strip()}") + warn(message) return [] try: projects = json.loads(result.stdout or "[]") except json.JSONDecodeError as e: + message = f"docker compose ls returned malformed JSON: {e}" + if raise_on_error: + raise RuntimeError(message) from e if warn_on_error: - warn(f"docker compose ls returned malformed JSON: {e}") + warn(message) return [] names: list[str] = [] for p in projects: @@ -97,7 +104,10 @@ def list_compose_projects( def list_active_slugs( - *, include_stopped: bool = False, warn_on_error: bool = True, + *, + include_stopped: bool = False, + warn_on_error: bool = True, + raise_on_error: bool = False, ) -> list[str]: """Slugs (project name minus prefix) of currently-running bottles. Used by the dashboard's operator-edit verbs to choose @@ -108,6 +118,7 @@ def list_active_slugs( for p in list_compose_projects( include_stopped=include_stopped, warn_on_error=warn_on_error, + raise_on_error=raise_on_error, ) ) if slug ) diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index 8bfd91cf..f7c28431 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -1,9 +1,8 @@ """Active-agent enumeration for the docker backend. Returns `ActiveAgent` records the CLI `active` command and the -dashboard agents pane consume. Empty when docker isn't reachable -— gated by `has_backend('docker')` at the cross-backend caller -so this module trusts that docker is available when called. +dashboard agents pane consume. Docker query failures raise rather +than masquerading as an authoritative empty result. The parser (`_parse_services_by_project`) is exposed for direct unit testing; the docker `docker ps` invocation is in @@ -19,11 +18,12 @@ from .compose import compose_project_name, list_active_slugs def enumerate_active() -> list[ActiveAgent]: - """All currently-running docker-backed agents. Caller is - responsible for gating on `has_backend('docker')` if it - matters; if docker is missing the `docker ps` call below - returns an empty list silently.""" - slugs = list_active_slugs(include_stopped=False, warn_on_error=False) + """All currently-running docker-backed agents.""" + slugs = list_active_slugs( + include_stopped=False, + warn_on_error=False, + raise_on_error=True, + ) if not slugs: return [] services_by_project = _query_services_by_project() @@ -74,8 +74,8 @@ def _query_services_by_project() -> dict[str, set[str]]: ], capture_output=True, text=True, check=False, ) - except FileNotFoundError: - return {} + except FileNotFoundError as exc: + raise RuntimeError("docker ps failed: docker not found") from exc if r.returncode != 0: - return {} + raise RuntimeError(f"docker ps failed: {r.stderr.strip()}") return _parse_services_by_project(r.stdout or "") diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 95f5301e..2fe94b16 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -69,6 +69,19 @@ class TestComposeProjectListing(unittest.TestCase): self.assertEqual([], list_active_slugs(warn_on_error=False)) warn.assert_not_called() + def test_compose_ls_error_can_be_raised_for_enumeration(self): + with mock.patch( + "bot_bottle.backend.docker.compose.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["docker"], returncode=1, stdout="", stderr="no daemon", + ), + ): + with self.assertRaisesRegex(RuntimeError, "no daemon"): + list_active_slugs( + warn_on_error=False, + raise_on_error=True, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index c774c282..15cf5bd5 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -19,9 +19,11 @@ of issue #77 — the dashboard now delegates to this layer. from __future__ import annotations +import subprocess import tempfile import unittest from pathlib import Path +from unittest.mock import patch from tests.unit import use_bottle_root from bot_bottle import bottle_state @@ -72,6 +74,18 @@ class TestParseServicesByProject(unittest.TestCase): self.assertEqual({"bot-bottle-dev-abc": {"egress"}}, out) +class TestQueryServicesByProject(unittest.TestCase): + def test_docker_ps_failure_is_not_an_empty_result(self): + with patch( + "bot_bottle.backend.docker.enumerate.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["docker"], returncode=1, stdout="", stderr="daemon down", + ), + ): + with self.assertRaisesRegex(RuntimeError, "daemon down"): + _enumerate._query_services_by_project() + + class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.")