From 0ca39cf4d58ef091941f947633934fe01075a07a Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 24 Jul 2026 03:03:00 +0000 Subject: [PATCH] feat(backend): BackendStatus enum, quiet status(), is_backend_ready() Add a module-level BackendStatus(IntEnum) with READY=0 to replace magic 0 comparisons. Extend BottleBackend.status() with a quiet parameter: quiet=False (default) prints diagnostics; quiet=True returns the code silently for programmatic checks. Add is_backend_available() (cheap PATH check, alias for has_backend) and is_backend_ready(name, *, quiet=False) (full status() check) to the backend package for callers that need to distinguish availability from readiness. Update tests/_backend.py guards to use is_backend_ready(quiet=False) so diagnostic output is printed during test discovery, giving the operator a concrete reason for each skip rather than a bare "prerequisites unavailable" message. Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/backend/__init__.py | 47 +++++++++++++++++-- bot_bottle/backend/docker/backend.py | 8 +++- bot_bottle/backend/firecracker/backend.py | 8 +++- bot_bottle/backend/macos_container/backend.py | 8 +++- tests/_backend.py | 32 ++++++++----- 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index ff5223e..cc5be62 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -33,6 +33,7 @@ backend field; the host picks. from __future__ import annotations +import enum import os import shlex import sys @@ -59,6 +60,12 @@ if TYPE_CHECKING: from .freeze import CommitCancelled, Freezer, get_freezer +class BackendStatus(enum.IntEnum): + """Return codes for BottleBackend.status(). READY == 0 so callsites + can compare against 0 or the named constant interchangeably.""" + READY = 0 + + @dataclass(frozen=True) class BottleSpec: """CLI-supplied intent. Backend-agnostic — each backend's prepare @@ -611,12 +618,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): @classmethod @abstractmethod - def status(cls) -> int: + def status(cls, *, quiet: bool = False) -> int: """Report whether this backend's prerequisites are satisfied on the host — binaries, daemon reachability, network pool, range - conflicts, etc. Prints a human-readable summary; returns 0 when - the backend is ready to launch and non-zero when something is - missing. Invoked by `./cli.py backend status [--backend=…]`.""" + conflicts, etc. Returns BackendStatus.READY (0) when the backend + is ready to launch and non-zero when something is missing. + + When quiet=False (default) prints a human-readable summary to + stderr. When quiet=True returns the status code silently — + useful for cheap programmatic checks. + + Invoked by `./cli.py backend status [--backend=…]` (quiet=False) + and by is_backend_ready() (caller-controlled).""" @classmethod @abstractmethod @@ -811,6 +824,29 @@ def has_backend(name: str) -> bool: return backends[name].is_available() +def is_backend_available(name: str) -> bool: + """Cheap availability check: is the backend's binary on PATH? + + Suitable for cleanup enumeration and auto-selection — does NOT probe + the daemon or network pool. Use is_backend_ready() for a full + readiness check before launching tests.""" + return has_backend(name) + + +def is_backend_ready(name: str, *, quiet: bool = False) -> bool: + """Full readiness check: passes all of the backend's status() checks. + + When quiet=False the backend prints diagnostic output explaining what + is missing — intended for test-suite guards that run at discovery time + so the operator sees a concrete failure reason for each skip. + + Returns False for unknown backend names.""" + backends = _get_backends() + if name not in backends: + return False + return backends[name].status(quiet=quiet) == BackendStatus.READY + + def enumerate_active_agents() -> list[ActiveAgent]: """All currently-running agents, across every available backend. Used by CLI `list active` and the dashboard's agents @@ -835,6 +871,7 @@ def enumerate_active_agents() -> list[ActiveAgent]: __all__ = [ "ActiveAgent", + "BackendStatus", "Bottle", "BottleBackend", "BottleCleanupPlan", @@ -847,5 +884,7 @@ __all__ = [ "get_bottle_backend", "get_freezer", "has_backend", + "is_backend_available", + "is_backend_ready", "known_backend_names", ] diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index b2ae45b..5e0c735 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -20,7 +20,8 @@ infrastructure: CA install and git copy-in. from __future__ import annotations import shutil -from contextlib import contextmanager +import io +from contextlib import contextmanager, redirect_stderr from pathlib import Path from typing import Generator, Sequence @@ -60,8 +61,11 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup return _setup.setup() @classmethod - def status(cls) -> int: + def status(cls, *, quiet: bool = False) -> int: from . import setup as _setup + if quiet: + with redirect_stderr(io.StringIO()): + return _setup.status() return _setup.status() @classmethod diff --git a/bot_bottle/backend/firecracker/backend.py b/bot_bottle/backend/firecracker/backend.py index a8a9df9..89a851c 100644 --- a/bot_bottle/backend/firecracker/backend.py +++ b/bot_bottle/backend/firecracker/backend.py @@ -8,7 +8,8 @@ fail-closed nftables egress boundary. Selected by from __future__ import annotations -from contextlib import contextmanager +import io +from contextlib import contextmanager, redirect_stderr from pathlib import Path from typing import Generator, Sequence @@ -52,8 +53,11 @@ class FirecrackerBottleBackend( return _setup.setup() @classmethod - def status(cls) -> int: + def status(cls, *, quiet: bool = False) -> int: from . import setup as _setup + if quiet: + with redirect_stderr(io.StringIO()): + return _setup.status() return _setup.status() @classmethod diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py index 2448c00..6d59994 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -2,7 +2,8 @@ from __future__ import annotations -from contextlib import contextmanager +import io +from contextlib import contextmanager, redirect_stderr from pathlib import Path from typing import Generator, Sequence @@ -43,8 +44,11 @@ class MacosContainerBottleBackend( return _setup.setup() @classmethod - def status(cls) -> int: + def status(cls, *, quiet: bool = False) -> int: from . import setup as _setup + if quiet: + with redirect_stderr(io.StringIO()): + return _setup.status() return _setup.status() @classmethod diff --git a/tests/_backend.py b/tests/_backend.py index 00f5f38..f40ca8c 100644 --- a/tests/_backend.py +++ b/tests/_backend.py @@ -1,9 +1,11 @@ -"""Backend selection + capability-aware skip guards for the integration suite. +"""Backend selection + readiness-aware skip guards for the integration suite. Each integration test targets the backend named by ``BOT_BOTTLE_BACKEND`` -(default ``docker``) and skips on that backend's own readiness check — -``bot_bottle.backend.has_backend`` (the same probe behind -``./cli.py backend status``), not an unrelated "is Docker installed" test. +(default ``docker``) and gates on that backend's full readiness check — +``is_backend_ready()`` (equivalent to ``./cli.py backend status``), not just +a binary-on-PATH probe. When the backend is not ready, diagnostic output is +printed during test discovery so the operator sees a concrete reason for each +skip. """ from __future__ import annotations @@ -11,7 +13,7 @@ from __future__ import annotations import os import unittest -from bot_bottle.backend import has_backend +from bot_bottle.backend import is_backend_available, is_backend_ready # Default when ``BOT_BOTTLE_BACKEND`` is unset. Docker preserves the historical # Docker-backed CI path (and mirrors the pin in ``test_sandbox_escape``). @@ -30,13 +32,16 @@ def selected_backend() -> str: def skip_unless_backend(backend: str): """Skip a backend-specific test unless the selected backend matches AND - that backend is available on the host. + that backend is fully ready. Docker-implementation tests (``DockerBroker``, ``DockerGateway``, ``backend.docker.*``) use ``skip_unless_backend("docker")`` so they no-op under a run targeting a different backend instead of testing Docker internals that run doesn't exercise — the guard reads ``BOT_BOTTLE_BACKEND`` rather than "is Docker installed". + + When the backend is not ready, ``status()`` output is printed so the + operator sees a concrete diagnostic for each skipped test module. """ sel = selected_backend() if sel != backend: @@ -44,18 +49,23 @@ def skip_unless_backend(backend: str): f"backend {backend!r} not selected (BOT_BOTTLE_BACKEND={sel})" ) return unittest.skipUnless( - has_backend(backend), f"{backend} backend prerequisites unavailable" + is_backend_ready(backend, quiet=False), + f"{backend} backend not ready", ) def skip_unless_selected_backend_available(): - """Skip a backend-agnostic test unless the *selected* backend is available. + """Skip a backend-agnostic test unless the *selected* backend is fully ready. The test then runs through whichever backend ``BOT_BOTTLE_BACKEND`` names, - gated on that backend's real prerequisites (e.g. Linux + KVM for - Firecracker) rather than unrelated Docker availability. + gated on that backend's full status() check (e.g. daemon reachable, TAP + pool present for Firecracker) rather than just a binary-on-PATH probe. + + When the backend is not ready, ``status()`` output is printed so the + operator sees a concrete diagnostic for each skipped test module. """ backend = selected_backend() return unittest.skipUnless( - has_backend(backend), f"selected backend {backend!r} prerequisites unavailable" + is_backend_ready(backend, quiet=False), + f"selected backend {backend!r} not ready", )