refactor(docker): rename OrchestratorService -> DockerInfraService, move to backend/docker
test / integration-docker (pull_request) Successful in 22s
lint / lint (push) Successful in 1m7s
test / unit (pull_request) Successful in 2m10s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 15s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 13m2s
test / integration-docker (pull_request) Successful in 22s
lint / lint (push) Successful in 1m7s
test / unit (pull_request) Successful in 2m10s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 15s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 13m2s
OrchestratorService wasn't the orchestrator — it's the host-side lifecycle of the docker infra *container* (the one that runs the Orchestrator). It read as "the orchestrator as a service" and lived in orchestrator/lifecycle.py, while its siblings (macOS MacosInfraService, Firecracker infra_vm) live under their backend package. Rename it DockerInfraService and move it to backend/docker/infra.py alongside the docker backend, with its docker-only constants (INFRA_*/ORCHESTRATOR_* image + container names, daemon list, mount paths). orchestrator/lifecycle.py keeps only the backend-neutral pieces the other infra services share — DEFAULT_PORT, DEFAULT_STARTUP_TIMEOUT_SECONDS, OrchestratorStartError, source_hash — which macOS / firecracker / client still import from there. backend/docker/infra.py imports those (backend -> orchestrator is an allowed direction). Renamed the unit test to test_docker_infra.py. Full unit suite green (2243). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -112,8 +112,8 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def ensure_orchestrator(self) -> str:
|
def ensure_orchestrator(self) -> str:
|
||||||
from ...orchestrator.lifecycle import OrchestratorService
|
from .infra import DockerInfraService
|
||||||
return OrchestratorService().ensure_running()
|
return DockerInfraService().ensure_running()
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||||
"""Docker bottles reach the supervise daemon via the
|
"""Docker bottles reach the supervise daemon via the
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from ...egress import EgressPlan
|
|||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...orchestrator.client import OrchestratorClient
|
from ...orchestrator.client import OrchestratorClient
|
||||||
from ...gateway import GATEWAY_NETWORK
|
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.store.secret_store import ENV_VAR_SECRET_NAME
|
||||||
from ...orchestrator.reprovision import reprovision_bottles
|
from ...orchestrator.reprovision import reprovision_bottles
|
||||||
from ..consolidated_util import provision_bottle
|
from ..consolidated_util import provision_bottle
|
||||||
@@ -144,7 +144,7 @@ def launch_consolidated(
|
|||||||
*,
|
*,
|
||||||
image_ref: str = "",
|
image_ref: str = "",
|
||||||
tokens: dict[str, str] | None = None,
|
tokens: dict[str, str] | None = None,
|
||||||
service: OrchestratorService | None = None,
|
service: DockerInfraService | None = None,
|
||||||
infra_name: str = INFRA_NAME,
|
infra_name: str = INFRA_NAME,
|
||||||
network: str = GATEWAY_NETWORK,
|
network: str = GATEWAY_NETWORK,
|
||||||
) -> LaunchContext:
|
) -> LaunchContext:
|
||||||
@@ -154,7 +154,7 @@ def launch_consolidated(
|
|||||||
Also reprovisiones egress tokens for any already-running bottles that lost
|
Also reprovisiones egress tokens for any already-running bottles that lost
|
||||||
their in-memory credentials (e.g. after an infra container restart), so
|
their in-memory credentials (e.g. after an infra container restart), so
|
||||||
they regain egress access before the new bottle is registered."""
|
they regain egress access before the new bottle is registered."""
|
||||||
service = service or OrchestratorService()
|
service = service or DockerInfraService()
|
||||||
url = service.ensure_running()
|
url = service.ensure_running()
|
||||||
_reprovision_running_bottles(url, network=network, infra_name=infra_name)
|
_reprovision_running_bottles(url, network=network, infra_name=infra_name)
|
||||||
client = OrchestratorClient(url)
|
client = OrchestratorClient(url)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -66,7 +66,7 @@ from .compose import (
|
|||||||
from .consolidated_compose import consolidated_agent_compose
|
from .consolidated_compose import consolidated_agent_compose
|
||||||
from ...orchestrator.store.config_store import resolve_teardown_timeout
|
from ...orchestrator.store.config_store import resolve_teardown_timeout
|
||||||
from .consolidated_launch import launch_consolidated, teardown_consolidated
|
from .consolidated_launch import launch_consolidated, teardown_consolidated
|
||||||
from ...orchestrator.lifecycle import INFRA_NAME
|
from .infra import INFRA_NAME
|
||||||
from .gateway import DockerGateway
|
from .gateway import DockerGateway
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Backend-neutral pieces the per-backend infra services build on: the
|
||||||
a single `bot-bottle-infra` container on the shared gateway network —
|
control-plane port, the startup timeout, the start-error, and the source hash
|
||||||
matching the structure already used by the macOS and Firecracker backends.
|
used to detect a code change. The per-backend infra lifecycle lives with each
|
||||||
`gateway_init` is PID 1 and supervises both; the infra container is an
|
backend — docker: `backend.docker.infra.DockerInfraService`; macOS:
|
||||||
idempotent per-host singleton.
|
`backend.macos_container.infra`; firecracker: `backend.firecracker.infra_vm`.
|
||||||
|
|
||||||
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).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
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_PORT = 8099
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
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):
|
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:
|
def source_hash(repo_root: Path) -> str:
|
||||||
"""Content hash of the orchestrator's bind-mounted Python source (the
|
"""Content hash of the orchestrator's bind-mounted Python source (the
|
||||||
`bot_bottle` package the control-plane process imports). Changes only
|
`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,
|
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
|
but leaves a healthy up-to-date container alone to preserve in-memory
|
||||||
egress tokens."""
|
egress tokens."""
|
||||||
@@ -99,179 +34,9 @@ def source_hash(repo_root: Path) -> str:
|
|||||||
return h.hexdigest()
|
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__ = [
|
__all__ = [
|
||||||
"OrchestratorService",
|
|
||||||
"OrchestratorStartError",
|
|
||||||
"INFRA_NAME",
|
|
||||||
"INFRA_IMAGE",
|
|
||||||
"INFRA_SOURCE_HASH_LABEL",
|
|
||||||
"ORCHESTRATOR_IMAGE",
|
|
||||||
"DEFAULT_PORT",
|
"DEFAULT_PORT",
|
||||||
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
||||||
|
"OrchestratorStartError",
|
||||||
"source_hash",
|
"source_hash",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from pathlib import Path
|
|||||||
from ..docker_cmd import run_docker
|
from ..docker_cmd import run_docker
|
||||||
from ..paths import host_gateway_ca_dir
|
from ..paths import host_gateway_ca_dir
|
||||||
from ..gateway import GATEWAY_NAME, rotate_gateway_ca
|
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 containers whose mitmproxy would still be serving the old CA from memory:
|
||||||
# the consolidated infra container and the standalone per-host gateway.
|
# the consolidated infra container and the standalone per-host gateway.
|
||||||
|
|||||||
@@ -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.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.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK
|
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
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
# One upstream reachable under two names; reflects the Authorization header so
|
# One upstream reachable under two names; reflects the Authorization header so
|
||||||
@@ -85,7 +85,7 @@ class TestMultitenantIsolation(unittest.TestCase):
|
|||||||
self._tmp = tempfile.TemporaryDirectory()
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self._tmp.cleanup)
|
self.addCleanup(self._tmp.cleanup)
|
||||||
# Throwaway root → a clean registry DB, independent of the host's.
|
# 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)
|
self.addCleanup(self._teardown_docker)
|
||||||
# ensure_running builds the bundle image (slow on a cold cache) and
|
# ensure_running builds the bundle image (slow on a cold cache) and
|
||||||
# brings up the shared network + gateway + orchestrator.
|
# brings up the shared network + gateway + orchestrator.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
||||||
from bot_bottle.orchestrator.client import OrchestratorClient
|
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 bot_bottle.paths import host_control_plane_token
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
|||||||
cls.addClassCleanup(cls._tmp.cleanup)
|
cls.addClassCleanup(cls._tmp.cleanup)
|
||||||
|
|
||||||
# host_control_plane_token() — both the token read below and the one
|
# 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
|
# path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root
|
||||||
# kwarg passed to the constructor (that kwarg only controls the DB
|
# kwarg passed to the constructor (that kwarg only controls the DB
|
||||||
# bind-mount destination). Without pointing the env var at the same
|
# 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._teardown_docker, infra_name, network, host_root
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.svc = OrchestratorService(
|
cls.svc = DockerInfraService(
|
||||||
infra_name=infra_name,
|
infra_name=infra_name,
|
||||||
network=network,
|
network=network,
|
||||||
image=_TEST_INFRA_IMAGE,
|
image=_TEST_INFRA_IMAGE,
|
||||||
|
|||||||
@@ -390,10 +390,10 @@ class TestEnsureOrchestrator(unittest.TestCase):
|
|||||||
the orchestrator + gateway containers; firecracker boots the infra VM;
|
the orchestrator + gateway containers; firecracker boots the infra VM;
|
||||||
macos-container starts the infra container."""
|
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")
|
b = get_bottle_backend("docker")
|
||||||
with patch(
|
with patch(
|
||||||
"bot_bottle.orchestrator.lifecycle.OrchestratorService"
|
"bot_bottle.backend.docker.infra.DockerInfraService"
|
||||||
) as service_cls:
|
) as service_cls:
|
||||||
service_cls.return_value.ensure_running.return_value = (
|
service_cls.return_value.ensure_running.return_value = (
|
||||||
"http://127.0.0.1:8099"
|
"http://127.0.0.1:8099"
|
||||||
|
|||||||
@@ -9,20 +9,22 @@ from pathlib import Path
|
|||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
from bot_bottle.gateway import GatewayError
|
from bot_bottle.gateway import GatewayError
|
||||||
from bot_bottle.orchestrator.lifecycle import (
|
from bot_bottle.backend.docker.infra import (
|
||||||
INFRA_NAME,
|
INFRA_NAME,
|
||||||
INFRA_SOURCE_HASH_LABEL,
|
INFRA_SOURCE_HASH_LABEL,
|
||||||
OrchestratorService,
|
DockerInfraService,
|
||||||
|
)
|
||||||
|
from bot_bottle.orchestrator.lifecycle import (
|
||||||
OrchestratorStartError,
|
OrchestratorStartError,
|
||||||
source_hash,
|
source_hash,
|
||||||
)
|
)
|
||||||
from bot_bottle.paths import GATEWAY_CA_DIRNAME
|
from bot_bottle.paths import GATEWAY_CA_DIRNAME
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
|
|
||||||
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
_URLOPEN = "bot_bottle.backend.docker.infra.urllib.request.urlopen"
|
||||||
_RUN = "bot_bottle.orchestrator.lifecycle.run_docker"
|
_RUN = "bot_bottle.backend.docker.infra.run_docker"
|
||||||
_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep"
|
_SLEEP = "bot_bottle.backend.docker.infra.time.sleep"
|
||||||
_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic"
|
_MONOTONIC = "bot_bottle.backend.docker.infra.time.monotonic"
|
||||||
|
|
||||||
|
|
||||||
def _health(status: int) -> MagicMock:
|
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)
|
return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
class TestOrchestratorService(unittest.TestCase):
|
class TestDockerInfraService(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self._tmp.cleanup)
|
self.addCleanup(self._tmp.cleanup)
|
||||||
self.addCleanup(use_bottle_root(Path(self._tmp.name)))
|
self.addCleanup(use_bottle_root(Path(self._tmp.name)))
|
||||||
self.svc = OrchestratorService(port=8099)
|
self.svc = DockerInfraService(port=8099)
|
||||||
|
|
||||||
def test_url(self) -> None:
|
def test_url(self) -> None:
|
||||||
self.assertEqual("http://127.0.0.1:8099", self.svc.url)
|
self.assertEqual("http://127.0.0.1:8099", self.svc.url)
|
||||||
@@ -159,7 +161,7 @@ class TestOrchestratorService(unittest.TestCase):
|
|||||||
return _proc(stdout="")
|
return _proc(stdout="")
|
||||||
return _proc()
|
return _proc()
|
||||||
|
|
||||||
svc = OrchestratorService(port=20001)
|
svc = DockerInfraService(port=20001)
|
||||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||||
svc.ensure_running()
|
svc.ensure_running()
|
||||||
@@ -9,7 +9,7 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
from bot_bottle.orchestrator import rotate_ca
|
from bot_bottle.orchestrator import rotate_ca
|
||||||
from bot_bottle.gateway import GATEWAY_NAME
|
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 bot_bottle.paths import host_gateway_ca_dir
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user