diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 8cab6d7..eff4d5d 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -112,8 +112,8 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup yield bottle def ensure_orchestrator(self) -> str: - from ...orchestrator.lifecycle import OrchestratorService - return OrchestratorService().ensure_running() + from .infra import DockerInfraService + return DockerInfraService().ensure_running() def supervise_mcp_url(self, plan: DockerBottlePlan) -> str: """Docker bottles reach the supervise daemon via the diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py index 2f57efb..2b7097f 100644 --- a/bot_bottle/backend/docker/consolidated_launch.py +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -21,7 +21,7 @@ from ...egress import EgressPlan from ...git_gate import GitGatePlan from ...orchestrator.client import OrchestratorClient from ...gateway import GATEWAY_NETWORK -from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService +from .infra import INFRA_NAME, DockerInfraService from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.reprovision import reprovision_bottles from ..consolidated_util import provision_bottle @@ -144,7 +144,7 @@ def launch_consolidated( *, image_ref: str = "", tokens: dict[str, str] | None = None, - service: OrchestratorService | None = None, + service: DockerInfraService | None = None, infra_name: str = INFRA_NAME, network: str = GATEWAY_NETWORK, ) -> LaunchContext: @@ -154,7 +154,7 @@ def launch_consolidated( Also reprovisiones egress tokens for any already-running bottles that lost their in-memory credentials (e.g. after an infra container restart), so they regain egress access before the new bottle is registered.""" - service = service or OrchestratorService() + service = service or DockerInfraService() url = service.ensure_running() _reprovision_running_bottles(url, network=network, infra_name=infra_name) client = OrchestratorClient(url) diff --git a/bot_bottle/backend/docker/infra.py b/bot_bottle/backend/docker/infra.py new file mode 100644 index 0000000..2471082 --- /dev/null +++ b/bot_bottle/backend/docker/infra.py @@ -0,0 +1,261 @@ +"""The per-host infra container for the docker backend (PRD 0070). + +Runs both the orchestrator control plane and the gateway data plane inside a +single `bot-bottle-infra` container on the shared gateway network — the docker +analogue of the macOS infra container (`backend/macos_container/infra.py`) and +the Firecracker infra VM (`backend/firecracker/infra_vm.py`). `gateway_init` is +PID 1 and supervises both; the infra container is an idempotent per-host +singleton. + +The combined container replaces the prior two-container split +(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches the +control plane via a published loopback port; gateway daemons reach it over +127.0.0.1 (same container). + +The shared, backend-neutral pieces (control-plane port, startup timeout, +`OrchestratorStartError`, `source_hash`) live in `orchestrator.lifecycle`. +""" + +from __future__ import annotations + +import os +import time +import urllib.error +import urllib.request +from pathlib import Path + +from ... import log +from ...control_auth import ROLE_GATEWAY, mint +from ...docker_cmd import run_docker +from ...paths import ( + CONTROL_AUTH_JWT_ENV, + CONTROL_PLANE_TOKEN_ENV, + bot_bottle_root, + host_control_plane_token, + host_gateway_ca_dir, +) +from ...gateway import ( + GATEWAY_DOCKERFILE, + GATEWAY_IMAGE, + GATEWAY_NETWORK, + GatewayError, + MITMPROXY_HOME, +) +from ...orchestrator.lifecycle import ( + DEFAULT_PORT, + DEFAULT_STARTUP_TIMEOUT_SECONDS, + OrchestratorStartError, + source_hash, +) + +INFRA_NAME = "bot-bottle-infra" +INFRA_LABEL = "bot-bottle-infra=1" +# The combined infra image: gateway data plane + orchestrator content. +# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator). +INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest") +INFRA_DOCKERFILE = "Dockerfile.infra" +# Baked as a container label so `ensure_running` can detect whether the +# running container is executing the current bind-mounted source. +INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash" + +# Orchestrator image: the single canonical definition of the control-plane +# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git). +# Used as a build intermediate: `Dockerfile.infra` COPY --from this image. +ORCHESTRATOR_IMAGE = os.environ.get( + "BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest" +) +ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator" + +# The gateway daemons + orchestrator the infra container runs. +# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to +# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS). +_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator" + +# The bind-mount path for the live control-plane source inside the +# container. Separate from /app so the gateway's baked scripts +# (egress_addon.py, egress-entrypoint.sh) are not overlaid. +_SRC_IN_CONTAINER = "/bot-bottle-src" +# Bot-bottle host-root bind-mount inside the container (DB + state). The +# orchestrator control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT +# -> host_db_path()); it is the ONLY process in the container with a file +# handle on it (PRD 0070 / issue #469). +_ROOT_IN_CONTAINER = "/bot-bottle-root" + +_HEALTH_POLL_SECONDS = 0.25 +_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0 + +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +class DockerInfraService: + """Manages the single per-host docker infra container (control plane + + gateway). Callers only need `ensure_running()` + `url`. + + `infra_name` / `infra_label` let callers run independent infra containers + on the same host without name collisions (e.g. isolated integration tests + that can't share the production INFRA_NAME singleton).""" + + def __init__( + self, + *, + port: int = DEFAULT_PORT, + network: str = GATEWAY_NETWORK, + image: str = INFRA_IMAGE, + repo_root: Path = _REPO_ROOT, + host_root: Path | None = None, + infra_name: str = INFRA_NAME, + infra_label: str = INFRA_LABEL, + ) -> None: + self.port = port + self.network = network + self.image = image + self._repo_root = repo_root + self._host_root = host_root or bot_bottle_root() + self._infra_name = infra_name + self._infra_label = infra_label + + @property + def url(self) -> str: + """Host-side control-plane URL (published loopback port).""" + return f"http://127.0.0.1:{self.port}" + + def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool: + try: + with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp: + return resp.status == 200 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + def _container_running(self, name: str) -> bool: + proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"]) + return name in proc.stdout.split() + + def _infra_source_current(self, current_hash: str) -> bool: + """True iff the running infra container was started from the current + bind-mounted source. Mirrors the macOS backend's `_source_current`.""" + if not self._container_running(self._infra_name): + return False + proc = run_docker([ + "docker", "inspect", "--format", + "{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}", + self._infra_name, + ]) + if proc.returncode != 0: + return True # can't compare → don't churn a working container + return proc.stdout.strip() == current_hash + + def _ensure_network(self) -> None: + if run_docker(["docker", "network", "inspect", self.network]).returncode == 0: + return + proc = run_docker(["docker", "network", "create", self.network]) + if proc.returncode != 0 and "already exists" not in proc.stderr: + raise GatewayError( + f"gateway network {self.network} failed to create: {proc.stderr.strip()}" + ) + + def _build_images(self) -> None: + """Build the gateway base, the orchestrator intermediate, then the + infra image. All are cache-aware: a no-op when nothing changed.""" + for tag, dockerfile in ( + (GATEWAY_IMAGE, GATEWAY_DOCKERFILE), + (ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE), + (self.image, INFRA_DOCKERFILE), + ): + argv = ["docker", "build", "-t", tag, + "-f", str(self._repo_root / dockerfile), + str(self._repo_root)] + if os.environ.get("BOT_BOTTLE_NO_CACHE"): + argv.insert(2, "--no-cache") + proc = run_docker(argv) + if proc.returncode != 0: + raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}") + + def _run_infra_container(self, current_hash: str) -> None: + """Start the combined infra container (idempotent: clears a stale + fixed-name container first). Labels the container with `current_hash` + so a later `ensure_running` can detect a real code change.""" + self._ensure_network() + run_docker(["docker", "rm", "--force", self._infra_name]) + _signing_key = host_control_plane_token() + proc = run_docker([ + "docker", "run", "--detach", + "--name", self._infra_name, + "--label", self._infra_label, + "--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}", + "--network", self.network, + # Host CLI reaches the control plane here (loopback only). + # gateway_init always starts the orchestrator on DEFAULT_PORT (8099) + # inside the container; self.port is the host-side published port. + "--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}", + # Persist the mitmproxy CA on the host so it survives container + # recreation AND docker volume pruning (issue #450): every agent + # trusts this one CA, so a fresh one would break all running bottles. + "--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}", + # Live control-plane source, mounted to a path that does not + # overlay the gateway's baked /app scripts. + "--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro", + # PYTHONPATH lets the orchestrator (and other Python daemons) + # import the live source ahead of the installed package. + "--env", f"PYTHONPATH={_SRC_IN_CONTAINER}", + # Orchestrator registry DB on the host (sole writer: control plane). + "--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}", + "--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}", + # Control-plane signing key (orchestrator: verifies tokens) + the + # pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init + # scopes each to its process, so a compromised data-plane daemon never + # sees the key and can't mint a `cli` token (issue #469 review). + "--env", CONTROL_PLANE_TOKEN_ENV, + "--env", CONTROL_AUTH_JWT_ENV, + # Gateway daemons reach the orchestrator over loopback at its + # fixed internal port (DEFAULT_PORT), independent of self.port. + "--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}", + # Opt the orchestrator into gateway_init's supervise tree. + "--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}", + self.image, + ], env={ + **os.environ, + CONTROL_PLANE_TOKEN_ENV: _signing_key, + CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key), + }) + if proc.returncode != 0: + raise OrchestratorStartError( + f"infra container failed to start: {proc.stderr.strip()}" + ) + + def ensure_running( + self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS, + ) -> str: + """Ensure the infra container (control plane + gateway) is up; return + the host control-plane URL. Idempotent — a healthy container on current + source is left untouched. Raises `OrchestratorStartError` on timeout.""" + self._build_images() + + current_hash = source_hash(self._repo_root) + if self.is_healthy() and self._infra_source_current(current_hash): + return self.url + + log.info("starting infra container", context={"name": self._infra_name}) + self._run_infra_container(current_hash) + + deadline = time.monotonic() + startup_timeout + while time.monotonic() < deadline: + if self.is_healthy(): + log.info("infra container healthy", context={"url": self.url}) + return self.url + time.sleep(_HEALTH_POLL_SECONDS) + raise OrchestratorStartError( + f"infra container at {self.url} did not become healthy within {startup_timeout:g}s" + ) + + def stop(self) -> None: + """Remove the infra container (idempotent).""" + run_docker(["docker", "rm", "--force", self._infra_name]) + + +__all__ = [ + "DockerInfraService", + "INFRA_NAME", + "INFRA_IMAGE", + "INFRA_SOURCE_HASH_LABEL", + "ORCHESTRATOR_IMAGE", +] diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 78c8785..068b7c4 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -66,7 +66,7 @@ from .compose import ( from .consolidated_compose import consolidated_agent_compose from ...orchestrator.store.config_store import resolve_teardown_timeout from .consolidated_launch import launch_consolidated, teardown_consolidated -from ...orchestrator.lifecycle import INFRA_NAME +from .infra import INFRA_NAME from .gateway import DockerGateway diff --git a/bot_bottle/orchestrator/lifecycle.py b/bot_bottle/orchestrator/lifecycle.py index 5c7b9c6..d57b14d 100644 --- a/bot_bottle/orchestrator/lifecycle.py +++ b/bot_bottle/orchestrator/lifecycle.py @@ -1,94 +1,29 @@ -"""Orchestrator + gateway lifecycle (PRD 0070, docker slice). +"""Shared orchestrator-process constants + helpers (PRD 0070). -Runs both the orchestrator control plane and the gateway data plane inside -a single `bot-bottle-infra` container on the shared gateway network — -matching the structure already used by the macOS and Firecracker backends. -`gateway_init` is PID 1 and supervises both; the infra container is an -idempotent per-host singleton. - -The combined container replaces the prior two-container split -(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches -the control plane via a published loopback port; gateway daemons reach it -over 127.0.0.1 (same container). +Backend-neutral pieces the per-backend infra services build on: the +control-plane port, the startup timeout, the start-error, and the source hash +used to detect a code change. The per-backend infra lifecycle lives with each +backend — docker: `backend.docker.infra.DockerInfraService`; macOS: +`backend.macos_container.infra`; firecracker: `backend.firecracker.infra_vm`. """ from __future__ import annotations import hashlib -import os -import time -import urllib.error -import urllib.request from pathlib import Path -from .. import log -from ..control_auth import ROLE_GATEWAY, mint -from ..docker_cmd import run_docker -from ..paths import ( - CONTROL_AUTH_JWT_ENV, - CONTROL_PLANE_TOKEN_ENV, - bot_bottle_root, - host_control_plane_token, - host_gateway_ca_dir, -) -from ..gateway import ( - GATEWAY_DOCKERFILE, - GATEWAY_IMAGE, - GATEWAY_NETWORK, - GatewayError, - MITMPROXY_HOME, -) - DEFAULT_PORT = 8099 DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0 -INFRA_NAME = "bot-bottle-infra" -INFRA_LABEL = "bot-bottle-infra=1" -# The combined infra image: gateway data plane + orchestrator content. -# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator). -INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest") -INFRA_DOCKERFILE = "Dockerfile.infra" -# Baked as a container label so `ensure_running` can detect whether the -# running container is executing the current bind-mounted source. -INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash" - -# Orchestrator image: the single canonical definition of the control-plane -# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git). -# Used as a build intermediate: `Dockerfile.infra` COPY --from this image. -ORCHESTRATOR_IMAGE = os.environ.get( - "BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest" -) -ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator" - -# The gateway daemons + orchestrator the infra container runs. -# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to -# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS). -_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator" - -# The bind-mount path for the live control-plane source inside the -# container. Separate from /app so the gateway's baked scripts -# (egress_addon.py, egress-entrypoint.sh) are not overlaid. -_SRC_IN_CONTAINER = "/bot-bottle-src" -# Bot-bottle host-root bind-mount inside the container (DB + state). The -# orchestrator control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT -# -> host_db_path()); it is the ONLY process in the container with a file -# handle on it (PRD 0070 / issue #469). -_ROOT_IN_CONTAINER = "/bot-bottle-root" - -_HEALTH_POLL_SECONDS = 0.25 -_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0 - -_REPO_ROOT = Path(__file__).resolve().parents[2] - class OrchestratorStartError(RuntimeError): - """The infra container did not become healthy within the timeout.""" + """The infra container/VM did not become healthy within the timeout.""" def source_hash(repo_root: Path) -> str: """Content hash of the orchestrator's bind-mounted Python source (the `bot_bottle` package the control-plane process imports). Changes only - when the code that would actually run changes — `ensure_running` + when the code that would actually run changes — a backend's `ensure_running` recreates the container on a mismatch so a code change takes effect, but leaves a healthy up-to-date container alone to preserve in-memory egress tokens.""" @@ -99,179 +34,9 @@ def source_hash(repo_root: Path) -> str: return h.hexdigest() -class OrchestratorService: - """Manages the single per-host infra container (control plane + gateway). - Callers only need `ensure_running()` + `url`. - - `infra_name` / `infra_label` let backends run independent infra containers - on the same host without name collisions (e.g. isolated integration tests - that can't share the production INFRA_NAME singleton).""" - - def __init__( - self, - *, - port: int = DEFAULT_PORT, - network: str = GATEWAY_NETWORK, - image: str = INFRA_IMAGE, - repo_root: Path = _REPO_ROOT, - host_root: Path | None = None, - infra_name: str = INFRA_NAME, - infra_label: str = INFRA_LABEL, - ) -> None: - self.port = port - self.network = network - self.image = image - self._repo_root = repo_root - self._host_root = host_root or bot_bottle_root() - self._infra_name = infra_name - self._infra_label = infra_label - - @property - def url(self) -> str: - """Host-side control-plane URL (published loopback port).""" - return f"http://127.0.0.1:{self.port}" - - def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool: - try: - with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp: - return resp.status == 200 - except (urllib.error.URLError, TimeoutError, OSError): - return False - - def _container_running(self, name: str) -> bool: - proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"]) - return name in proc.stdout.split() - - def _infra_source_current(self, current_hash: str) -> bool: - """True iff the running infra container was started from the current - bind-mounted source. Mirrors the macOS backend's `_source_current`.""" - if not self._container_running(self._infra_name): - return False - proc = run_docker([ - "docker", "inspect", "--format", - "{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}", - self._infra_name, - ]) - if proc.returncode != 0: - return True # can't compare → don't churn a working container - return proc.stdout.strip() == current_hash - - def _ensure_network(self) -> None: - if run_docker(["docker", "network", "inspect", self.network]).returncode == 0: - return - proc = run_docker(["docker", "network", "create", self.network]) - if proc.returncode != 0 and "already exists" not in proc.stderr: - raise GatewayError( - f"gateway network {self.network} failed to create: {proc.stderr.strip()}" - ) - - def _build_images(self) -> None: - """Build the gateway base, the orchestrator intermediate, then the - infra image. All are cache-aware: a no-op when nothing changed.""" - for tag, dockerfile in ( - (GATEWAY_IMAGE, GATEWAY_DOCKERFILE), - (ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE), - (self.image, INFRA_DOCKERFILE), - ): - argv = ["docker", "build", "-t", tag, - "-f", str(self._repo_root / dockerfile), - str(self._repo_root)] - if os.environ.get("BOT_BOTTLE_NO_CACHE"): - argv.insert(2, "--no-cache") - proc = run_docker(argv) - if proc.returncode != 0: - raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}") - - def _run_infra_container(self, current_hash: str) -> None: - """Start the combined infra container (idempotent: clears a stale - fixed-name container first). Labels the container with `current_hash` - so a later `ensure_running` can detect a real code change.""" - self._ensure_network() - run_docker(["docker", "rm", "--force", self._infra_name]) - _signing_key = host_control_plane_token() - proc = run_docker([ - "docker", "run", "--detach", - "--name", self._infra_name, - "--label", self._infra_label, - "--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}", - "--network", self.network, - # Host CLI reaches the control plane here (loopback only). - # gateway_init always starts the orchestrator on DEFAULT_PORT (8099) - # inside the container; self.port is the host-side published port. - "--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}", - # Persist the mitmproxy CA on the host so it survives container - # recreation AND docker volume pruning (issue #450): every agent - # trusts this one CA, so a fresh one would break all running bottles. - "--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}", - # Live control-plane source, mounted to a path that does not - # overlay the gateway's baked /app scripts. - "--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro", - # PYTHONPATH lets the orchestrator (and other Python daemons) - # import the live source ahead of the installed package. - "--env", f"PYTHONPATH={_SRC_IN_CONTAINER}", - # Orchestrator registry DB on the host (sole writer: control plane). - "--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}", - "--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}", - # Control-plane signing key (orchestrator: verifies tokens) + the - # pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init - # scopes each to its process, so a compromised data-plane daemon never - # sees the key and can't mint a `cli` token (issue #469 review). - "--env", CONTROL_PLANE_TOKEN_ENV, - "--env", CONTROL_AUTH_JWT_ENV, - # Gateway daemons reach the orchestrator over loopback at its - # fixed internal port (DEFAULT_PORT), independent of self.port. - "--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}", - # Opt the orchestrator into gateway_init's supervise tree. - "--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}", - self.image, - ], env={ - **os.environ, - CONTROL_PLANE_TOKEN_ENV: _signing_key, - CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key), - }) - if proc.returncode != 0: - raise OrchestratorStartError( - f"infra container failed to start: {proc.stderr.strip()}" - ) - - def ensure_running( - self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS, - ) -> str: - """Ensure the infra container (control plane + gateway) is up; return - the host control-plane URL. Idempotent — a healthy container on current - source is left untouched. Raises `OrchestratorStartError` on timeout.""" - self._build_images() - - current_hash = source_hash(self._repo_root) - if self.is_healthy() and self._infra_source_current(current_hash): - return self.url - - log.info("starting infra container", context={"name": self._infra_name}) - self._run_infra_container(current_hash) - - deadline = time.monotonic() + startup_timeout - while time.monotonic() < deadline: - if self.is_healthy(): - log.info("infra container healthy", context={"url": self.url}) - return self.url - time.sleep(_HEALTH_POLL_SECONDS) - raise OrchestratorStartError( - f"infra container at {self.url} did not become healthy within {startup_timeout:g}s" - ) - - def stop(self) -> None: - """Remove the infra container (idempotent).""" - run_docker(["docker", "rm", "--force", self._infra_name]) - - __all__ = [ - "OrchestratorService", - "OrchestratorStartError", - "INFRA_NAME", - "INFRA_IMAGE", - "INFRA_SOURCE_HASH_LABEL", - "ORCHESTRATOR_IMAGE", "DEFAULT_PORT", "DEFAULT_STARTUP_TIMEOUT_SECONDS", + "OrchestratorStartError", "source_hash", ] diff --git a/bot_bottle/orchestrator/rotate_ca.py b/bot_bottle/orchestrator/rotate_ca.py index 5ae7d2d..0cae300 100644 --- a/bot_bottle/orchestrator/rotate_ca.py +++ b/bot_bottle/orchestrator/rotate_ca.py @@ -26,7 +26,7 @@ from pathlib import Path from ..docker_cmd import run_docker from ..paths import host_gateway_ca_dir from ..gateway import GATEWAY_NAME, rotate_gateway_ca -from .lifecycle import INFRA_NAME +from ..backend.docker.infra import INFRA_NAME # The containers whose mitmproxy would still be serving the old CA from memory: # the consolidated infra container and the standalone per-host gateway. diff --git a/tests/integration/test_multitenant_isolation.py b/tests/integration/test_multitenant_isolation.py index fdd8ea5..e32f9e2 100644 --- a/tests/integration/test_multitenant_isolation.py +++ b/tests/integration/test_multitenant_isolation.py @@ -30,7 +30,7 @@ from bot_bottle.backend.docker.egress import EGRESS_PORT from bot_bottle.backend.docker.gateway_net import next_free_ip from bot_bottle.orchestrator.client import OrchestratorClient from bot_bottle.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK -from bot_bottle.orchestrator.lifecycle import OrchestratorService +from bot_bottle.backend.docker.infra import DockerInfraService from tests._docker import skip_unless_docker # One upstream reachable under two names; reflects the Authorization header so @@ -85,7 +85,7 @@ class TestMultitenantIsolation(unittest.TestCase): self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) # Throwaway root → a clean registry DB, independent of the host's. - self.svc = OrchestratorService(host_root=Path(self._tmp.name)) + self.svc = DockerInfraService(host_root=Path(self._tmp.name)) self.addCleanup(self._teardown_docker) # ensure_running builds the bundle image (slow on a cold cache) and # brings up the shared network + gateway + orchestrator. diff --git a/tests/integration/test_orchestrator_docker_control_plane_auth.py b/tests/integration/test_orchestrator_docker_control_plane_auth.py index aedb8ca..08a6c62 100644 --- a/tests/integration/test_orchestrator_docker_control_plane_auth.py +++ b/tests/integration/test_orchestrator_docker_control_plane_auth.py @@ -27,7 +27,7 @@ from pathlib import Path from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator.client import OrchestratorClient -from bot_bottle.orchestrator.lifecycle import OrchestratorService +from bot_bottle.backend.docker.infra import DockerInfraService from bot_bottle.paths import host_control_plane_token from tests._docker import skip_unless_docker @@ -54,7 +54,7 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase): cls.addClassCleanup(cls._tmp.cleanup) # host_control_plane_token() — both the token read below and the one - # OrchestratorService injects into the container's env — resolves its + # 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 @@ -78,7 +78,7 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase): cls._teardown_docker, infra_name, network, host_root ) - cls.svc = OrchestratorService( + cls.svc = DockerInfraService( infra_name=infra_name, network=network, image=_TEST_INFRA_IMAGE, diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index a5a4c18..bb549d1 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -390,10 +390,10 @@ class TestEnsureOrchestrator(unittest.TestCase): the orchestrator + gateway containers; firecracker boots the infra VM; macos-container starts the infra container.""" - def test_docker_delegates_to_orchestrator_service(self): + def test_docker_delegates_to_infra_service(self): b = get_bottle_backend("docker") with patch( - "bot_bottle.orchestrator.lifecycle.OrchestratorService" + "bot_bottle.backend.docker.infra.DockerInfraService" ) as service_cls: service_cls.return_value.ensure_running.return_value = ( "http://127.0.0.1:8099" diff --git a/tests/unit/test_orchestrator_lifecycle.py b/tests/unit/test_docker_infra.py similarity index 95% rename from tests/unit/test_orchestrator_lifecycle.py rename to tests/unit/test_docker_infra.py index 1a65908..bf85eb4 100644 --- a/tests/unit/test_orchestrator_lifecycle.py +++ b/tests/unit/test_docker_infra.py @@ -9,20 +9,22 @@ from pathlib import Path from unittest.mock import MagicMock, Mock, patch from bot_bottle.gateway import GatewayError -from bot_bottle.orchestrator.lifecycle import ( +from bot_bottle.backend.docker.infra import ( INFRA_NAME, INFRA_SOURCE_HASH_LABEL, - OrchestratorService, + DockerInfraService, +) +from bot_bottle.orchestrator.lifecycle import ( OrchestratorStartError, source_hash, ) from bot_bottle.paths import GATEWAY_CA_DIRNAME from tests.unit import use_bottle_root -_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen" -_RUN = "bot_bottle.orchestrator.lifecycle.run_docker" -_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep" -_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic" +_URLOPEN = "bot_bottle.backend.docker.infra.urllib.request.urlopen" +_RUN = "bot_bottle.backend.docker.infra.run_docker" +_SLEEP = "bot_bottle.backend.docker.infra.time.sleep" +_MONOTONIC = "bot_bottle.backend.docker.infra.time.monotonic" def _health(status: int) -> MagicMock: @@ -35,12 +37,12 @@ def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock: return Mock(returncode=returncode, stdout=stdout, stderr=stderr) -class TestOrchestratorService(unittest.TestCase): +class TestDockerInfraService(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) self.addCleanup(use_bottle_root(Path(self._tmp.name))) - self.svc = OrchestratorService(port=8099) + self.svc = DockerInfraService(port=8099) def test_url(self) -> None: self.assertEqual("http://127.0.0.1:8099", self.svc.url) @@ -159,7 +161,7 @@ class TestOrchestratorService(unittest.TestCase): return _proc(stdout="") return _proc() - svc = OrchestratorService(port=20001) + svc = DockerInfraService(port=20001) with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ patch(_RUN, side_effect=fake), patch(_SLEEP): svc.ensure_running() diff --git a/tests/unit/test_orchestrator_rotate_ca.py b/tests/unit/test_orchestrator_rotate_ca.py index 3c2db89..e3ee60b 100644 --- a/tests/unit/test_orchestrator_rotate_ca.py +++ b/tests/unit/test_orchestrator_rotate_ca.py @@ -9,7 +9,7 @@ from unittest.mock import Mock, patch from bot_bottle.orchestrator import rotate_ca from bot_bottle.gateway import GATEWAY_NAME -from bot_bottle.orchestrator.lifecycle import INFRA_NAME +from bot_bottle.backend.docker.infra import INFRA_NAME from bot_bottle.paths import host_gateway_ca_dir from tests.unit import use_bottle_root