fix(docker): surface enumeration failures
This commit is contained in:
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user