fix(test): resolve the remaining review findings on the control-plane auth test
test / unit (pull_request) Successful in 1m17s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m21s
lint / lint (push) Successful in 2m23s
test / unit (push) Successful in 1m24s
test / integration (push) Successful in 30s
test / coverage (push) Successful in 1m26s
Update Quality Badges / update-badges (push) Successful in 1m24s

Addresses the 5 lower-priority findings left as follow-up in the earlier
review, now that each has a concrete answer:

- Add gateway_name: str = GATEWAY_NAME to OrchestratorService.__init__
  (mirrors the existing orchestrator_name param) and thread it through
  _gateway(). Deletes the test's _IsolatedOrchestratorService subclass,
  which existed only to override a private method for this one kwarg —
  any caller needing gateway-name isolation can now use the public
  constructor. Backward compatible: every existing caller constructs
  OrchestratorService with keyword args and a sensible default is kept.

- Give the test its own fixed image tags (bot-bottle-orchestrator:itest,
  bot-bottle-gateway:itest) instead of the production :latest ones.
  _running_image_is_current() keys gateway staleness off the image tag's
  ID, not per-instance identity, so rebuilding the shared :latest tag from
  whatever's on disk during a test run could make a real host's running
  production gateway look stale and get force-recreated. Fixed tags (not
  per-run-suffixed, so they don't accumulate) fully decouple the two.

- setUp -> setUpClass/tearDownClass: all 5 tests are read-only checks
  against the same running control plane, so one shared container
  lifecycle replaces 5 (each of which paid its own container-start +
  image-build + health-poll cycle). Cuts the file's wall-clock roughly
  4x (11.5s -> 2.9-4.3s) and, combined with the network-rm cleanup from
  the previous commit, means one cleanup instead of five.

- Reuse OrchestratorClient (bot_bottle/orchestrator/client.py) instead of
  a hand-rolled urllib helper — the test now exercises the same
  request/response code path the real host CLI uses, rather than a
  private copy that could silently drift from it.

- Add the chown workaround test_multitenant_isolation.py already needed
  for this exact bind-mount: the orchestrator container has no USER
  directive, so it writes the registry DB as root into the throwaway
  host_root; chown it back before tempdir cleanup so that doesn't raise
  PermissionError on native Linux Docker (no UID remap, unlike Docker
  Desktop's macOS VM).

Verified: ran the suite twice in a row (idempotency — fixed image tags
don't accumulate, 5/5 pass both times, 2.99-4.33s each), the real
~/.bot-bottle/control-plane-token is untouched, zero leaked networks or
containers after either run, exactly 2 :itest images (not growing), the
full orchestrator unit suite (93 tests) and the sibling docker
gateway/broker integration tests still pass. pyright clean, pylint
10.00/10 on both changed files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit was merged in pull request #399.
This commit is contained in:
2026-07-17 17:00:31 -04:00
parent 492669e620
commit 5c526860bc
2 changed files with 81 additions and 66 deletions
+12 -4
View File
@@ -26,7 +26,7 @@ from pathlib import Path
from .. import log from .. import log
from ..docker_cmd import run_docker from ..docker_cmd import run_docker
from ..paths import CONTROL_PLANE_TOKEN_ENV, bot_bottle_root, host_control_plane_token from ..paths import CONTROL_PLANE_TOKEN_ENV, bot_bottle_root, host_control_plane_token
from .gateway import GATEWAY_IMAGE, GATEWAY_NETWORK, DockerGateway, GatewayError from .gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, DockerGateway, GatewayError
DEFAULT_PORT = 8099 DEFAULT_PORT = 8099
ORCHESTRATOR_NAME = "bot-bottle-orchestrator" ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
@@ -83,8 +83,11 @@ class OrchestratorService:
`orchestrator_name` / `orchestrator_label` let backends run independent `orchestrator_name` / `orchestrator_label` let backends run independent
orchestrators on the same host without name collisions (e.g. the orchestrators on the same host without name collisions (e.g. the
Firecracker backend uses `bot-bottle-fc-orchestrator` alongside the Docker Firecracker backend uses `bot-bottle-fc-orchestrator` alongside the Docker
backend's `bot-bottle-orchestrator`). Subclass and override `_gateway()` backend's `bot-bottle-orchestrator`); `gateway_name` gives the paired
to supply a backend-specific gateway variant.""" gateway container the same treatment (e.g. isolated integration tests
that can't share the production `GATEWAY_NAME` singleton). Subclass and
override `_gateway()` for anything `_gateway_image`/`gateway_name` can't
express (a genuinely backend-specific gateway variant)."""
def __init__( def __init__(
self, self,
@@ -93,6 +96,7 @@ class OrchestratorService:
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
image: str = ORCHESTRATOR_IMAGE, image: str = ORCHESTRATOR_IMAGE,
gateway_image: str = GATEWAY_IMAGE, gateway_image: str = GATEWAY_IMAGE,
gateway_name: str = GATEWAY_NAME,
repo_root: Path = _REPO_ROOT, repo_root: Path = _REPO_ROOT,
host_root: Path | None = None, host_root: Path | None = None,
orchestrator_name: str = ORCHESTRATOR_NAME, orchestrator_name: str = ORCHESTRATOR_NAME,
@@ -106,6 +110,7 @@ class OrchestratorService:
# were one conflated image before the split. # were one conflated image before the split.
self.image = image self.image = image
self._gateway_image = gateway_image self._gateway_image = gateway_image
self._gateway_name = gateway_name
self._repo_root = repo_root self._repo_root = repo_root
self._host_root = host_root or bot_bottle_root() self._host_root = host_root or bot_bottle_root()
self._orchestrator_name = orchestrator_name self._orchestrator_name = orchestrator_name
@@ -175,7 +180,10 @@ class OrchestratorService:
def _gateway(self) -> DockerGateway: def _gateway(self) -> DockerGateway:
return DockerGateway( return DockerGateway(
self._gateway_image, network=self.network, orchestrator_url=self.internal_url self._gateway_image,
name=self._gateway_name,
network=self.network,
orchestrator_url=self.internal_url,
) )
def _ensure_orchestrator_image(self) -> None: def _ensure_orchestrator_image(self) -> None:
@@ -6,46 +6,34 @@ actual orchestrator + gateway as Docker containers and drives the real HTTP
server over its published loopback port, the same path an agent sharing the server over its published loopback port, the same path an agent sharing the
gateway network — or the trusted host CLI — would use. gateway network — or the trusted host CLI — would use.
Gated on a reachable Docker daemon. Uses unique container/network names and Gated on a reachable Docker daemon. Uses unique container/network names,
a throwaway `BOT_BOTTLE_ROOT` so it never collides with a real per-host test-only image tags (never the production `:latest` ones, so a rebuild
orchestrator or gateway. 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 from __future__ import annotations
import json
import os import os
import secrets import secrets
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from bot_bottle.orchestrator.control_plane import CONTROL_AUTH_HEADER from bot_bottle.orchestrator.client import OrchestratorClient
from bot_bottle.orchestrator.gateway import DockerGateway
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._docker import skip_unless_docker
# Fixed (not per-run-suffixed) so repeated runs reuse the same layer-cached
class _IsolatedOrchestratorService(OrchestratorService): # image instead of leaking a new dangling tag on every invocation.
"""`OrchestratorService`, but with a uniquely-named gateway too (the base _TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest"
class only parameterizes the orchestrator's own name/network/port) so a _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
test run can never touch a real host's orchestrator or gateway."""
def __init__(self, *, gateway_name: str, **kwargs: object) -> None:
super().__init__(**kwargs) # type: ignore[arg-type]
self._test_gateway_name = gateway_name
def _gateway(self) -> DockerGateway:
return DockerGateway(
self._gateway_image,
name=self._test_gateway_name,
network=self.network,
orchestrator_url=self.internal_url,
)
@skip_unless_docker() @skip_unless_docker()
@@ -57,18 +45,19 @@ class _IsolatedOrchestratorService(OrchestratorService):
"bottle-bringup integration tests", "bottle-bringup integration tests",
) )
class TestDockerControlPlaneAuthIntegration(unittest.TestCase): class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
def setUp(self) -> None: @classmethod
def setUpClass(cls) -> None:
suffix = secrets.token_hex(4) suffix = secrets.token_hex(4)
self._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with cls._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
self.addCleanup(self._tmp.cleanup) cls.addClassCleanup(cls._tmp.cleanup)
# host_control_plane_token() — both the test's own call below and the # host_control_plane_token() — both the token read below and the one
# one OrchestratorService makes internally to inject the container's # OrchestratorService injects into the container's env — resolves its
# env var — resolves its path via the *ambient* BOT_BOTTLE_ROOT env # path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root
# var, not the host_root kwarg passed to the constructor (that kwarg # kwarg passed to the constructor (that kwarg only controls the DB
# only controls the DB bind-mount destination). Without pointing the # bind-mount destination). Without pointing the env var at the same
# env var at the same throwaway dir, the "isolated" test reads/writes # throwaway dir, this "isolated" test would read/write the developer's
# the developer's real ~/.bot-bottle/control-plane-token. # real ~/.bot-bottle/control-plane-token.
previous_root = os.environ.get("BOT_BOTTLE_ROOT") previous_root = os.environ.get("BOT_BOTTLE_ROOT")
def _restore_root() -> None: def _restore_root() -> None:
@@ -77,43 +66,61 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
else: else:
os.environ["BOT_BOTTLE_ROOT"] = previous_root os.environ["BOT_BOTTLE_ROOT"] = previous_root
os.environ["BOT_BOTTLE_ROOT"] = self._tmp.name os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name
self.addCleanup(_restore_root) cls.addClassCleanup(_restore_root)
# ensure_running()/DockerGateway create this network but never remove orchestrator_name = f"bot-bottle-orch-itest-{suffix}"
# it — stop() only removes containers — so every run would otherwise gateway_name = f"bot-bottle-gw-itest-{suffix}"
# leak one bridge network permanently.
network = f"bot-bottle-net-itest-{suffix}" network = f"bot-bottle-net-itest-{suffix}"
self.addCleanup( host_root = Path(cls._tmp.name)
lambda: subprocess.run( cls.addClassCleanup(
["docker", "network", "rm", network], cls._teardown_docker, orchestrator_name, gateway_name, network, host_root
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
) )
self.svc = _IsolatedOrchestratorService( cls.svc = OrchestratorService(
orchestrator_name=f"bot-bottle-orch-itest-{suffix}", orchestrator_name=orchestrator_name,
gateway_name=f"bot-bottle-gw-itest-{suffix}", gateway_name=gateway_name,
network=network, network=network,
image=_TEST_ORCHESTRATOR_IMAGE,
gateway_image=_TEST_GATEWAY_IMAGE,
port=20000 + secrets.randbelow(10000), port=20000 + secrets.randbelow(10000),
host_root=Path(self._tmp.name), host_root=host_root,
)
cls.svc.ensure_running()
cls.token = host_control_plane_token()
@staticmethod
def _teardown_docker(
orchestrator_name: str, gateway_name: str, network: str, host_root: Path
) -> None:
subprocess.run(
["docker", "rm", "--force", orchestrator_name, gateway_name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
subprocess.run(
["docker", "network", "rm", network],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
# The orchestrator 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_GATEWAY_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
) )
self.addCleanup(self.svc.stop)
self.svc.ensure_running()
self.token = host_control_plane_token()
def _request( def _request(
self, method: str, path: str, *, token: str | None = None self, method: str, path: str, *, token: str = ""
) -> tuple[int, dict[str, object]]: ) -> tuple[int, dict[str, object]]:
headers = {CONTROL_AUTH_HEADER: token} if token is not None else {} # Reuses the real host-side client's request/response handling rather
req = urllib.request.Request( # than hand-rolling urllib here; _request (not one of the named
self.svc.url + path, method=method, headers=headers # wrapper methods) is what exposes raw status codes for arbitrary
) # paths/tokens, which is exactly what these auth-boundary tests need.
try: client = OrchestratorClient(self.svc.url, auth_token=token)
with urllib.request.urlopen(req, timeout=5) as resp: return client._request(method, path) # pylint: disable=protected-access
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def test_health_is_open_without_a_token(self) -> None: def test_health_is_open_without_a_token(self) -> None:
status, payload = self._request("GET", "/health") status, payload = self._request("GET", "/health")