ci: backend-agnostic integration guards + per-backend preflight
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 1m5s
test / unit (pull_request) Successful in 1m49s
test / integration-firecracker (pull_request) Successful in 2m0s
test / coverage (pull_request) Successful in 16s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / integration-docker (pull_request) Successful in 20s
lint / lint (push) Successful in 1m5s
test / unit (pull_request) Successful in 1m49s
test / integration-firecracker (pull_request) Successful in 2m0s
test / coverage (pull_request) Successful in 16s
test / publish-infra (pull_request) Has been skipped
Integration tests now select their backend from BOT_BOTTLE_BACKEND and
skip on the capability that backend actually needs, instead of gating
every backend on unrelated Docker availability.
Task 1 — backend-agnostic guards (tests/_backend.py):
- Capability probes: docker_capability() (reachable daemon) and
firecracker_capability() (accessible /dev/kvm + firecracker on PATH,
Docker-independent). backend_capability()/selected_backend() resolve
the target from BOT_BOTTLE_BACKEND (default docker).
- skip_unless_selected_backend_available() for backend-agnostic tests
(test_sandbox_escape) — runs through whichever backend is selected and
checks that backend's real capability.
- skip_unless_backend("docker") for Docker-implementation tests
(DockerBroker, DockerGateway, backend.docker.*) — they no-op under a
non-Docker run rather than testing internals that run doesn't target.
- Retires tests/_docker.py; the KVM job no longer needs SKIP_DOCKER_TESTS
to steer Docker-only classes.
Task 2 — explicit per-backend skip visibility:
- tests/backend_preflight.py prints a clear PASS/FAIL capability line and
exits non-zero when the selected backend is missing.
- Both integration jobs run it as a preflight, so absent infrastructure
is surfaced at the job level instead of hidden among unittest.skip
lines. The docker job replaces its soft "Show environment" step; the
firecracker job keeps its richer backend-status check.
Docs (tests/README.md, docs/ci.md) updated; unit coverage for the probes,
guards, and preflight in test_backend_skip_guards.py.
Closes #414
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+13
-3
@@ -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 <backend>`)
|
||||
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
|
||||
|
||||
+18
-5
@@ -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_<topic>.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 [<backend>]`, which prints a clear
|
||||
PASS/FAIL line and exits non-zero when the selected backend is missing.
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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.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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user