ci: backend-agnostic integration guards + per-backend preflight
tracker-policy-pr / check-pr (pull_request) Successful in 19s
test / integration-docker (pull_request) Successful in 22s
lint / lint (push) Successful in 57s
test / unit (pull_request) Successful in 45s
test / integration-firecracker (pull_request) Successful in 1m58s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 19s
test / integration-docker (pull_request) Successful in 22s
lint / lint (push) Successful in 57s
test / unit (pull_request) Successful in 45s
test / integration-firecracker (pull_request) Successful in 1m58s
test / coverage (pull_request) Successful in 19s
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
|
- name: Install coverage
|
||||||
run: python3 -m pip install --break-system-packages 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: |
|
run: |
|
||||||
python3 --version
|
python3 --version
|
||||||
if command -v docker >/dev/null 2>&1; then
|
python3 -m tests.backend_preflight docker
|
||||||
docker version || true
|
|
||||||
else
|
|
||||||
echo "docker not on PATH — integration tests will skip"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run integration tests (docker) with coverage
|
- name: Run integration tests (docker) with coverage
|
||||||
env:
|
env:
|
||||||
@@ -157,10 +158,13 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Preflight — Firecracker host is ready
|
- name: Preflight — Firecracker host is ready
|
||||||
|
env:
|
||||||
|
BOT_BOTTLE_BACKEND: firecracker
|
||||||
run: |
|
run: |
|
||||||
command -v firecracker >/dev/null || {
|
# Standardized capability line — same probe (and PASS/FAIL wording)
|
||||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
# the integration skip guards use, so a missing backend is visible
|
||||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
# 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
|
# `backend status` exits non-zero unless the TAP pool is up + no
|
||||||
# range overlap; it prints the exact `backend setup` fix.
|
# range overlap; it prints the exact `backend setup` fix.
|
||||||
python3 cli.py backend status --backend=firecracker
|
python3 cli.py backend status --backend=firecracker
|
||||||
|
|||||||
+13
-3
@@ -1,13 +1,23 @@
|
|||||||
# CI
|
# CI
|
||||||
|
|
||||||
The test workflow lives at [`.gitea/workflows/test.yml`](../.gitea/workflows/test.yml).
|
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 a branch with an open pull request, and
|
||||||
- every push to `main`.
|
- every push to `main`.
|
||||||
|
|
||||||
Integration tests need Docker on the runner; they skip cleanly via
|
Each integration job selects its backend via `BOT_BOTTLE_BACKEND` and
|
||||||
`tests/_docker.skip_unless_docker` when no daemon is reachable.
|
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
|
A small subset of integration tests skip when running specifically
|
||||||
under Gitea Actions (`GITEA_ACTIONS=true`), because `act_runner` runs
|
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
|
Plain-Python test suite using stdlib `unittest`. No external
|
||||||
dependencies. Unit tests run anywhere Python 3 is present; integration
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
tests/
|
tests/
|
||||||
fixtures.py # JSON manifest builders (shared)
|
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/
|
unit/
|
||||||
test_egress.py
|
test_egress.py
|
||||||
test_egress_addon_core.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
|
## Adding a test
|
||||||
|
|
||||||
1. Pick the directory: `tests/unit/` for a pure unit 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`.
|
2. Filename: `test_<topic>.py`.
|
||||||
3. Boilerplate:
|
3. Boilerplate:
|
||||||
```python
|
```python
|
||||||
@@ -88,5 +90,16 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
```
|
```
|
||||||
4. For Docker-dependent tests, decorate the class with
|
4. Skip guards live in `tests._backend`:
|
||||||
`@skip_unless_docker()` from `tests._docker`.
|
- 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 subprocess
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from tests._docker import skip_unless_docker
|
from tests._backend import skip_unless_backend
|
||||||
|
|
||||||
|
|
||||||
_IMAGE = "bot-bottle-gateway-test:chunk1"
|
_IMAGE = "bot-bottle-gateway-test:chunk1"
|
||||||
_DOCKERFILE = "Dockerfile.gateway"
|
_DOCKERFILE = "Dockerfile.gateway"
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
@skip_unless_backend("docker")
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
"skipped under act_runner: multi-stage build pulls a 200+MB "
|
"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.client import OrchestratorClient
|
||||||
from bot_bottle.orchestrator.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK
|
from bot_bottle.orchestrator.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK
|
||||||
from bot_bottle.orchestrator.lifecycle import OrchestratorService
|
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
|
# One upstream reachable under two names; reflects the Authorization header so
|
||||||
# the probe can see exactly what the gateway injected (or didn't).
|
# 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(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
"skipped under act_runner: the orchestrator container bind-mounts the repo "
|
"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.broker import LaunchRequest, sign_request
|
||||||
from bot_bottle.orchestrator.docker_broker import DockerBroker, container_name
|
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"
|
IMAGE = "busybox"
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
@skip_unless_backend("docker")
|
||||||
class TestDockerBrokerIntegration(unittest.TestCase):
|
class TestDockerBrokerIntegration(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.secret = secrets.token_bytes(16)
|
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.client import OrchestratorClient
|
||||||
from bot_bottle.orchestrator.lifecycle import OrchestratorService
|
from bot_bottle.orchestrator.lifecycle import OrchestratorService
|
||||||
from bot_bottle.paths import host_control_plane_token
|
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
|
# Fixed (not per-run-suffixed) so repeated runs reuse the same layer-cached
|
||||||
# image instead of leaking a new dangling tag on every invocation.
|
# 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"
|
_TEST_INFRA_IMAGE = "bot-bottle-infra:itest"
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
@skip_unless_backend("docker")
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
"skipped under act_runner: the orchestrator container bind-mounts the repo "
|
"skipped under act_runner: the orchestrator container bind-mounts the repo "
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import subprocess
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.orchestrator.gateway import DockerGateway
|
from bot_bottle.orchestrator.gateway import DockerGateway
|
||||||
from tests._docker import skip_unless_docker
|
from tests._backend import skip_unless_backend
|
||||||
|
|
||||||
IMAGE = "busybox"
|
IMAGE = "busybox"
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
@skip_unless_backend("docker")
|
||||||
class TestDockerGatewayIntegration(unittest.TestCase):
|
class TestDockerGatewayIntegration(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4)
|
self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4)
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import subprocess
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.orchestrator.gateway import DockerGateway
|
from bot_bottle.orchestrator.gateway import DockerGateway
|
||||||
from tests._docker import skip_unless_docker
|
from tests._backend import skip_unless_backend
|
||||||
|
|
||||||
IMAGE = "busybox"
|
IMAGE = "busybox"
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
@skip_unless_backend("docker")
|
||||||
class TestDockerGatewayImageExists(unittest.TestCase):
|
class TestDockerGatewayImageExists(unittest.TestCase):
|
||||||
def test_image_exists_true_for_present_false_for_absent(self) -> None:
|
def test_image_exists_true_for_present_false_for_absent(self) -> None:
|
||||||
# Ensure the tiny image is present (build_if_missing is disabled here
|
# 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_create_internal,
|
||||||
network_remove,
|
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):
|
class TestOrphanCleanup(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.slug = f"cb-test-orphan-{os.getpid()}"
|
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.backend import BottleSpec, get_bottle_backend
|
||||||
from bot_bottle.bottle_state import cleanup_state
|
from bot_bottle.bottle_state import cleanup_state
|
||||||
from bot_bottle.manifest import ManifestIndex
|
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
|
# 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(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true"
|
os.environ.get("GITEA_ACTIONS") == "true"
|
||||||
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
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