fix(dashboard): quiet docker polling errors
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Successful in 41s

This commit is contained in:
2026-05-28 18:33:13 -04:00
parent cdb1870b1c
commit 7f3998e79e
3 changed files with 51 additions and 7 deletions
+18 -6
View File
@@ -371,7 +371,9 @@ def slug_from_compose_project(project: str) -> str:
return project[len(COMPOSE_PROJECT_PREFIX):] 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-`. """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
@@ -379,7 +381,10 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
Returns [] on docker daemon errors or malformed output rather Returns [] on docker daemon errors or malformed output rather
than raising — callers should treat the empty list as "no 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"] argv = ["docker", "compose", "ls", "--format", "json"]
if include_stopped: if include_stopped:
argv.insert(3, "--all") 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. # error from the caller's POV: no projects discoverable.
return [] return []
if result.returncode != 0: 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 [] 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:
warn(f"docker compose ls returned malformed JSON: {e}") if warn_on_error:
warn(f"docker compose ls returned malformed JSON: {e}")
return [] return []
names: list[str] = [] names: list[str] = []
for p in projects: for p in projects:
@@ -409,14 +416,19 @@ def list_compose_projects(*, include_stopped: bool = True) -> list[str]:
return sorted(set(names)) 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 """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
a bottle to apply a config edit to.""" a bottle to apply a config edit to."""
return sorted( return sorted(
slug for slug in ( slug for slug in (
slug_from_compose_project(p) 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 ) if slug
) )
+1 -1
View File
@@ -24,7 +24,7 @@ def enumerate_active() -> list[ActiveAgent]:
responsible for gating on `has_backend('docker')` if it responsible for gating on `has_backend('docker')` if it
matters; if docker is missing the `docker ps` call below matters; if docker is missing the `docker ps` call below
returns an empty list silently.""" 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: if not slugs:
return [] return []
services_by_project = _query_services_by_project() services_by_project = _query_services_by_project()
+32
View File
@@ -9,8 +9,10 @@ supervise on/off).
from __future__ import annotations from __future__ import annotations
import subprocess
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
@@ -18,6 +20,8 @@ from bot_bottle.backend.docker.compose import (
COMPOSE_PROJECT_PREFIX, COMPOSE_PROJECT_PREFIX,
bottle_plan_to_compose, bottle_plan_to_compose,
compose_project_name, compose_project_name,
list_active_slugs,
list_compose_projects,
slug_from_compose_project, slug_from_compose_project,
) )
from bot_bottle.egress import ( from bot_bottle.egress import (
@@ -455,5 +459,33 @@ class TestProjectNaming(unittest.TestCase):
self.assertEqual("", slug_from_compose_project("other-project")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()