7e9ad8a78d
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>
172 lines
7.6 KiB
Python
172 lines
7.6 KiB
Python
"""Integration: the Docker orchestrator's control plane enforces the
|
|
per-host auth secret against a real container (issue #400).
|
|
|
|
Unit tests exercise `dispatch()` in-process, socket-free. This starts the
|
|
actual orchestrator + gateway as Docker containers and drives the real HTTP
|
|
server over its published loopback port, the same path an agent sharing the
|
|
gateway network — or the trusted host CLI — would use.
|
|
|
|
Gated on a reachable Docker daemon. Uses unique container/network names,
|
|
test-only image tags (never the production `:latest` ones, so a rebuild
|
|
here can't make `_running_image_is_current()` see a real host's running
|
|
gateway as stale and force-recreate it), and a throwaway `BOT_BOTTLE_ROOT`
|
|
so a run never touches or collides with a real per-host orchestrator or
|
|
gateway. The whole stack is brought up once for the class (`setUpClass`),
|
|
not per test method — every test here is a read-only check against the
|
|
same running control plane.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
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._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.
|
|
_TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest"
|
|
_TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
|
|
_TEST_INFRA_IMAGE = "bot-bottle-infra:itest"
|
|
|
|
|
|
@skip_unless_backend("docker")
|
|
@unittest.skipIf(
|
|
os.environ.get("GITEA_ACTIONS") == "true",
|
|
"skipped under act_runner: the orchestrator container bind-mounts the repo "
|
|
"path into a container on the socket-shared host daemon, which can't see the "
|
|
"runner's /workspace — same host-bind-mount constraint as the other "
|
|
"bottle-bringup integration tests",
|
|
)
|
|
class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
suffix = secrets.token_hex(4)
|
|
cls._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
|
|
cls.addClassCleanup(cls._tmp.cleanup)
|
|
|
|
# host_orchestrator_token() — both the token read below and the one
|
|
# DockerInfraService injects into the container's env — resolves its
|
|
# path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root
|
|
# kwarg passed to the constructor (that kwarg only controls the DB
|
|
# bind-mount destination). Without pointing the env var at the same
|
|
# throwaway dir, this "isolated" test would read/write the developer's
|
|
# real ~/.bot-bottle/orchestrator-token.
|
|
previous_root = os.environ.get("BOT_BOTTLE_ROOT")
|
|
|
|
def _restore_root() -> None:
|
|
if previous_root is None:
|
|
os.environ.pop("BOT_BOTTLE_ROOT", None)
|
|
else:
|
|
os.environ["BOT_BOTTLE_ROOT"] = previous_root
|
|
|
|
os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name
|
|
cls.addClassCleanup(_restore_root)
|
|
|
|
infra_name = f"bot-bottle-infra-itest-{suffix}"
|
|
network = f"bot-bottle-net-itest-{suffix}"
|
|
host_root = Path(cls._tmp.name)
|
|
cls.addClassCleanup(
|
|
cls._teardown_docker, infra_name, network, host_root
|
|
)
|
|
|
|
cls.svc = DockerInfraService(
|
|
infra_name=infra_name,
|
|
network=network,
|
|
image=_TEST_INFRA_IMAGE,
|
|
port=20000 + secrets.randbelow(10000),
|
|
host_root=host_root,
|
|
)
|
|
cls.svc.ensure_running()
|
|
# The control plane now verifies role-scoped signed tokens, not the raw
|
|
# key. Mint one of each role from the host signing key (issue #469 review).
|
|
signing_key = host_orchestrator_token()
|
|
cls.cli_token = mint(ROLE_CLI, signing_key)
|
|
cls.gateway_token = mint(ROLE_GATEWAY, signing_key)
|
|
|
|
@staticmethod
|
|
def _teardown_docker(
|
|
infra_name: str, network: str, host_root: Path
|
|
) -> None:
|
|
subprocess.run(
|
|
["docker", "rm", "--force", infra_name],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
subprocess.run(
|
|
["docker", "network", "rm", network],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
# The infra container (no USER directive) wrote the registry
|
|
# DB as root into the throwaway host_root; chown it back so the
|
|
# (non-root) tempdir cleanup can remove it. Same workaround
|
|
# test_multitenant_isolation.py uses for the identical bind mount.
|
|
subprocess.run(
|
|
["docker", "run", "--rm", "-v", f"{host_root}:/r",
|
|
"--entrypoint", "chown", _TEST_INFRA_IMAGE, "-R",
|
|
f"{os.getuid()}:{os.getgid()}", "/r"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
|
|
def _request(
|
|
self, method: str, path: str, *, token: str = ""
|
|
) -> tuple[int, dict[str, object]]:
|
|
# Reuses the real host-side client's request/response handling rather
|
|
# than hand-rolling urllib here; _request (not one of the named
|
|
# wrapper methods) is what exposes raw status codes for arbitrary
|
|
# paths/tokens, which is exactly what these auth-boundary tests need.
|
|
client = OrchestratorClient(self.svc.url, auth_token=token)
|
|
return client._request(method, path) # pylint: disable=protected-access
|
|
|
|
def test_health_is_open_without_a_token(self) -> None:
|
|
status, payload = self._request("GET", "/health")
|
|
self.assertEqual(200, status)
|
|
self.assertEqual("ok", payload["status"])
|
|
|
|
def test_bottles_rejects_a_caller_with_no_token(self) -> None:
|
|
"""The enumeration attack from issue #400: an agent sharing the
|
|
gateway network could list every bottle + its policy with no
|
|
credential at all."""
|
|
status, _ = self._request("GET", "/bottles")
|
|
self.assertEqual(401, status)
|
|
|
|
def test_bottles_rejects_a_wrong_token(self) -> None:
|
|
status, _ = self._request("GET", "/bottles", token="not-the-real-secret")
|
|
self.assertEqual(401, status)
|
|
|
|
def test_bottles_accepts_the_cli_token(self) -> None:
|
|
status, payload = self._request("GET", "/bottles", token=self.cli_token)
|
|
self.assertEqual(200, status)
|
|
self.assertEqual([], payload["bottles"])
|
|
|
|
def test_bottles_rejects_the_gateway_token(self) -> None:
|
|
"""A compromised gateway holds only a `gateway` token — it must not be
|
|
able to enumerate bottles (an operator route) with it (issue #469)."""
|
|
status, _ = self._request("GET", "/bottles", token=self.gateway_token)
|
|
self.assertEqual(403, status)
|
|
|
|
def test_resolve_rejects_a_caller_with_no_token(self) -> None:
|
|
"""The credential-lift attack from issue #400: an agent could POST
|
|
/resolve directly and read back the upstream tokens it's never meant
|
|
to see."""
|
|
status, _ = self._request("POST", "/resolve")
|
|
self.assertEqual(401, status)
|
|
|
|
def test_resolve_accepts_the_gateway_token(self) -> None:
|
|
"""The gateway's own token DOES reach /resolve: with an empty body it
|
|
gets 400 (missing source_ip) rather than the 401 a rejected caller sees
|
|
— proving the role gate let the gateway token through."""
|
|
status, _ = self._request("POST", "/resolve", token=self.gateway_token)
|
|
self.assertEqual(400, status) # past the role gate; body validation fails
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|