diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 7d613a2..bf83e5c 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -371,7 +371,9 @@ def slug_from_compose_project(project: str) -> str: return project[len(COMPOSE_PROJECT_PREFIX):] -def list_compose_projects(*, include_stopped: bool = True) -> list[str]: +def list_compose_projects( + *, include_stopped: bool = True, warn_on_error: bool = True, +) -> 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 @@ -379,7 +381,10 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]: 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".""" + 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.""" argv = ["docker", "compose", "ls", "--format", "json"] if include_stopped: argv.insert(3, "--all") @@ -392,12 +397,14 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]: # error from the caller's POV: no projects discoverable. return [] if result.returncode != 0: - warn(f"docker compose ls failed: {result.stderr.strip()}") + if warn_on_error: + warn(f"docker compose ls failed: {result.stderr.strip()}") return [] try: projects = json.loads(result.stdout or "[]") except json.JSONDecodeError as e: - warn(f"docker compose ls returned malformed JSON: {e}") + if warn_on_error: + warn(f"docker compose ls returned malformed JSON: {e}") return [] names: list[str] = [] for p in projects: @@ -409,14 +416,19 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]: return sorted(set(names)) -def list_active_slugs(*, include_stopped: bool = False) -> list[str]: +def list_active_slugs( + *, include_stopped: bool = False, warn_on_error: bool = True, +) -> list[str]: """Slugs (project name minus prefix) of currently-running bottles. Used by the dashboard's operator-edit verbs to choose a bottle to apply a config edit to.""" return sorted( slug for slug in ( slug_from_compose_project(p) - for p in list_compose_projects(include_stopped=include_stopped) + for p in list_compose_projects( + include_stopped=include_stopped, + warn_on_error=warn_on_error, + ) ) if slug ) diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index d0179a9..b57fc83 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -24,7 +24,7 @@ def enumerate_active() -> list[ActiveAgent]: 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) + slugs = list_active_slugs(include_stopped=False, warn_on_error=False) if not slugs: return [] services_by_project = _query_services_by_project() diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 901c34c..b12dd83 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -9,8 +9,10 @@ supervise on/off). from __future__ import annotations +import subprocess import unittest from pathlib import Path +from unittest import mock from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan @@ -18,6 +20,8 @@ from bot_bottle.backend.docker.compose import ( COMPOSE_PROJECT_PREFIX, bottle_plan_to_compose, compose_project_name, + list_active_slugs, + list_compose_projects, slug_from_compose_project, ) from bot_bottle.egress import ( @@ -455,5 +459,33 @@ class TestProjectNaming(unittest.TestCase): self.assertEqual("", slug_from_compose_project("other-project")) +class TestComposeProjectListing(unittest.TestCase): + def test_compose_ls_error_warns_by_default(self): + with ( + mock.patch( + "bot_bottle.backend.docker.compose.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["docker"], returncode=1, stdout="", stderr="no daemon", + ), + ), + mock.patch("bot_bottle.backend.docker.compose.warn") as warn, + ): + self.assertEqual([], list_compose_projects()) + warn.assert_called_once_with("docker compose ls failed: no daemon") + + def test_compose_ls_error_can_be_quiet_for_dashboard_polling(self): + with ( + mock.patch( + "bot_bottle.backend.docker.compose.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["docker"], returncode=1, stdout="", stderr="no daemon", + ), + ), + mock.patch("bot_bottle.backend.docker.compose.warn") as warn, + ): + self.assertEqual([], list_active_slugs(warn_on_error=False)) + warn.assert_not_called() + + if __name__ == "__main__": unittest.main()