fix(docker): surface enumeration failures

This commit is contained in:
2026-07-26 22:27:59 +00:00
parent e2222bd96b
commit 15ecada022
4 changed files with 62 additions and 24 deletions
+24 -13
View File
@@ -52,19 +52,20 @@ def slug_from_compose_project(project: str) -> str:
def list_compose_projects( 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]: ) -> list[str]:
"""All compose project names starting with `bot-bottle-`. """All compose project names starting with `bot-bottle-`.
`include_stopped=True` (default) runs `docker compose ls --all` `include_stopped=True` (default) runs `docker compose ls --all`
so exited projects appear too; pass False to get only projects so exited projects appear too; pass False to get only projects
with at least one running container. with at least one running container.
Returns [] on docker daemon errors or malformed output rather Best-effort callers get ``[]`` on Docker errors or malformed output.
than raising — callers should treat the empty list as "no Enumeration callers pass ``raise_on_error=True`` so a failed query is not
projects discoverable", not "no projects exist". `warn_on_error` reported as an authoritative empty result.
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."""
argv = ["docker", "compose", "ls", "--format", "json"] argv = ["docker", "compose", "ls", "--format", "json"]
if include_stopped: if include_stopped:
argv.insert(3, "--all") argv.insert(3, "--all")
@@ -72,19 +73,25 @@ def list_compose_projects(
result = subprocess.run( result = subprocess.run(
argv, capture_output=True, text=True, check=False, argv, capture_output=True, text=True, check=False,
) )
except FileNotFoundError: except FileNotFoundError as exc:
# docker binary not on PATH — same shape as a daemon-down if raise_on_error:
# error from the caller's POV: no projects discoverable. raise RuntimeError("docker compose ls failed: docker not found") from exc
return [] return []
if result.returncode != 0: if result.returncode != 0:
message = f"docker compose ls failed: {result.stderr.strip()}"
if raise_on_error:
raise RuntimeError(message)
if warn_on_error: if warn_on_error:
warn(f"docker compose ls failed: {result.stderr.strip()}") warn(message)
return [] return []
try: try:
projects = json.loads(result.stdout or "[]") projects = json.loads(result.stdout or "[]")
except json.JSONDecodeError as e: 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: if warn_on_error:
warn(f"docker compose ls returned malformed JSON: {e}") warn(message)
return [] return []
names: list[str] = [] names: list[str] = []
for p in projects: for p in projects:
@@ -97,7 +104,10 @@ def list_compose_projects(
def list_active_slugs( 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]: ) -> list[str]:
"""Slugs (project name minus prefix) of currently-running """Slugs (project name minus prefix) of currently-running
bottles. Used by the dashboard's operator-edit verbs to choose 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( for p in list_compose_projects(
include_stopped=include_stopped, include_stopped=include_stopped,
warn_on_error=warn_on_error, warn_on_error=warn_on_error,
raise_on_error=raise_on_error,
) )
) if slug ) if slug
) )
+11 -11
View File
@@ -1,9 +1,8 @@
"""Active-agent enumeration for the docker backend. """Active-agent enumeration for the docker backend.
Returns `ActiveAgent` records the CLI `active` command and the Returns `ActiveAgent` records the CLI `active` command and the
dashboard agents pane consume. Empty when docker isn't reachable dashboard agents pane consume. Docker query failures raise rather
— gated by `has_backend('docker')` at the cross-backend caller than masquerading as an authoritative empty result.
so this module trusts that docker is available when called.
The parser (`_parse_services_by_project`) is exposed for direct The parser (`_parse_services_by_project`) is exposed for direct
unit testing; the docker `docker ps` invocation is in 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]: def enumerate_active() -> list[ActiveAgent]:
"""All currently-running docker-backed agents. Caller is """All currently-running docker-backed agents."""
responsible for gating on `has_backend('docker')` if it slugs = list_active_slugs(
matters; if docker is missing the `docker ps` call below include_stopped=False,
returns an empty list silently.""" warn_on_error=False,
slugs = list_active_slugs(include_stopped=False, warn_on_error=False) raise_on_error=True,
)
if not slugs: if not slugs:
return [] return []
services_by_project = _query_services_by_project() 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, capture_output=True, text=True, check=False,
) )
except FileNotFoundError: except FileNotFoundError as exc:
return {} raise RuntimeError("docker ps failed: docker not found") from exc
if r.returncode != 0: if r.returncode != 0:
return {} raise RuntimeError(f"docker ps failed: {r.stderr.strip()}")
return _parse_services_by_project(r.stdout or "") return _parse_services_by_project(r.stdout or "")
+13
View File
@@ -69,6 +69,19 @@ class TestComposeProjectListing(unittest.TestCase):
self.assertEqual([], list_active_slugs(warn_on_error=False)) self.assertEqual([], list_active_slugs(warn_on_error=False))
warn.assert_not_called() 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -19,9 +19,11 @@ of issue #77 — the dashboard now delegates to this layer.
from __future__ import annotations from __future__ import annotations
import subprocess
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
@@ -72,6 +74,18 @@ class TestParseServicesByProject(unittest.TestCase):
self.assertEqual({"bot-bottle-dev-abc": {"egress"}}, out) 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: class _FakeHomeMixin:
def _setup_fake_home(self) -> None: def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.")