diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index e28fc2a..f2e1a16 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -103,14 +103,15 @@ jobs: - name: Install coverage run: python3 -m pip install --break-system-packages coverage - - name: Show environment + # Fail loudly if the backend this job promises isn't actually usable, + # rather than letting every test silently `unittest.skip` and the job + # go green on zero coverage. Same capability probe the skip guards use. + - name: Preflight — Docker backend is ready + env: + BOT_BOTTLE_BACKEND: docker run: | python3 --version - if command -v docker >/dev/null 2>&1; then - docker version || true - else - echo "docker not on PATH — integration tests will skip" - fi + python3 -m tests.backend_preflight docker - name: Run integration tests (docker) with coverage env: @@ -157,10 +158,13 @@ jobs: uses: actions/checkout@v4 - name: Preflight — Firecracker host is ready + env: + BOT_BOTTLE_BACKEND: firecracker run: | - command -v firecracker >/dev/null || { - echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; } - test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; } + # Standardized capability line — same probe (and PASS/FAIL wording) + # the integration skip guards use, so a missing backend is visible + # at the job level rather than hidden among `unittest.skip` lines. + python3 -m tests.backend_preflight firecracker # `backend status` exits non-zero unless the TAP pool is up + no # range overlap; it prints the exact `backend setup` fix. python3 cli.py backend status --backend=firecracker diff --git a/docs/ci.md b/docs/ci.md index 51d7e8d..c0230c2 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,13 +1,23 @@ # CI The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml). -It runs `tests/run_tests.py` (full suite — unit + integration) on: +It runs the unit suite plus one integration job per backend +(`integration-docker`, `integration-firecracker`) on: - every push to a branch with an open pull request, and - every push to `main`. -Integration tests need Docker on the runner; they skip cleanly via -`tests/_docker.skip_unless_docker` when no daemon is reachable. +Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and +runs a **preflight** (`python3 -m tests.backend_preflight `) +that prints a clear PASS/FAIL capability line and fails the job when the +backend is missing — so absent infrastructure is visible at the job level +rather than hidden among per-test `unittest.skip` lines. The same +capability probes back the skip guards in +[`tests/_backend.py`](../tests/_backend.py): backend-agnostic tests use +`skip_unless_selected_backend_available()` and run through whichever +backend is selected (checking, e.g., `/dev/kvm` for Firecracker rather +than unrelated Docker availability); Docker-implementation tests use +`skip_unless_backend("docker")` and no-op under a non-Docker run. A small subset of integration tests skip when running specifically under Gitea Actions (`GITEA_ACTIONS=true`), because `act_runner` runs diff --git a/tests/README.md b/tests/README.md index 07857d5..25391fc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,14 +2,16 @@ Plain-Python test suite using stdlib `unittest`. No external dependencies. Unit tests run anywhere Python 3 is present; integration -tests need Docker and skip cleanly otherwise. +tests run through the backend named by `BOT_BOTTLE_BACKEND` (default +`docker`) and skip cleanly when that backend isn't available on the host. ## Layout ``` tests/ fixtures.py # JSON manifest builders (shared) - _docker.py # docker-availability skip helper (shared) + _backend.py # backend capability probes + skip guards + backend_preflight.py # `python -m tests.backend_preflight` (CI) unit/ test_egress.py test_egress_addon_core.py @@ -73,7 +75,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v ## Adding a test 1. Pick the directory: `tests/unit/` for a pure unit test, - `tests/integration/` for one that needs Docker. + `tests/integration/` for one that needs a backend. 2. Filename: `test_.py`. 3. Boilerplate: ```python @@ -88,5 +90,16 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v if __name__ == "__main__": unittest.main() ``` -4. For Docker-dependent tests, decorate the class with - `@skip_unless_docker()` from `tests._docker`. +4. Skip guards live in `tests._backend`: + - Backend-agnostic tests (go through `get_bottle_backend()`) decorate + the class with `@skip_unless_selected_backend_available()` — the test + runs against whichever backend `BOT_BOTTLE_BACKEND` selects and skips + unless that backend's capability is present (a reachable Docker daemon, + or an accessible `/dev/kvm` + `firecracker` for Firecracker). + - Backend-specific tests (exercise `DockerBroker`, `DockerGateway`, + `backend.docker.*`, …) decorate with `@skip_unless_backend("docker")` + so they no-op under a run targeting a different backend. + + The same capability probes back the CI preflight, + `python -m tests.backend_preflight []`, which prints a clear + PASS/FAIL line and exits non-zero when the selected backend is missing. diff --git a/tests/_backend.py b/tests/_backend.py new file mode 100644 index 0000000..39ed57c --- /dev/null +++ b/tests/_backend.py @@ -0,0 +1,153 @@ +"""Backend-aware capability probes and skip guards for the integration suite. + +Every integration test selects its backend from ``BOT_BOTTLE_BACKEND`` +(default ``docker``) and exercises the full test through that backend. Skip +guards check the *capability* the selected backend actually needs — a +reachable Docker daemon for ``docker``, an accessible ``/dev/kvm`` plus a +``firecracker`` binary for ``firecracker`` — rather than gating every backend +on unrelated Docker availability. + +The same probes back the CI preflight (``python3 -m tests.backend_preflight``) +so a job surfaces missing infrastructure at the job level with a clear +PASS/FAIL line instead of turning every test into a silent ``unittest.skip``. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import unittest +from dataclasses import dataclass + +# Default when ``BOT_BOTTLE_BACKEND`` is unset. Docker preserves the historical +# Docker-backed CI path (and mirrors the pin in ``test_sandbox_escape``). +DEFAULT_BACKEND = "docker" + +# `/dev/kvm` must exist and be openable by the invoking user for the +# Firecracker backend to boot a guest (mirrors +# ``bot_bottle.backend.firecracker.util``). +_KVM_DEVICE = "/dev/kvm" + + +def selected_backend() -> str: + """The backend this test run targets, from ``BOT_BOTTLE_BACKEND``. + + Mirrors the CLI's env selector; unset means ``docker`` so an + unconfigured run behaves exactly as the suite did before backends were + pluggable. + """ + return os.environ.get("BOT_BOTTLE_BACKEND") or DEFAULT_BACKEND + + +@dataclass(frozen=True) +class Capability: + """Outcome of a backend capability probe. + + ``ok`` gates the tests; ``detail`` is a one-line human string reused for + both preflight output and ``unittest.skip`` reasons so the same wording + shows up at the job level and next to a skipped test. + """ + + backend: str + ok: bool + detail: str + + +def docker_capability() -> Capability: + """Whether a Docker daemon is reachable (and not opted out).""" + name = "docker" + if os.environ.get("SKIP_DOCKER_TESTS"): + return Capability(name, False, "SKIP_DOCKER_TESTS is set") + if shutil.which("docker") is None: + return Capability(name, False, "docker not on PATH") + try: + returncode = subprocess.run( + ["docker", "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=5, + ).returncode + except subprocess.TimeoutExpired: + return Capability(name, False, "docker info timed out") + if returncode != 0: + return Capability(name, False, "docker daemon unreachable") + return Capability(name, True, "docker daemon reachable") + + +def firecracker_capability() -> Capability: + """Whether this host can boot a Firecracker guest: an accessible + ``/dev/kvm`` and a ``firecracker`` binary on ``PATH``. + + Deliberately does not probe unrelated Docker availability — the + Firecracker backend is Docker-independent at runtime. + """ + name = "firecracker" + if not os.path.exists(_KVM_DEVICE): + return Capability(name, False, f"{_KVM_DEVICE} missing — KVM unavailable") + if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK): + return Capability( + name, False, f"{_KVM_DEVICE} not accessible — add your user to the kvm group" + ) + if shutil.which("firecracker") is None: + return Capability(name, False, "firecracker not on PATH") + return Capability(name, True, f"{_KVM_DEVICE} accessible; firecracker on PATH") + + +_PROBES = { + "docker": docker_capability, + "firecracker": firecracker_capability, +} + + +def backend_capability(backend: str | None = None) -> Capability: + """Probe ``backend`` (default: the selected backend) for readiness.""" + backend = backend or selected_backend() + probe = _PROBES.get(backend) + if probe is None: + return Capability(backend, False, f"no capability probe for backend {backend!r}") + return probe() + + +# ---- back-compat boolean probe ------------------------------------------- + + +def docker_available() -> bool: + """Boolean Docker probe (kept for callers that only need the flag).""" + return docker_capability().ok + + +# ---- skip guards --------------------------------------------------------- + + +def skip_unless_backend(backend: str): + """Skip a backend-specific test unless the selected backend matches AND + that backend's capability is present. + + Docker-implementation tests (``DockerBroker``, ``DockerGateway``, + ``backend.docker.*``) use ``skip_unless_backend("docker")`` so they no-op + under a Firecracker run instead of testing Docker internals that run + doesn't target — the guard reads ``BOT_BOTTLE_BACKEND`` rather than + "is Docker installed". + """ + sel = selected_backend() + if sel != backend: + return unittest.skip( + f"backend {backend!r} not selected (BOT_BOTTLE_BACKEND={sel})" + ) + cap = backend_capability(backend) + return unittest.skipUnless(cap.ok, f"{backend} backend unavailable: {cap.detail}") + + +def skip_unless_selected_backend_available(): + """Skip a backend-agnostic test unless the *selected* backend can run it. + + The test then exercises whichever backend ``BOT_BOTTLE_BACKEND`` names, + checking that backend's real capability (e.g. ``/dev/kvm`` for + Firecracker) rather than unrelated Docker availability. + """ + cap = backend_capability() + return unittest.skipUnless( + cap.ok, f"selected backend {cap.backend!r} unavailable: {cap.detail}" + ) diff --git a/tests/_docker.py b/tests/_docker.py deleted file mode 100644 index 4cb7a25..0000000 --- a/tests/_docker.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Docker availability check used by integration tests.""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import unittest - - -def docker_available() -> bool: - if os.environ.get("SKIP_DOCKER_TESTS"): - return False - if shutil.which("docker") is None: - return False - try: - return ( - subprocess.run( - ["docker", "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - timeout=5, - ).returncode - == 0 - ) - except subprocess.TimeoutExpired: - return False - - -def skip_unless_docker(reason: str = "docker unreachable"): - return unittest.skipUnless(docker_available(), reason) - - -def skip_unless_docker_or_firecracker( - reason: str = "neither Docker nor Firecracker selected", -): - """Skip a backend-agnostic test unless one supported backend can run. - - Firecracker does not require the host Docker daemon. The KVM coverage job - deliberately sets ``SKIP_DOCKER_TESTS`` to exclude Docker-only integration - classes while still exercising this path. - """ - firecracker_selected = os.environ.get("BOT_BOTTLE_BACKEND") == "firecracker" - return unittest.skipUnless(firecracker_selected or docker_available(), reason) diff --git a/tests/backend_preflight.py b/tests/backend_preflight.py new file mode 100644 index 0000000..0659586 --- /dev/null +++ b/tests/backend_preflight.py @@ -0,0 +1,27 @@ +"""Backend capability preflight for the integration CI jobs. + +Run as ``python3 -m tests.backend_preflight`` (optionally with a backend name +argument; default: ``BOT_BOTTLE_BACKEND`` or ``docker``). Prints a single +clear PASS/FAIL line for the selected backend's capability and exits non-zero +on failure, so a job surfaces missing infrastructure at the job level instead +of hiding it among ``unittest.skip`` lines further down the output. +""" + +from __future__ import annotations + +import sys + +from tests._backend import backend_capability, selected_backend + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + backend = argv[0] if argv else selected_backend() + cap = backend_capability(backend) + status = "PASS" if cap.ok else "FAIL" + print(f"[preflight] backend={backend} {status}: {cap.detail}") + return 0 if cap.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_gateway_image.py b/tests/integration/test_gateway_image.py index 87e5d97..e12cb08 100644 --- a/tests/integration/test_gateway_image.py +++ b/tests/integration/test_gateway_image.py @@ -25,14 +25,14 @@ import os import subprocess import unittest -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend _IMAGE = "bot-bottle-gateway-test:chunk1" _DOCKERFILE = "Dockerfile.gateway" -@skip_unless_docker() +@skip_unless_backend("docker") @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true", "skipped under act_runner: multi-stage build pulls a 200+MB " diff --git a/tests/integration/test_multitenant_isolation.py b/tests/integration/test_multitenant_isolation.py index 7ed3031..e8b9ae5 100644 --- a/tests/integration/test_multitenant_isolation.py +++ b/tests/integration/test_multitenant_isolation.py @@ -31,7 +31,7 @@ from bot_bottle.backend.docker.gateway_net import next_free_ip from bot_bottle.orchestrator.client import OrchestratorClient from bot_bottle.orchestrator.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK from bot_bottle.orchestrator.lifecycle import OrchestratorService -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend # One upstream reachable under two names; reflects the Authorization header so # the probe can see exactly what the gateway injected (or didn't). @@ -72,7 +72,7 @@ _PROBE_SRC = ( ) -@skip_unless_docker() +@skip_unless_backend("docker") @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true", "skipped under act_runner: the orchestrator container bind-mounts the repo " diff --git a/tests/integration/test_orchestrator_docker_broker.py b/tests/integration/test_orchestrator_docker_broker.py index 8c4da42..b502422 100644 --- a/tests/integration/test_orchestrator_docker_broker.py +++ b/tests/integration/test_orchestrator_docker_broker.py @@ -13,12 +13,12 @@ import unittest from bot_bottle.orchestrator.broker import LaunchRequest, sign_request from bot_bottle.orchestrator.docker_broker import DockerBroker, container_name -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend IMAGE = "busybox" -@skip_unless_docker() +@skip_unless_backend("docker") class TestDockerBrokerIntegration(unittest.TestCase): def setUp(self) -> None: self.secret = secrets.token_bytes(16) diff --git a/tests/integration/test_orchestrator_docker_control_plane_auth.py b/tests/integration/test_orchestrator_docker_control_plane_auth.py index 2c66887..8aac63c 100644 --- a/tests/integration/test_orchestrator_docker_control_plane_auth.py +++ b/tests/integration/test_orchestrator_docker_control_plane_auth.py @@ -28,7 +28,7 @@ from pathlib import Path from bot_bottle.orchestrator.client import OrchestratorClient from bot_bottle.orchestrator.lifecycle import OrchestratorService from bot_bottle.paths import host_control_plane_token -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend # Fixed (not per-run-suffixed) so repeated runs reuse the same layer-cached # image instead of leaking a new dangling tag on every invocation. @@ -37,7 +37,7 @@ _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest" _TEST_INFRA_IMAGE = "bot-bottle-infra:itest" -@skip_unless_docker() +@skip_unless_backend("docker") @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true", "skipped under act_runner: the orchestrator container bind-mounts the repo " diff --git a/tests/integration/test_orchestrator_docker_gateway.py b/tests/integration/test_orchestrator_docker_gateway.py index 38e4b76..888b8da 100644 --- a/tests/integration/test_orchestrator_docker_gateway.py +++ b/tests/integration/test_orchestrator_docker_gateway.py @@ -11,12 +11,12 @@ import subprocess import unittest from bot_bottle.orchestrator.gateway import DockerGateway -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend IMAGE = "busybox" -@skip_unless_docker() +@skip_unless_backend("docker") class TestDockerGatewayIntegration(unittest.TestCase): def setUp(self) -> None: self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4) diff --git a/tests/integration/test_orchestrator_docker_gateway_build.py b/tests/integration/test_orchestrator_docker_gateway_build.py index 5de444f..346e142 100644 --- a/tests/integration/test_orchestrator_docker_gateway_build.py +++ b/tests/integration/test_orchestrator_docker_gateway_build.py @@ -12,12 +12,12 @@ import subprocess import unittest from bot_bottle.orchestrator.gateway import DockerGateway -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend IMAGE = "busybox" -@skip_unless_docker() +@skip_unless_backend("docker") class TestDockerGatewayImageExists(unittest.TestCase): def test_image_exists_true_for_present_false_for_absent(self) -> None: # Ensure the tiny image is present (build_if_missing is disabled here diff --git a/tests/integration/test_orphan_cleanup.py b/tests/integration/test_orphan_cleanup.py index 7094117..d170273 100644 --- a/tests/integration/test_orphan_cleanup.py +++ b/tests/integration/test_orphan_cleanup.py @@ -18,10 +18,10 @@ from bot_bottle.backend.docker.network import ( network_create_internal, network_remove, ) -from tests._docker import skip_unless_docker +from tests._backend import skip_unless_backend -@skip_unless_docker() +@skip_unless_backend("docker") class TestOrphanCleanup(unittest.TestCase): def setUp(self): self.slug = f"cb-test-orphan-{os.getpid()}" diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index de52e5f..e710506 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -31,7 +31,7 @@ from pathlib import Path from bot_bottle.backend import BottleSpec, get_bottle_backend from bot_bottle.bottle_state import cleanup_state from bot_bottle.manifest import ManifestIndex -from tests._docker import skip_unless_docker_or_firecracker +from tests._backend import skip_unless_selected_backend_available # Secrets planted in the bottle env as literals (agents substitute via @@ -67,7 +67,7 @@ _DUMMY_HOST_KEY = ( ) -@skip_unless_docker_or_firecracker() +@skip_unless_selected_backend_available() @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true" and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker", diff --git a/tests/unit/test_backend_skip_guards.py b/tests/unit/test_backend_skip_guards.py new file mode 100644 index 0000000..54265a1 --- /dev/null +++ b/tests/unit/test_backend_skip_guards.py @@ -0,0 +1,188 @@ +"""Tests for the backend-aware capability probes and skip guards +(``tests/_backend.py``) and the CI preflight (``tests/backend_preflight.py``). + +The probes shell out to Docker / inspect ``/dev/kvm``; here they are driven +entirely through mocks so the unit job asserts the decision logic without +needing either backend present on the runner. +""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from tests import _backend, backend_preflight +from tests._backend import ( + Capability, + backend_capability, + docker_capability, + firecracker_capability, + selected_backend, + skip_unless_backend, + skip_unless_selected_backend_available, +) + + +def _skipped(decorated: type) -> bool: + return getattr(decorated, "__unittest_skip__", False) + + +def _new_case() -> type: + return type("Case", (unittest.TestCase,), {}) + + +class TestSelectedBackend(unittest.TestCase): + def test_defaults_to_docker_when_unset(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual("docker", selected_backend()) + + def test_reads_env(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True): + self.assertEqual("firecracker", selected_backend()) + + +class TestDockerCapability(unittest.TestCase): + def test_opt_out_via_skip_docker_tests(self): + with patch.dict(os.environ, {"SKIP_DOCKER_TESTS": "1"}, clear=True): + cap = docker_capability() + self.assertFalse(cap.ok) + self.assertIn("SKIP_DOCKER_TESTS", cap.detail) + + def test_not_on_path(self): + with patch.dict(os.environ, {}, clear=True), \ + patch("tests._backend.shutil.which", return_value=None): + cap = docker_capability() + self.assertFalse(cap.ok) + self.assertIn("PATH", cap.detail) + + def test_daemon_reachable(self): + with patch.dict(os.environ, {}, clear=True), \ + patch("tests._backend.shutil.which", return_value="/usr/bin/docker"), \ + patch("tests._backend.subprocess.run") as run: + run.return_value.returncode = 0 + cap = docker_capability() + self.assertTrue(cap.ok) + + def test_daemon_unreachable(self): + with patch.dict(os.environ, {}, clear=True), \ + patch("tests._backend.shutil.which", return_value="/usr/bin/docker"), \ + patch("tests._backend.subprocess.run") as run: + run.return_value.returncode = 1 + cap = docker_capability() + self.assertFalse(cap.ok) + self.assertIn("unreachable", cap.detail) + + +class TestFirecrackerCapability(unittest.TestCase): + def test_kvm_missing(self): + with patch("tests._backend.os.path.exists", return_value=False): + cap = firecracker_capability() + self.assertFalse(cap.ok) + self.assertIn("KVM", cap.detail) + + def test_kvm_inaccessible(self): + with patch("tests._backend.os.path.exists", return_value=True), \ + patch("tests._backend.os.access", return_value=False): + cap = firecracker_capability() + self.assertFalse(cap.ok) + self.assertIn("kvm group", cap.detail) + + def test_binary_missing(self): + with patch("tests._backend.os.path.exists", return_value=True), \ + patch("tests._backend.os.access", return_value=True), \ + patch("tests._backend.shutil.which", return_value=None): + cap = firecracker_capability() + self.assertFalse(cap.ok) + self.assertIn("firecracker not on PATH", cap.detail) + + def test_ready(self): + with patch("tests._backend.os.path.exists", return_value=True), \ + patch("tests._backend.os.access", return_value=True), \ + patch("tests._backend.shutil.which", return_value="/usr/bin/firecracker"): + cap = firecracker_capability() + self.assertTrue(cap.ok) + + +class TestBackendCapability(unittest.TestCase): + def test_unknown_backend(self): + cap = backend_capability("qemu") + self.assertFalse(cap.ok) + self.assertIn("no capability probe", cap.detail) + + def test_defaults_to_selected(self): + def probe() -> Capability: + return Capability("docker", True, "ok") + + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "docker"}, clear=True), \ + patch.dict("tests._backend._PROBES", {"docker": probe}): + cap = backend_capability() + self.assertEqual("docker", cap.backend) + self.assertTrue(cap.ok) + + +class TestSkipUnlessBackend(unittest.TestCase): + def test_skips_when_other_backend_selected(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True): + decorated = skip_unless_backend("docker")(_new_case()) + self.assertTrue(_skipped(decorated)) + + def test_runs_when_selected_and_capable(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "docker"}, clear=True), \ + patch("tests._backend.backend_capability", + return_value=Capability("docker", True, "ok")): + decorated = skip_unless_backend("docker")(_new_case()) + self.assertFalse(_skipped(decorated)) + + def test_skips_when_selected_but_incapable(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "docker"}, clear=True), \ + patch("tests._backend.backend_capability", + return_value=Capability("docker", False, "docker daemon unreachable")): + decorated = skip_unless_backend("docker")(_new_case()) + self.assertTrue(_skipped(decorated)) + + +class TestSkipUnlessSelectedBackendAvailable(unittest.TestCase): + def test_runs_when_selected_backend_capable(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True), \ + patch("tests._backend.backend_capability", + return_value=Capability("firecracker", True, "ok")): + decorated = skip_unless_selected_backend_available()(_new_case()) + self.assertFalse(_skipped(decorated)) + + def test_skips_when_selected_backend_incapable(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True), \ + patch("tests._backend.backend_capability", + return_value=Capability("firecracker", False, "/dev/kvm missing")): + decorated = skip_unless_selected_backend_available()(_new_case()) + self.assertTrue(_skipped(decorated)) + + +class TestPreflightMain(unittest.TestCase): + def test_pass_exits_zero(self): + with patch.object(backend_preflight, "backend_capability", + return_value=Capability("docker", True, "docker daemon reachable")): + rc = backend_preflight.main(["docker"]) + self.assertEqual(0, rc) + + def test_fail_exits_nonzero(self): + with patch.object(backend_preflight, "backend_capability", + return_value=Capability("firecracker", False, "/dev/kvm missing")): + rc = backend_preflight.main(["firecracker"]) + self.assertEqual(1, rc) + + def test_defaults_to_selected_backend(self): + with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "docker"}, clear=True), \ + patch.object(backend_preflight, "backend_capability", + return_value=Capability("docker", True, "ok")) as cap: + rc = backend_preflight.main([]) + self.assertEqual(0, rc) + cap.assert_called_once_with("docker") + + def test_module_exposes_selected_backend(self): + # Import-surface guard: the preflight re-exports the selector it uses. + self.assertIs(backend_preflight.selected_backend, _backend.selected_backend) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_docker_test_helpers.py b/tests/unit/test_docker_test_helpers.py deleted file mode 100644 index a6c1e37..0000000 --- a/tests/unit/test_docker_test_helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Tests for integration-test backend selection helpers.""" - -from __future__ import annotations - -import os -import unittest -from unittest.mock import patch - -from tests._docker import skip_unless_docker_or_firecracker - - -class TestSkipUnlessDockerOrFirecracker(unittest.TestCase): - def test_firecracker_runs_when_docker_tests_are_disabled(self): - with patch.dict( - os.environ, - {"BOT_BOTTLE_BACKEND": "firecracker", "SKIP_DOCKER_TESTS": "1"}, - clear=True, - ): - decorated = skip_unless_docker_or_firecracker()(type("Case", (), {})) - - self.assertFalse(getattr(decorated, "__unittest_skip__", False)) - - def test_non_firecracker_still_skips_when_docker_tests_are_disabled(self): - with patch.dict( - os.environ, - {"BOT_BOTTLE_BACKEND": "docker", "SKIP_DOCKER_TESTS": "1"}, - clear=True, - ): - decorated = skip_unless_docker_or_firecracker()(type("Case", (), {})) - - self.assertTrue(getattr(decorated, "__unittest_skip__", False)) - - -if __name__ == "__main__": - unittest.main()