refactor(orchestrator): macOS orchestrator under the Orchestrator ABC

Extract the Apple-Container control-plane lifecycle out of `MacosInfraService`
into `MacosOrchestrator` (backend/macos_container/orchestrator.py): the
container run on the host-only control network, the container-only DB volume,
the source-hash recreate gate, health polling against the resolved
control-network address, and `probe_orchestrator_url`. `url()` == `gateway_url()`
here (Apple has no container DNS, so one resolved address serves the CLI and the
gateway).

`MacosInfraService` now composes `orchestrator()` + `gateway()`: ensure the
networks, build both images (each service self-builds via `ensure_built` — added
to `MacosGateway` too), bring the orchestrator up first, then connect the gateway
with the orchestrator-minted token. `ORCHESTRATOR_IMAGE` + the DB-volume constant
move to the orchestrator module (with back-compat aliases where imported).

Container-lifecycle tests split into test_macos_orchestrator; test_macos_infra
now covers the composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:44:21 -04:00
parent cc4e29c3da
commit 5465379654
5 changed files with 486 additions and 301 deletions
+13 -7
View File
@@ -14,6 +14,7 @@ share: the network names, the gateway image, and the network-creation helper.
from __future__ import annotations
import os
from pathlib import Path
from ...gateway import (
DEFAULT_CA_TIMEOUT_SECONDS,
@@ -50,9 +51,8 @@ GATEWAY_LABEL = "bot-bottle-mac-infra=1"
GATEWAY_DAEMONS = "egress,git-http,supervise"
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
ORCHESTRATOR_IMAGE = os.environ.get(
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
_REPO_ROOT = Path(__file__).resolve().parents[3]
def ensure_networks(
@@ -72,9 +72,9 @@ class MacosGateway(Gateway):
"""The consolidated gateway as a single Apple container, triple-homed on the
NAT egress, host-only agent, and control networks.
Images are built by the infra service (`ensure_built` here is the ABC's
no-op); the networks are ensured there too. `connect_to_orchestrator` runs
the container carrying the mitmproxy CA + the pre-minted `gateway` token."""
`ensure_built` builds `Dockerfile.gateway`; the networks are ensured by the
composer. `connect_to_orchestrator` runs the container carrying the mitmproxy
CA + the pre-minted `gateway` token."""
def __init__(
self,
@@ -84,18 +84,25 @@ class MacosGateway(Gateway):
network: str = GATEWAY_NETWORK,
egress_network: str = GATEWAY_EGRESS_NETWORK,
control_network: str = CONTROL_NETWORK,
repo_root: Path = _REPO_ROOT,
) -> None:
self.image_ref = image_ref
self.name = name
self.network = network
self.egress_network = egress_network
self.control_network = control_network
self._repo_root = repo_root
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
# against + the pre-minted `gateway` token they present. The gateway
# never mints, so it never holds the signing key (#469).
self._orchestrator_url = ""
self._gateway_token = ""
def ensure_built(self) -> None:
"""Build the data-plane image from `Dockerfile.gateway`."""
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None:
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on
the agent + control networks, resolving policy against `orchestrator_url`
@@ -198,7 +205,6 @@ __all__ = [
"GATEWAY_LABEL",
"GATEWAY_DAEMONS",
"GATEWAY_IMAGE",
"ORCHESTRATOR_IMAGE",
"GatewayError",
"DEFAULT_CA_TIMEOUT_SECONDS",
"ensure_networks",
+52 -150
View File
@@ -6,40 +6,29 @@ model existed only because two Apple-Container guests writing one `bot-bottle.db
over virtiofs would race incoherent `fcntl` locks; with the data plane no longer
opening the DB at all, only the orchestrator does, so the split is safe.
* `bot-bottle-mac-orchestrator` — the lean control plane. Joins the host-only
**control network** (`bot-bottle-mac-control`) only. Sole opener of the
container-only DB volume; holds the signing key. The host CLI reaches it at
its control-network address; the gateway reaches it there too.
* `bot-bottle-mac-infra` — the gateway data plane. Triple-homed: the NAT
egress network (route out), the host-only agent network (agents + CLI reach
the gateway), and the control network (reach the orchestrator by IP — Apple
has no container DNS). Holds the mitmproxy CA + the `gateway` JWT.
* `bot-bottle-mac-orchestrator` — the lean control plane (`MacosOrchestrator`).
Joins the host-only **control network** (`bot-bottle-mac-control`) only. Sole
opener of the container-only DB volume; holds the signing key. The host CLI
reaches it at its control-network address; the gateway reaches it there too.
* `bot-bottle-mac-infra` — the gateway data plane (`MacosGateway`). Triple-homed:
the NAT egress network (route out), the host-only agent network (agents + CLI
reach the gateway), and the control network (reach the orchestrator by IP —
Apple has no container DNS). Holds the mitmproxy CA + the `gateway` JWT.
Agents sit on the agent network only, never the control network, so they have no
route to the control plane (the L3 block, not just the JWT).
`MacosInfraService` composes the two services and brings them up as an idempotent
per-host pair. Agents sit on the agent network only, never the control network,
so they have no route to the control plane (the L3 block, not just the JWT).
"""
from __future__ import annotations
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from ... import log
from ...orchestrator.lifecycle import (
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
OrchestratorStartError,
source_hash,
)
from ...orchestrator_auth import ROLE_GATEWAY, mint
from ...paths import (
ORCHESTRATOR_TOKEN_ENV,
HOST_DB_FILENAME,
host_orchestrator_token,
)
from . import util as container_mod
from .gateway import (
@@ -51,31 +40,24 @@ from .gateway import (
GATEWAY_NETWORK,
GatewayError,
MacosGateway,
ORCHESTRATOR_IMAGE,
ensure_networks,
)
from .orchestrator import (
ORCHESTRATOR_DB_VOLUME,
ORCHESTRATOR_IMAGE,
ORCHESTRATOR_NAME,
MacosOrchestrator,
probe_orchestrator_url,
)
# The orchestrator (control plane) container. `INFRA_NAME` is kept — now aliasing
# the gateway container — for callers that still import it (probe / reprovision
# attribute against the gateway).
ORCHESTRATOR_NAME = "bot-bottle-mac-orchestrator"
ORCHESTRATOR_LABEL = "bot-bottle-mac-orchestrator=1"
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
# Container-only volume holding bot-bottle.db, mounted ONLY into the
# orchestrator. One kernel writes it (never host-shared or cross-guest).
INFRA_DB_VOLUME = "bot-bottle-mac-db"
# BOT_BOTTLE_ROOT inside the orchestrator; host_db_path() resolves the DB to
# <root>/db/<filename>.
_DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
_DB_PATH_IN_CONTAINER = f"{_DB_ROOT_IN_CONTAINER}/db/{HOST_DB_FILENAME}"
_SRC_IN_CONTAINER = "/bot-bottle-src"
# `INFRA_NAME` is kept — now aliasing the gateway container — for callers that
# still import it (probe / reprovision attribute against the gateway).
INFRA_NAME = GATEWAY_NAME
# Back-compat alias: the DB volume moved to the orchestrator module.
INFRA_DB_VOLUME = ORCHESTRATOR_DB_VOLUME
_REPO_ROOT = Path(__file__).resolve().parents[3]
_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
@dataclass(frozen=True)
class InfraEndpoint:
@@ -88,7 +70,7 @@ class InfraEndpoint:
class MacosInfraService:
"""Manages the per-host orchestrator + gateway containers. Callers use
"""Composes the per-host orchestrator + gateway containers. Callers use
`ensure_running()` (returns the endpoint) and `ca_cert_pem()`."""
def __init__(
@@ -103,7 +85,7 @@ class MacosInfraService:
repo_root: Path = _REPO_ROOT,
orchestrator_name: str = ORCHESTRATOR_NAME,
gateway_name: str = INFRA_NAME,
db_volume: str = INFRA_DB_VOLUME,
db_volume: str = ORCHESTRATOR_DB_VOLUME,
) -> None:
self.port = port
self.network = network
@@ -120,6 +102,19 @@ class MacosInfraService:
def gateway_name(self) -> str:
return self._gateway_name
def orchestrator(self) -> MacosOrchestrator:
"""The control-plane service on the host-only control network. Cheap to
reconstruct — the launch flow reads its `url()` / `gateway_url()` /
`mint_gateway_token()` off it."""
return MacosOrchestrator(
self.orchestrator_image,
name=self._orchestrator_name,
port=self.port,
control_network=self.control_network,
repo_root=self._repo_root,
db_volume=self._db_volume,
)
def gateway(self) -> MacosGateway:
"""The data-plane gateway service, triple-homed on the egress + agent +
control networks. Cheap to reconstruct — the launch flow reads its
@@ -131,51 +126,16 @@ class MacosInfraService:
network=self.network,
egress_network=self.egress_network,
control_network=self.control_network,
repo_root=self._repo_root,
)
def _resolve_orchestrator_url(self) -> str:
"""The control-plane URL (orchestrator's control-network address), or ""
while it has no address."""
ip = container_mod.try_container_ipv4_on_network(
self._orchestrator_name, self.control_network)
return f"http://{ip}:{self.port}" if ip else ""
def _resolve_gateway_ip(self) -> str:
"""The gateway's agent-network address, or "" while it has none."""
return container_mod.try_container_ipv4_on_network(
self._gateway_name, self.network)
def is_healthy(
self, url: str, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
) -> bool:
if not url:
return False
try:
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _orchestrator_source_current(self, current_hash: str) -> bool:
"""True iff the running orchestrator was created from the current
bind-mounted control-plane source (it loads that code at startup and
won't reload it)."""
if not container_mod.container_is_running(self._orchestrator_name):
return False
env = container_mod.container_env(self._orchestrator_name)
if not env:
return True # can't compare → don't churn a working container
return env.get("BOT_BOTTLE_SOURCE_HASH") == current_hash
def ensure_built(self) -> None:
"""Ensure the gateway + orchestrator images exist. The control-plane
source is bind-mounted, so a code change takes effect without a rebuild;
the images still carry the package for their entrypoints."""
container_mod.build_image(
self.gateway_image, str(self._repo_root), dockerfile="Dockerfile.gateway")
container_mod.build_image(
self.orchestrator_image, str(self._repo_root),
dockerfile="Dockerfile.orchestrator")
def is_healthy(self) -> bool:
return self.orchestrator().is_healthy()
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -184,77 +144,26 @@ class MacosInfraService:
reach them. Idempotent per-host singleton — a healthy orchestrator on
current source is left untouched. Raises `OrchestratorStartError` on
control-plane startup timeout."""
self.ensure_built()
# The networks (host-only agent + NAT egress + host-only control) are a
# shared concern — create them before either plane comes up.
ensure_networks(self.network, self.egress_network, self.control_network)
current_hash = source_hash(self._repo_root)
url = self._resolve_orchestrator_url()
if not (self._orchestrator_source_current(current_hash)
and url and self.is_healthy(url)):
log.info("starting orchestrator container",
context={"name": self._orchestrator_name})
self._run_orchestrator_container(current_hash)
url = self._wait_healthy(startup_timeout)
orchestrator = self.orchestrator()
gateway = self.gateway()
orchestrator.ensure_built()
gateway.ensure_built()
orchestrator.ensure_running(startup_timeout=startup_timeout)
# (Re)ensure the gateway once the control plane it resolves against is
# healthy — it needs the orchestrator's control-network address. The
# orchestrator (which holds the signing key) mints the role-scoped
# `gateway` JWT and hands it to the gateway, which never sees the key
# (#469).
gateway_token = mint(ROLE_GATEWAY, host_orchestrator_token())
self.gateway().connect_to_orchestrator(url, gateway_token)
url = orchestrator.url()
gateway.connect_to_orchestrator(url, orchestrator.mint_gateway_token())
return InfraEndpoint(orchestrator_url=url, gateway_ip=self._resolve_gateway_ip())
def _run_orchestrator_container(self, current_hash: str) -> None:
container_mod.force_remove_container(self._orchestrator_name)
_signing_key = host_orchestrator_token()
argv = [
"container", "run", "--detach",
"--name", self._orchestrator_name,
"--label", "bot-bottle.backend=macos-container",
"--label", ORCHESTRATOR_LABEL,
# Control network only — agents are never on it (L3-isolated).
"--network", self.control_network,
"--dns", container_mod.dns_server(),
# Container-only DB volume: exactly one kernel writes bot-bottle.db.
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
# Live control-plane source (a code change takes effect on relaunch).
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER}",
# Detect a real control-plane code change and recreate.
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
# The signing key — held ONLY by the orchestrator (issue #469). Bare
# `--env NAME` keeps the value off argv / `container inspect`.
"--env", ORCHESTRATOR_TOKEN_ENV,
self.orchestrator_image,
# Dockerfile.orchestrator ENTRYPOINT is `-m bot_bottle.orchestrator`.
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
]
result = container_mod.run_container_argv(
argv, env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
if result.returncode != 0:
raise OrchestratorStartError(
f"orchestrator container failed to start: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _wait_healthy(self, startup_timeout: float) -> str:
deadline = time.monotonic() + startup_timeout
while True:
url = self._resolve_orchestrator_url()
if url and self.is_healthy(url):
log.info("orchestrator healthy", context={"url": url})
return url
if time.monotonic() >= deadline:
raise OrchestratorStartError(
f"orchestrator did not become healthy within "
f"{startup_timeout:g}s"
)
time.sleep(_HEALTH_POLL_SECONDS)
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS
interception — delegated to the gateway service (reads it out of the
@@ -267,14 +176,6 @@ class MacosInfraService:
container_mod.force_remove_container(self._orchestrator_name)
def probe_orchestrator_url(port: int = DEFAULT_PORT) -> str:
"""The running orchestrator's control-plane URL, or "" if it isn't up. Used
by host-side control-plane discovery; safe on any host (returns "" when the
container or the `container` CLI isn't present)."""
ip = container_mod.try_container_ipv4_on_network(ORCHESTRATOR_NAME, CONTROL_NETWORK)
return f"http://{ip}:{port}" if ip else ""
__all__ = [
"MacosInfraService",
"InfraEndpoint",
@@ -283,4 +184,5 @@ __all__ = [
"ORCHESTRATOR_NAME",
"INFRA_NAME",
"INFRA_DB_VOLUME",
"probe_orchestrator_url",
]
@@ -0,0 +1,205 @@
"""The macOS orchestrator (control plane) as an Apple container (PRD 0070).
`MacosOrchestrator` is the Apple-Container implementation of the backend-neutral
`Orchestrator` service. The lean control-plane container joins the host-only
control network only (agents are never on it), mounts a container-only DB volume
(exactly one kernel writes `bot-bottle.db`), and holds the signing key (#469).
The host CLI and the gateway both reach it at its control-network address —
Apple has no container DNS, so there's a single resolved URL, not docker's
loopback-vs-name split.
"""
from __future__ import annotations
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from ... import log
from ...paths import (
ORCHESTRATOR_TOKEN_ENV,
host_orchestrator_token,
)
from ...orchestrator.lifecycle import (
DEFAULT_HEALTH_TIMEOUT_SECONDS,
DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS,
Orchestrator,
OrchestratorStartError,
source_hash,
)
from . import util as container_mod
from .gateway import CONTROL_NETWORK
ORCHESTRATOR_IMAGE = os.environ.get(
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
ORCHESTRATOR_NAME = "bot-bottle-mac-orchestrator"
ORCHESTRATOR_LABEL = "bot-bottle-mac-orchestrator=1"
# Container-only volume holding bot-bottle.db, mounted ONLY into the
# orchestrator. One kernel writes it (never host-shared or cross-guest).
ORCHESTRATOR_DB_VOLUME = "bot-bottle-mac-db"
# BOT_BOTTLE_ROOT inside the orchestrator; host_db_path() resolves the DB to
# <root>/db/<filename>.
_DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
_SRC_IN_CONTAINER = "/bot-bottle-src"
_HEALTH_POLL_SECONDS = 0.25
_REPO_ROOT = Path(__file__).resolve().parents[3]
class MacosOrchestrator(Orchestrator):
"""The control plane as an Apple container on the host-only control network.
`ensure_built` builds `Dockerfile.orchestrator`; `ensure_running` starts it
and blocks until `/health` answers at its control-network address."""
def __init__(
self,
image_ref: str = ORCHESTRATOR_IMAGE,
*,
name: str = ORCHESTRATOR_NAME,
label: str = ORCHESTRATOR_LABEL,
port: int = DEFAULT_PORT,
control_network: str = CONTROL_NETWORK,
repo_root: Path = _REPO_ROOT,
db_volume: str = ORCHESTRATOR_DB_VOLUME,
) -> None:
self.image_ref = image_ref
self.name = name
self.label = label
self.port = port
self.control_network = control_network
self._repo_root = repo_root
self._db_volume = db_volume
def url(self) -> str:
"""The orchestrator's control-network address (host CLI + registration),
or "" while it has no address yet. Apple has no container DNS, so this is
also what the gateway resolves against (`gateway_url`)."""
ip = container_mod.try_container_ipv4_on_network(self.name, self.control_network)
return f"http://{ip}:{self.port}" if ip else ""
def gateway_url(self) -> str:
"""Same address the host uses — the gateway reaches the orchestrator by
control-network IP (Apple has no container DNS)."""
return self.url()
def is_healthy(self, *, timeout: float = DEFAULT_HEALTH_TIMEOUT_SECONDS) -> bool:
url = self.url()
if not url:
return False
try:
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def is_running(self) -> bool:
return container_mod.container_is_running(self.name)
def _source_current(self, current_hash: str) -> bool:
"""True iff the running orchestrator was created from the current
bind-mounted control-plane source (it loads that code at startup and
won't reload it)."""
if not self.is_running():
return False
env = container_mod.container_env(self.name)
if not env:
return True # can't compare → don't churn a working container
return env.get("BOT_BOTTLE_SOURCE_HASH") == current_hash
def ensure_built(self) -> None:
"""Build the control-plane image. The source is bind-mounted so a code
change takes effect without a rebuild; the image still carries the
package for its entrypoint."""
container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.orchestrator")
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> None:
"""Ensure the control-plane container is up on current source; block
until healthy. Idempotent — a healthy orchestrator on current source is
left untouched. Raises `OrchestratorStartError` on startup timeout.
The control network must already exist (the composer ensures it)."""
current_hash = source_hash(self._repo_root)
if self._source_current(current_hash) and self.is_healthy():
return
log.info("starting orchestrator container", context={"name": self.name})
self._run_container(current_hash)
self._wait_healthy(startup_timeout)
def _run_container(self, current_hash: str) -> None:
container_mod.force_remove_container(self.name)
_signing_key = host_orchestrator_token()
argv = [
"container", "run", "--detach",
"--name", self.name,
"--label", "bot-bottle.backend=macos-container",
"--label", self.label,
# Control network only — agents are never on it (L3-isolated).
"--network", self.control_network,
"--dns", container_mod.dns_server(),
# Container-only DB volume: exactly one kernel writes bot-bottle.db.
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
# Live control-plane source (a code change takes effect on relaunch).
"--mount",
container_mod.bind_mount_spec(
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER}",
# Detect a real control-plane code change and recreate.
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
# The signing key — held ONLY by the orchestrator (issue #469). Bare
# `--env NAME` keeps the value off argv / `container inspect`.
"--env", ORCHESTRATOR_TOKEN_ENV,
self.image_ref,
# Dockerfile.orchestrator ENTRYPOINT is `-m bot_bottle.orchestrator`.
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
]
result = container_mod.run_container_argv(
argv, env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
if result.returncode != 0:
raise OrchestratorStartError(
f"orchestrator container failed to start: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _wait_healthy(self, startup_timeout: float) -> None:
deadline = time.monotonic() + startup_timeout
while True:
if self.is_healthy():
log.info("orchestrator healthy", context={"url": self.url()})
return
if time.monotonic() >= deadline:
raise OrchestratorStartError(
f"orchestrator did not become healthy within "
f"{startup_timeout:g}s"
)
time.sleep(_HEALTH_POLL_SECONDS)
def stop(self) -> None:
"""Remove the control-plane container (idempotent). The DB volume
persists."""
container_mod.force_remove_container(self.name)
def probe_orchestrator_url(port: int = DEFAULT_PORT) -> str:
"""The running orchestrator's control-plane URL, or "" if it isn't up. Used
by host-side control-plane discovery; safe on any host (returns "" when the
container or the `container` CLI isn't present)."""
ip = container_mod.try_container_ipv4_on_network(ORCHESTRATOR_NAME, CONTROL_NETWORK)
return f"http://{ip}:{port}" if ip else ""
__all__ = [
"MacosOrchestrator",
"ORCHESTRATOR_NAME",
"ORCHESTRATOR_LABEL",
"ORCHESTRATOR_IMAGE",
"ORCHESTRATOR_DB_VOLUME",
"probe_orchestrator_url",
]
+48 -144
View File
@@ -1,153 +1,59 @@
"""Unit: macOS orchestrator + gateway containers (PRD 0070 plane split)."""
"""Unit: MacosInfraService composes the orchestrator + gateway services.
`MacosInfraService` no longer owns the container lifecycles — it composes the
`MacosOrchestrator` (control plane) and `MacosGateway` (data plane) services,
each tested in its own module. These tests isolate the *composition*: the
networks are ensured, both images built, the orchestrator comes up first, then
the gateway is connected to it with a freshly minted token."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
from bot_bottle.backend.macos_container.infra import (
INFRA_DB_VOLUME,
MacosInfraService,
OrchestratorStartError,
probe_orchestrator_url,
)
from bot_bottle.backend.macos_container.infra import MacosInfraService
_INFRA = "bot_bottle.backend.macos_container.infra"
def _ok(stdout: str = "") -> Mock:
return Mock(returncode=0, stdout=stdout, stderr="")
def _fail(stderr: str = "boom") -> Mock:
return Mock(returncode=1, stdout="", stderr=stderr)
def _spec(src: str, tgt: str, readonly: bool = False) -> str:
return f"type=bind,source={src},target={tgt}" + (",readonly" if readonly else "")
class TestOrchestratorRun(unittest.TestCase):
def _run(self, svc: MacosInfraService) -> list[str]:
run = Mock(return_value=_ok())
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.host_orchestrator_token", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
svc._run_orchestrator_container("h1")
return run.call_args.args[0]
def test_runs_the_orchestrator_on_the_control_network_only(self) -> None:
argv = self._run(MacosInfraService(repo_root=Path("/r")))
nets = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]
self.assertEqual(["bot-bottle-mac-control"], nets)
# Image ENTRYPOINT is `-m bot_bottle.orchestrator`; these are its args.
self.assertIn("--broker", argv)
self.assertIn("stub", argv)
self.assertIn("bot-bottle-orchestrator:latest", argv)
def test_db_is_a_container_only_volume(self) -> None:
argv = self._run(MacosInfraService(repo_root=Path("/r")))
vols = [argv[i + 1] for i, a in enumerate(argv) if a == "--volume"]
self.assertTrue(any(v.startswith(f"{INFRA_DB_VOLUME}:") for v in vols))
def test_orchestrator_has_no_ca_mount(self) -> None:
# The CA lives with the gateway, not the control plane.
argv = self._run(MacosInfraService(repo_root=Path("/r")))
mounts = [argv[i + 1] for i, a in enumerate(argv) if a == "--mount"]
self.assertFalse([m for m in mounts if "/home/mitmproxy" in m])
def test_source_hash_is_labelled_for_recreate(self) -> None:
argv = self._run(MacosInfraService(repo_root=Path("/r")))
self.assertIn("BOT_BOTTLE_SOURCE_HASH=h1", argv)
def test_start_failure_raises(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.host_orchestrator_token", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.run_container_argv = Mock(return_value=_fail())
with self.assertRaises(OrchestratorStartError):
svc._run_orchestrator_container("h1")
class TestInfraEnsureRunning(unittest.TestCase):
"""Isolate the orchestrator-container logic; the gateway is brought up by
`MacosGateway` (its own module, tested separately), so mock `svc.gateway`.
`ensure_running` mints the `gateway` token, so `mint` /
`host_orchestrator_token` are stubbed."""
def setUp(self) -> None:
self.svc = MacosInfraService(repo_root=Path("/r"))
self.orch = MagicMock()
self.orch.url.return_value = "http://192.168.128.2:8099"
self.orch.mint_gateway_token.return_value = "gw.jwt"
self.gw = MagicMock()
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
p = patch.object(self.svc, name, return_value=mock)
p.start()
self.addCleanup(p.stop)
def _patches(self, svc: MacosInfraService):
gw = MagicMock()
return gw, (
patch.object(svc, "ensure_built"),
patch.object(svc, "gateway", return_value=gw),
patch(f"{_INFRA}.host_orchestrator_token", return_value="k"),
patch(f"{_INFRA}.mint", return_value="jwt"),
)
def test_current_healthy_orchestrator_left_alone(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
run = Mock()
gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h1"), \
patch.object(svc, "_run_orchestrator_container", run), \
patch.object(svc, "is_healthy", return_value=True), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = True
mod.container_env.return_value = {"BOT_BOTTLE_SOURCE_HASH": "h1"}
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
endpoint = svc.ensure_running()
run.assert_not_called()
gw.connect_to_orchestrator.assert_called_once()
def test_composes_networks_builds_and_brings_up_orchestrator_first(self) -> None:
with patch(f"{_INFRA}.ensure_networks") as nets, \
patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.3"
endpoint = self.svc.ensure_running()
nets.assert_called_once()
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_running.assert_called_once()
self.assertEqual("http://192.168.128.2:8099", endpoint.orchestrator_url)
self.assertEqual("192.168.128.2", endpoint.gateway_ip)
self.assertEqual("192.168.128.3", endpoint.gateway_ip)
def test_changed_source_recreates_orchestrator(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
run = Mock()
_gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h2"), \
patch.object(svc, "_run_orchestrator_container", run), \
patch.object(svc, "is_healthy", return_value=True), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = True
mod.container_env.return_value = {"BOT_BOTTLE_SOURCE_HASH": "h1"}
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
svc.ensure_running()
run.assert_called_once()
def test_connects_gateway_with_orchestrator_url_and_minted_token(self) -> None:
with patch(f"{_INFRA}.ensure_networks"), \
patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.3"
self.svc.ensure_running()
self.gw.connect_to_orchestrator.assert_called_once_with(
"http://192.168.128.2:8099", "gw.jwt")
self.orch.mint_gateway_token.assert_called_once()
def test_wedged_but_current_orchestrator_recreated(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
run = Mock()
_gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h1"), \
patch.object(svc, "_run_orchestrator_container", run), \
patch.object(svc, "is_healthy", Mock(side_effect=[False, True])), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = True
mod.container_env.return_value = {"BOT_BOTTLE_SOURCE_HASH": "h1"}
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
svc.ensure_running()
run.assert_called_once()
def test_never_healthy_raises(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
_gw, extra = self._patches(svc)
with patch(f"{_INFRA}.container_mod") as mod, \
patch(f"{_INFRA}.source_hash", return_value="h1"), \
patch.object(svc, "_run_orchestrator_container"), \
patch.object(svc, "is_healthy", return_value=False), \
extra[0], extra[1], extra[2], extra[3]:
mod.container_is_running.return_value = False
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
with self.assertRaises(OrchestratorStartError):
svc.ensure_running(startup_timeout=0.01)
def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
self.orch.is_healthy.return_value = True
self.assertTrue(self.svc.is_healthy())
self.orch.is_healthy.assert_called_once()
class TestCaCertPem(unittest.TestCase):
@@ -161,16 +67,14 @@ class TestCaCertPem(unittest.TestCase):
gw.ca_cert_pem.assert_called_once_with(timeout=5)
class TestProbeOrchestrator(unittest.TestCase):
def test_returns_url_when_running(self) -> None:
class TestStop(unittest.TestCase):
def test_removes_both_containers(self) -> None:
svc = MacosInfraService(repo_root=Path("/r"))
with patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
self.assertEqual("http://192.168.128.2:8099", probe_orchestrator_url())
def test_empty_when_absent(self) -> None:
with patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = ""
self.assertEqual("", probe_orchestrator_url())
svc.stop()
removed = {c.args[0] for c in mod.force_remove_container.call_args_list}
self.assertIn("bot-bottle-mac-infra", removed) # gateway
self.assertIn("bot-bottle-mac-orchestrator", removed) # orchestrator
if __name__ == "__main__":
+168
View File
@@ -0,0 +1,168 @@
"""Unit: the macOS orchestrator (control plane) container lifecycle (PRD 0070)."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from bot_bottle.backend.macos_container.orchestrator import (
ORCHESTRATOR_DB_VOLUME,
ORCHESTRATOR_NAME,
MacosOrchestrator,
probe_orchestrator_url,
)
from bot_bottle.orchestrator.lifecycle import OrchestratorStartError
_ORCH = "bot_bottle.backend.macos_container.orchestrator"
def _ok(stdout: str = "") -> Mock:
return Mock(returncode=0, stdout=stdout, stderr="")
def _fail(stderr: str = "boom") -> Mock:
return Mock(returncode=1, stdout="", stderr=stderr)
def _spec(src: str, tgt: str, readonly: bool = False) -> str:
return f"type=bind,source={src},target={tgt}" + (",readonly" if readonly else "")
class TestMacosOrchestratorRun(unittest.TestCase):
def _run(self) -> list[str]:
run = Mock(return_value=_ok())
with patch(f"{_ORCH}.container_mod") as mod, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
MacosOrchestrator(repo_root=Path("/r"))._run_container("h1")
return run.call_args.args[0]
def test_runs_on_the_control_network_only(self) -> None:
argv = self._run()
nets = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]
self.assertEqual(["bot-bottle-mac-control"], nets)
# Image ENTRYPOINT is `-m bot_bottle.orchestrator`; these are its args.
self.assertIn("--broker", argv)
self.assertIn("stub", argv)
self.assertIn("bot-bottle-orchestrator:latest", argv)
def test_db_is_a_container_only_volume(self) -> None:
argv = self._run()
vols = [argv[i + 1] for i, a in enumerate(argv) if a == "--volume"]
self.assertTrue(any(v.startswith(f"{ORCHESTRATOR_DB_VOLUME}:") for v in vols))
def test_no_ca_mount(self) -> None:
# The CA lives with the gateway, not the control plane.
argv = self._run()
mounts = [argv[i + 1] for i, a in enumerate(argv) if a == "--mount"]
self.assertFalse([m for m in mounts if "/home/mitmproxy" in m])
def test_source_hash_is_labelled_for_recreate(self) -> None:
self.assertIn("BOT_BOTTLE_SOURCE_HASH=h1", self._run())
def test_start_failure_raises(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.run_container_argv = Mock(return_value=_fail())
with self.assertRaises(OrchestratorStartError):
MacosOrchestrator(repo_root=Path("/r"))._run_container("h1")
class TestMacosOrchestratorUrls(unittest.TestCase):
def test_url_and_gateway_url_are_the_control_net_address(self) -> None:
orch = MacosOrchestrator(port=8099)
with patch(f"{_ORCH}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
self.assertEqual("http://192.168.128.2:8099", orch.url())
self.assertEqual("http://192.168.128.2:8099", orch.gateway_url())
def test_url_empty_when_no_address(self) -> None:
orch = MacosOrchestrator()
with patch(f"{_ORCH}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = ""
self.assertEqual("", orch.url())
def test_is_healthy_false_when_no_address(self) -> None:
orch = MacosOrchestrator()
with patch(f"{_ORCH}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = ""
self.assertFalse(orch.is_healthy())
class TestMacosOrchestratorEnsureRunning(unittest.TestCase):
def _orch(self) -> MacosOrchestrator:
return MacosOrchestrator(repo_root=Path("/r"))
def test_noop_when_current_and_healthy(self) -> None:
orch = self._orch()
with patch(f"{_ORCH}.source_hash", return_value="h1"), \
patch.object(orch, "_source_current", return_value=True), \
patch.object(orch, "is_healthy", return_value=True), \
patch.object(orch, "_run_container") as run:
orch.ensure_running()
run.assert_not_called()
def test_recreates_when_source_changed(self) -> None:
orch = self._orch()
with patch(f"{_ORCH}.source_hash", return_value="h2"), \
patch.object(orch, "_source_current", return_value=False), \
patch.object(orch, "_run_container") as run, \
patch.object(orch, "_wait_healthy"):
orch.ensure_running()
run.assert_called_once()
def test_recreates_when_current_but_wedged(self) -> None:
orch = self._orch()
with patch(f"{_ORCH}.source_hash", return_value="h1"), \
patch.object(orch, "_source_current", return_value=True), \
patch.object(orch, "is_healthy", return_value=False), \
patch.object(orch, "_run_container") as run, \
patch.object(orch, "_wait_healthy"):
orch.ensure_running()
run.assert_called_once()
def test_never_healthy_raises(self) -> None:
orch = self._orch()
with patch(f"{_ORCH}.source_hash", return_value="h1"), \
patch.object(orch, "_source_current", return_value=False), \
patch.object(orch, "_run_container"), \
patch.object(orch, "is_healthy", return_value=False), \
patch(f"{_ORCH}.time.sleep"), \
patch(f"{_ORCH}.time.monotonic", side_effect=[0.0, 0.5, 2.0]):
with self.assertRaises(OrchestratorStartError):
orch.ensure_running(startup_timeout=1.0)
class TestMacosOrchestratorBuildStop(unittest.TestCase):
def test_ensure_built_builds_the_orchestrator_image(self) -> None:
orch = MacosOrchestrator(repo_root=Path("/r"))
with patch(f"{_ORCH}.container_mod") as mod:
orch.ensure_built()
_args, kwargs = mod.build_image.call_args
self.assertEqual("Dockerfile.orchestrator", kwargs["dockerfile"])
def test_stop_removes_the_container(self) -> None:
orch = MacosOrchestrator()
with patch(f"{_ORCH}.container_mod") as mod:
orch.stop()
mod.force_remove_container.assert_called_once_with(ORCHESTRATOR_NAME)
class TestProbeOrchestrator(unittest.TestCase):
def test_returns_url_when_running(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
self.assertEqual("http://192.168.128.2:8099", probe_orchestrator_url())
def test_empty_when_absent(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = ""
self.assertEqual("", probe_orchestrator_url())
if __name__ == "__main__":
unittest.main()