Merge main into fix/db-off-data-plane-469
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / integration-docker (pull_request) Successful in 35s
test / unit (pull_request) Successful in 47s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 32s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / integration-docker (pull_request) Successful in 35s
test / unit (pull_request) Successful in 47s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 32s
test / publish-infra (pull_request) Has been skipped
Brings in main's 8 commits — #470 (backend-agnostic CI guards: BackendStatus enum, quiet status(), is_backend_available/is_backend_ready, tests/_backend.py skip_unless_backend) plus firecracker status() readiness checks (kvm/kernel/ dropbear/mke2fs). Reconciled #470's additions into the reorg'd backend structure: * BackendStatus enum + the status(*, quiet=) ABC signature -> backend/base.py * is_backend_available / is_backend_ready -> backend/selection.py * exposed all three through the lazy backend facade (__init__). Integration-test conflicts were our control_plane->orchestrator renames vs main's skip_unless_docker -> skip_unless_backend swap: kept our refactored import paths + main's guard. Repointed main's new is_backend_* tests to patch selection._backends (our moved home) instead of the facade. pyright: 0 errors. Full unit suite green (2272). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
-5
@@ -2,14 +2,15 @@
|
||||
|
||||
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 selection + skip guards (shared)
|
||||
unit/
|
||||
test_egress.py
|
||||
test_egress_addon_core.py
|
||||
@@ -73,7 +74,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_<topic>.py`.
|
||||
3. Boilerplate:
|
||||
```python
|
||||
@@ -88,5 +89,19 @@ 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` and gate on the backend's own
|
||||
readiness check, `bot_bottle.backend.has_backend` — the same probe
|
||||
behind `./cli.py backend status`:
|
||||
- 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 is available (checking, e.g., Linux + `/dev/kvm`
|
||||
for Firecracker rather than unrelated Docker availability).
|
||||
- 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.
|
||||
|
||||
Each CI integration job runs `./cli.py backend status --backend=<name>`
|
||||
as a preflight, which prints a clear per-check summary and exits non-zero
|
||||
when the backend is missing — so absent infrastructure fails the job
|
||||
instead of hiding among per-test `unittest.skip` lines.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Backend selection + readiness-aware skip guards for the integration suite.
|
||||
|
||||
Each integration test targets the backend named by ``BOT_BOTTLE_BACKEND``
|
||||
(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
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend import 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``).
|
||||
DEFAULT_BACKEND = "docker"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def skip_unless_backend(backend: str):
|
||||
"""Skip a backend-specific test unless the selected backend matches AND
|
||||
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:
|
||||
return unittest.skip(
|
||||
f"backend {backend!r} not selected (BOT_BOTTLE_BACKEND={sel})"
|
||||
)
|
||||
return unittest.skipUnless(
|
||||
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 fully ready.
|
||||
|
||||
The test then runs through whichever backend ``BOT_BOTTLE_BACKEND`` names,
|
||||
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(
|
||||
is_backend_ready(backend, quiet=False),
|
||||
f"selected backend {backend!r} not ready",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 "
|
||||
|
||||
@@ -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.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK
|
||||
from bot_bottle.backend.docker.infra import DockerInfraService
|
||||
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 "
|
||||
|
||||
@@ -29,7 +29,7 @@ from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
||||
from bot_bottle.orchestrator.client import OrchestratorClient
|
||||
from bot_bottle.backend.docker.infra import DockerInfraService
|
||||
from bot_bottle.paths import host_orchestrator_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.
|
||||
@@ -38,7 +38,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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,12 +11,12 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.docker.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)
|
||||
|
||||
@@ -12,12 +12,12 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
from bot_bottle.backend.docker.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
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -386,6 +386,60 @@ class TestHasBackend(unittest.TestCase):
|
||||
self.assertFalse(has_backend("nonexistent"))
|
||||
|
||||
|
||||
class TestIsBackendAvailable(unittest.TestCase):
|
||||
def test_delegates_to_has_backend(self):
|
||||
from bot_bottle.backend import is_backend_available
|
||||
with patch.object(_sel, "_backends", {}):
|
||||
self.assertFalse(is_backend_available("docker"))
|
||||
|
||||
def test_known_and_available(self):
|
||||
class _Ready:
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
from bot_bottle.backend import is_backend_available
|
||||
with patch.object(_sel, "_backends", {"docker": _Ready()}):
|
||||
self.assertTrue(is_backend_available("docker"))
|
||||
|
||||
|
||||
class TestIsBackendReady(unittest.TestCase):
|
||||
def test_unknown_backend_returns_false(self):
|
||||
from bot_bottle.backend import is_backend_ready
|
||||
with patch.object(_sel, "_backends", {}):
|
||||
self.assertFalse(is_backend_ready("docker"))
|
||||
|
||||
def test_ready_when_status_returns_zero(self):
|
||||
class _ReadyBackend:
|
||||
def status(self, *, quiet: bool = False) -> int:
|
||||
return 0
|
||||
|
||||
from bot_bottle.backend import is_backend_ready
|
||||
with patch.object(_sel, "_backends", {"docker": _ReadyBackend()}):
|
||||
self.assertTrue(is_backend_ready("docker"))
|
||||
|
||||
def test_not_ready_when_status_nonzero(self):
|
||||
class _BrokenBackend:
|
||||
def status(self, *, quiet: bool = False) -> int:
|
||||
return 1
|
||||
|
||||
from bot_bottle.backend import is_backend_ready
|
||||
with patch.object(_sel, "_backends", {"docker": _BrokenBackend()}):
|
||||
self.assertFalse(is_backend_ready("docker"))
|
||||
|
||||
def test_quiet_flag_forwarded(self):
|
||||
calls = []
|
||||
|
||||
class _SpyBackend:
|
||||
def status(self, *, quiet: bool = False) -> int:
|
||||
calls.append(quiet)
|
||||
return 0
|
||||
|
||||
from bot_bottle.backend import is_backend_ready
|
||||
with patch.object(_sel, "_backends", {"docker": _SpyBackend()}):
|
||||
is_backend_ready("docker", quiet=True)
|
||||
self.assertEqual([True], calls)
|
||||
|
||||
|
||||
class TestEnsureOrchestrator(unittest.TestCase):
|
||||
"""The backend-agnostic orchestrator bring-up entry point. Docker starts
|
||||
the orchestrator + gateway containers; firecracker boots the infra VM;
|
||||
|
||||
@@ -328,5 +328,201 @@ class TestNetpoolShellRenderers(unittest.TestCase):
|
||||
self.assertIn("BOT_BOTTLE_FC_POOL_SIZE=4", out)
|
||||
|
||||
|
||||
class TestFirecrackerBinaryCheck(unittest.TestCase):
|
||||
def test_binary_missing_returns_false(self):
|
||||
with patch.object(fc.shutil, "which", return_value=None):
|
||||
self.assertFalse(fc._firecracker_binary_ok())
|
||||
|
||||
def test_binary_present_and_runs_ok(self):
|
||||
with patch.object(fc.shutil, "which", return_value="/usr/bin/firecracker"), \
|
||||
patch.object(fc.subprocess, "run",
|
||||
return_value=subprocess.CompletedProcess([], 0)):
|
||||
self.assertTrue(fc._firecracker_binary_ok())
|
||||
|
||||
def test_binary_found_but_exits_nonzero(self):
|
||||
with patch.object(fc.shutil, "which", return_value="/usr/bin/firecracker"), \
|
||||
patch.object(fc.subprocess, "run",
|
||||
return_value=subprocess.CompletedProcess([], 1)):
|
||||
self.assertFalse(fc._firecracker_binary_ok())
|
||||
|
||||
def test_binary_found_but_oserror(self):
|
||||
with patch.object(fc.shutil, "which", return_value="/usr/bin/firecracker"), \
|
||||
patch.object(fc.subprocess, "run", side_effect=OSError("exec failed")):
|
||||
self.assertFalse(fc._firecracker_binary_ok())
|
||||
|
||||
|
||||
class TestFirecrackerKvmCheck(unittest.TestCase):
|
||||
def test_kvm_device_absent_returns_false(self):
|
||||
with patch.object(fc.os.path, "exists", return_value=False):
|
||||
self.assertFalse(fc._kvm_accessible())
|
||||
|
||||
def test_kvm_accessible_when_ioctl_succeeds(self):
|
||||
with patch.object(fc.os.path, "exists", return_value=True), \
|
||||
patch.object(fc.os, "open", return_value=42), \
|
||||
patch.object(fc.os, "close"), \
|
||||
patch.object(fc.fcntl, "ioctl", return_value=12):
|
||||
self.assertTrue(fc._kvm_accessible())
|
||||
|
||||
def test_kvm_open_rdwr_fails(self):
|
||||
with patch.object(fc.os.path, "exists", return_value=True), \
|
||||
patch.object(fc.os, "open", side_effect=OSError("Permission denied")):
|
||||
self.assertFalse(fc._kvm_accessible())
|
||||
|
||||
def test_kvm_present_but_ioctl_fails(self):
|
||||
with patch.object(fc.os.path, "exists", return_value=True), \
|
||||
patch.object(fc.os, "open", return_value=42), \
|
||||
patch.object(fc.os, "close"), \
|
||||
patch.object(fc.fcntl, "ioctl", side_effect=OSError("permission denied")):
|
||||
self.assertFalse(fc._kvm_accessible())
|
||||
|
||||
|
||||
class TestFirecrackerArtifactCheck(unittest.TestCase):
|
||||
"""status() reports missing guest kernel, dropbear binary, and mke2fs."""
|
||||
|
||||
def _apply_all_ok(self, stack: contextlib.ExitStack) -> None:
|
||||
"""Stub every status() check to pass except what the test overrides."""
|
||||
stack.enter_context(patch.object(fc, "_firecracker_binary_ok", return_value=True))
|
||||
stack.enter_context(patch.object(fc, "_kvm_accessible", return_value=True))
|
||||
k: MagicMock = MagicMock()
|
||||
k.is_file.return_value = True
|
||||
stack.enter_context(patch.object(fc.util, "kernel_path", return_value=k))
|
||||
d: MagicMock = MagicMock()
|
||||
d.is_file.return_value = True
|
||||
stack.enter_context(patch.object(fc.util, "dropbear_path", return_value=d))
|
||||
stack.enter_context(patch.object(fc.shutil, "which", return_value="/usr/bin/x"))
|
||||
stack.enter_context(patch.object(netpool, "missing_taps", return_value=[]))
|
||||
stack.enter_context(patch.object(netpool, "pool_size", return_value=8))
|
||||
stack.enter_context(patch.object(netpool, "overlapping_routes", return_value=[]))
|
||||
stack.enter_context(patch.object(fc, "_report_persistence", lambda: None))
|
||||
|
||||
def test_status_fails_when_kernel_missing(self):
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._apply_all_ok(stack)
|
||||
k: MagicMock = MagicMock()
|
||||
k.is_file.return_value = False
|
||||
stack.enter_context(patch.object(fc.util, "kernel_path", return_value=k))
|
||||
rc, out = _cap(fc.status)
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("guest kernel: NOT found", out)
|
||||
|
||||
def test_status_fails_when_dropbear_missing(self):
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._apply_all_ok(stack)
|
||||
d: MagicMock = MagicMock()
|
||||
d.is_file.return_value = False
|
||||
stack.enter_context(patch.object(fc.util, "dropbear_path", return_value=d))
|
||||
rc, out = _cap(fc.status)
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("dropbear: NOT found", out)
|
||||
|
||||
def test_status_fails_when_mke2fs_missing(self):
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._apply_all_ok(stack)
|
||||
stack.enter_context(patch.object(
|
||||
fc.shutil, "which",
|
||||
side_effect=lambda cmd: (None if cmd == "mke2fs" else "/usr/bin/x"), # type: ignore[misc]
|
||||
))
|
||||
rc, out = _cap(fc.status)
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("mke2fs: NOT found", out)
|
||||
|
||||
|
||||
class TestFirecrackerStatusRuntime(unittest.TestCase):
|
||||
"""status() reports binary and KVM problems and returns non-zero."""
|
||||
|
||||
def _apply_pool_ok(self, stack: contextlib.ExitStack) -> None:
|
||||
kernel_mock = MagicMock()
|
||||
kernel_mock.is_file.return_value = True
|
||||
dropbear_mock = MagicMock()
|
||||
dropbear_mock.is_file.return_value = True
|
||||
stack.enter_context(patch.object(fc.util, "kernel_path", return_value=kernel_mock))
|
||||
stack.enter_context(patch.object(fc.util, "dropbear_path", return_value=dropbear_mock))
|
||||
stack.enter_context(patch.object(netpool, "missing_taps", return_value=[]))
|
||||
stack.enter_context(patch.object(netpool, "pool_size", return_value=8))
|
||||
stack.enter_context(patch.object(netpool, "overlapping_routes", return_value=[]))
|
||||
stack.enter_context(patch.object(fc, "_report_persistence", lambda: None))
|
||||
|
||||
def test_status_fails_when_binary_missing(self):
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(fc, "_firecracker_binary_ok", return_value=False))
|
||||
stack.enter_context(
|
||||
patch.object(fc, "_kvm_accessible", return_value=True))
|
||||
stack.enter_context(
|
||||
patch.object(fc.shutil, "which", return_value=None))
|
||||
self._apply_pool_ok(stack)
|
||||
rc, out = _cap(fc.status)
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("NOT found on PATH", out)
|
||||
|
||||
def test_status_fails_when_kvm_not_accessible(self):
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(fc, "_firecracker_binary_ok", return_value=True))
|
||||
stack.enter_context(
|
||||
patch.object(fc.shutil, "which", return_value="/usr/bin/firecracker"))
|
||||
stack.enter_context(
|
||||
patch.object(fc, "_kvm_accessible", return_value=False))
|
||||
stack.enter_context(
|
||||
patch.object(fc.os.path, "exists", return_value=True))
|
||||
self._apply_pool_ok(stack)
|
||||
rc, out = _cap(fc.status)
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("not accessible", out)
|
||||
|
||||
def test_status_ok_when_binary_and_kvm_ready(self):
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(fc, "_firecracker_binary_ok", return_value=True))
|
||||
stack.enter_context(
|
||||
patch.object(fc.shutil, "which", return_value="/usr/bin/firecracker"))
|
||||
stack.enter_context(
|
||||
patch.object(fc, "_kvm_accessible", return_value=True))
|
||||
stack.enter_context(
|
||||
patch.object(netpool, "nft_table_present", return_value=True))
|
||||
self._apply_pool_ok(stack)
|
||||
rc, out = _cap(fc.status)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIn("firecracker binary: ok", out)
|
||||
self.assertIn("KVM:", out)
|
||||
|
||||
|
||||
class TestBackendStatusQuiet(unittest.TestCase):
|
||||
"""status(quiet=True) routes the underlying status() call through
|
||||
redirect_stderr so diagnostic output is suppressed. Verify the return
|
||||
code is propagated and that calling with quiet=False leaves the non-quiet
|
||||
path active (covered by the other TestDockerSetupStatus tests)."""
|
||||
|
||||
def test_docker_quiet_true_propagates_return_code(self):
|
||||
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
||||
with patch.object(dk, "status", return_value=0):
|
||||
self.assertEqual(0, DockerBottleBackend.status(quiet=True))
|
||||
|
||||
def test_docker_quiet_true_suppresses_stderr(self):
|
||||
import io as _io
|
||||
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
||||
|
||||
def _loud_status():
|
||||
import sys
|
||||
print("should be suppressed", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
with patch.object(dk, "status", side_effect=_loud_status):
|
||||
buf = _io.StringIO()
|
||||
with contextlib.redirect_stderr(buf):
|
||||
DockerBottleBackend.status(quiet=True)
|
||||
self.assertEqual("", buf.getvalue())
|
||||
|
||||
def test_firecracker_quiet_true_propagates_return_code(self):
|
||||
from bot_bottle.backend.firecracker.backend import FirecrackerBottleBackend
|
||||
with patch.object(fc, "status", return_value=1):
|
||||
self.assertEqual(1, FirecrackerBottleBackend.status(quiet=True))
|
||||
|
||||
def test_macos_quiet_true_propagates_return_code(self):
|
||||
from bot_bottle.backend.macos_container.backend import MacosContainerBottleBackend
|
||||
with patch.object(mc, "status", return_value=0):
|
||||
self.assertEqual(0, MacosContainerBottleBackend.status(quiet=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the backend-aware skip guards in ``tests/_backend.py``.
|
||||
|
||||
The guards delegate their readiness check to
|
||||
``bot_bottle.backend.is_backend_ready`` (the probe behind ``./cli.py backend
|
||||
status``); here that probe is mocked so the unit job asserts the
|
||||
selection/skip logic without either backend present on the runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests._backend import (
|
||||
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 TestSkipUnlessBackend(unittest.TestCase):
|
||||
def test_skips_when_other_backend_selected(self):
|
||||
# A different backend is selected — no host probe needed, skip.
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True), \
|
||||
patch("tests._backend.is_backend_ready") as has:
|
||||
decorated = skip_unless_backend("docker")(_new_case())
|
||||
self.assertTrue(_skipped(decorated))
|
||||
has.assert_not_called()
|
||||
|
||||
def test_runs_when_selected_and_available(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "docker"}, clear=True), \
|
||||
patch("tests._backend.is_backend_ready", return_value=True):
|
||||
decorated = skip_unless_backend("docker")(_new_case())
|
||||
self.assertFalse(_skipped(decorated))
|
||||
|
||||
def test_skips_when_selected_but_unavailable(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "docker"}, clear=True), \
|
||||
patch("tests._backend.is_backend_ready", return_value=False):
|
||||
decorated = skip_unless_backend("docker")(_new_case())
|
||||
self.assertTrue(_skipped(decorated))
|
||||
|
||||
|
||||
class TestSkipUnlessSelectedBackendAvailable(unittest.TestCase):
|
||||
def test_runs_when_selected_backend_available(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True), \
|
||||
patch("tests._backend.is_backend_ready", return_value=True) as has:
|
||||
decorated = skip_unless_selected_backend_available()(_new_case())
|
||||
self.assertFalse(_skipped(decorated))
|
||||
has.assert_called_once_with("firecracker", quiet=False)
|
||||
|
||||
def test_skips_when_selected_backend_unavailable(self):
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}, clear=True), \
|
||||
patch("tests._backend.is_backend_ready", return_value=False):
|
||||
decorated = skip_unless_selected_backend_available()(_new_case())
|
||||
self.assertTrue(_skipped(decorated))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -132,30 +132,56 @@ class TestFirecrackerStatus(unittest.TestCase):
|
||||
rc = fc_setup.status()
|
||||
return rc, buf.getvalue()
|
||||
|
||||
def _stub_artifacts(self, fc_setup: object) -> tuple[MagicMock, MagicMock]:
|
||||
k: MagicMock = MagicMock()
|
||||
k.is_file.return_value = True
|
||||
d: MagicMock = MagicMock()
|
||||
d.is_file.return_value = True
|
||||
return k, d
|
||||
|
||||
def test_ready_when_taps_present_even_if_nft_unverifiable(self):
|
||||
from bot_bottle.backend.firecracker import setup as fc_setup
|
||||
|
||||
def _which(cmd: str) -> str | None:
|
||||
return None if cmd == "nft" else f"/usr/bin/{cmd}"
|
||||
|
||||
k, d = self._stub_artifacts(fc_setup)
|
||||
with patch.object(fc_setup.netpool, "missing_taps", return_value=[]), \
|
||||
patch.object(fc_setup.netpool, "overlapping_routes", return_value=[]), \
|
||||
patch.object(fc_setup.shutil, "which", return_value=None):
|
||||
patch.object(fc_setup.shutil, "which", side_effect=_which), \
|
||||
patch.object(fc_setup, "_firecracker_binary_ok", return_value=True), \
|
||||
patch.object(fc_setup, "_kvm_accessible", return_value=True), \
|
||||
patch.object(fc_setup.util, "kernel_path", return_value=k), \
|
||||
patch.object(fc_setup.util, "dropbear_path", return_value=d):
|
||||
rc, out = self._run()
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIn("unverified", out)
|
||||
|
||||
def test_not_ready_when_taps_missing(self):
|
||||
from bot_bottle.backend.firecracker import setup as fc_setup
|
||||
k, d = self._stub_artifacts(fc_setup)
|
||||
with patch.object(fc_setup.netpool, "missing_taps", return_value=["bbfc0"]), \
|
||||
patch.object(fc_setup.netpool, "overlapping_routes", return_value=[]), \
|
||||
patch.object(fc_setup.shutil, "which", return_value=None):
|
||||
patch.object(fc_setup.shutil, "which", return_value=None), \
|
||||
patch.object(fc_setup, "_firecracker_binary_ok", return_value=True), \
|
||||
patch.object(fc_setup, "_kvm_accessible", return_value=True), \
|
||||
patch.object(fc_setup.util, "kernel_path", return_value=k), \
|
||||
patch.object(fc_setup.util, "dropbear_path", return_value=d):
|
||||
rc, _ = self._run()
|
||||
self.assertEqual(1, rc)
|
||||
|
||||
def test_not_ready_on_range_overlap(self):
|
||||
from bot_bottle.backend.firecracker import netpool
|
||||
from bot_bottle.backend.firecracker import setup as fc_setup
|
||||
k, d = self._stub_artifacts(fc_setup)
|
||||
conflict = netpool.RouteConflict(dst="10.243.0.0/24", dev="eth0")
|
||||
with patch.object(fc_setup.netpool, "missing_taps", return_value=[]), \
|
||||
patch.object(fc_setup.netpool, "overlapping_routes", return_value=[conflict]), \
|
||||
patch.object(fc_setup.shutil, "which", return_value=None):
|
||||
patch.object(fc_setup.shutil, "which", return_value=None), \
|
||||
patch.object(fc_setup, "_firecracker_binary_ok", return_value=True), \
|
||||
patch.object(fc_setup, "_kvm_accessible", return_value=True), \
|
||||
patch.object(fc_setup.util, "kernel_path", return_value=k), \
|
||||
patch.object(fc_setup.util, "dropbear_path", return_value=d):
|
||||
rc, out = self._run()
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIn("CLASHES", out)
|
||||
|
||||
Reference in New Issue
Block a user