400f173b0b
prd-number-check / require-numbered-prds (pull_request) Successful in 10s
lint / lint (push) Successful in 57s
tracker-policy-pr / check-pr (pull_request) Failing after 14m34s
test / coverage (pull_request) Blocked by required conditions
test / integration-docker (pull_request) Blocked by required conditions
test / unit (pull_request) Has started running
test / image-input-builds (pull_request) Has started running
The install itself now works end to end on a fresh macOS account — venv built,
package installed from git, entry point linked — and `doctor` then died with an
unhandled traceback:
PermissionError: [Errno 13] Permission denied: 'ip'
netpool's probe helpers caught only FileNotFoundError. That is not the only way
a probe binary can be unavailable: when a name on PATH exists but this user
cannot execute it, exec fails with EACCES, and CPython reports that in
preference to the ENOENT from the other PATH entries. So `except
FileNotFoundError` misses it and the crash propagates all the way out of
`doctor`. Reproduced directly: a mode-000 file named `ip` on PATH yields
exactly the error above.
_run_ok's docstring already stated the intent — treat an unavailable binary as
failure rather than crashing — so this widens the catch to OSError to match
what it says. Any OSError means the probe could not run, which for a
fail-closed check is indistinguishable from "not present". The same narrow
catch is fixed in overlapping_routes and in the two docker probes
(compose ls, docker ps), which are the same shape and equally reachable.
Deliberately not touched: the FileNotFoundError catches around file I/O in
bottle_state and orchestrator/service, where the narrow exception is correct.
The harness also required `bot-bottle` on PATH before running doctor, which
could never be true: install.sh prints the PATH line rather than editing a
shell profile, by design, so on a fresh account the entry point is installed
and working but not on PATH. It now looks where the installer actually puts
it (~/.local/bin, then the venv) and notes when it's running by absolute path.
That was the harness failing a run for a reason the installer intends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
86 lines
3.1 KiB
Python
86 lines
3.1 KiB
Python
"""Unit: docker compose lifecycle helpers (PRD 0018).
|
|
|
|
The compose *spec* is built by `consolidated_compose.py`; these
|
|
tests cover the I/O-side helpers in `compose.py` — the slug ↔
|
|
project mapping and `docker compose ls` enumeration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from bot_bottle.backend.docker.compose import (
|
|
COMPOSE_PROJECT_PREFIX,
|
|
compose_project_name,
|
|
list_active_slugs,
|
|
list_compose_projects,
|
|
slug_from_compose_project,
|
|
)
|
|
|
|
|
|
class TestProjectNaming(unittest.TestCase):
|
|
"""The slug ↔ compose-project mapping is the contract dashboard,
|
|
cleanup, and launch all rely on. Lock it down."""
|
|
|
|
def test_compose_project_name_is_prefix_plus_slug(self):
|
|
self.assertEqual(
|
|
f"{COMPOSE_PROJECT_PREFIX}myagent-abc12",
|
|
compose_project_name("myagent-abc12"),
|
|
)
|
|
|
|
def test_slug_from_compose_project_is_inverse(self):
|
|
self.assertEqual(
|
|
"myagent-abc12",
|
|
slug_from_compose_project(f"{COMPOSE_PROJECT_PREFIX}myagent-abc12"),
|
|
)
|
|
|
|
def test_slug_from_unrelated_project_returns_empty(self):
|
|
# Defends against `docker compose ls` including non-bottle
|
|
# projects on a host with other compose setups.
|
|
self.assertEqual("", slug_from_compose_project("other-project"))
|
|
|
|
|
|
class TestComposeProjectListing(unittest.TestCase):
|
|
def test_compose_ls_empty_when_docker_unusable(self):
|
|
# Missing is the obvious case; present-but-not-executable raises
|
|
# PermissionError instead, which must not escape as a crash.
|
|
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "docker")):
|
|
with self.subTest(exc=type(exc).__name__):
|
|
with mock.patch(
|
|
"bot_bottle.backend.docker.compose.subprocess.run",
|
|
side_effect=exc,
|
|
):
|
|
self.assertEqual([], list_compose_projects())
|
|
|
|
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()
|