fix(backend): extract poll_ca_cert helper and fix PRD port docs

Extract the shared CA cert polling loop into `backend/util.poll_ca_cert`
(firecracker and macos backends were duplicating deadline/sleep/raise logic).
Each caller now wraps a fetch lambda and converts TimeoutError to its own
error type. Also corrects the PRD port publication line from {port}:{port}
to {host_port}:8099.
This commit is contained in:
2026-07-20 23:26:01 +00:00
parent 14ff4fe186
commit 8e43c26ab4
4 changed files with 40 additions and 20 deletions
+8 -8
View File
@@ -33,6 +33,7 @@ from pathlib import Path
from typing import Generator from typing import Generator
from ...log import die, info from ...log import die, info
from .. import util as backend_util
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError from ..docker.gateway_provision import GatewayProvisionError
from . import firecracker_vm, infra_artifact, netpool, util from . import firecracker_vm, infra_artifact, netpool, util
@@ -93,19 +94,18 @@ class InfraVm:
"""The gateway's mitmproxy CA (PEM) that agents install to trust its """The gateway's mitmproxy CA (PEM) that agents install to trust its
TLS interception. Generated a moment after boot, so this polls over TLS interception. Generated a moment after boot, so this polls over
SSH until it appears (mirrors DockerGateway.ca_cert_pem).""" SSH until it appears (mirrors DockerGateway.ca_cert_pem)."""
deadline = time.monotonic() + timeout def _fetch() -> str | None:
while True:
proc = subprocess.run( proc = subprocess.run(
util.ssh_base_argv(self.private_key, self.guest_ip) util.ssh_base_argv(self.private_key, self.guest_ip)
+ [f"cat {_GATEWAY_CA_PATH}"], + [f"cat {_GATEWAY_CA_PATH}"],
capture_output=True, text=True, timeout=15, check=False, capture_output=True, text=True, timeout=15, check=False,
) )
if proc.returncode == 0 and "BEGIN CERTIFICATE" in proc.stdout: ok = proc.returncode == 0 and "BEGIN CERTIFICATE" in proc.stdout
return proc.stdout return proc.stdout if ok else None
if time.monotonic() >= deadline: try:
die(f"gateway CA not available after {timeout:g}s: " return backend_util.poll_ca_cert(_fetch, timeout=timeout)
f"{proc.stderr.strip() or 'empty'}") except TimeoutError as exc:
time.sleep(_HEALTH_POLL_SECONDS) die(str(exc))
def ensure_built() -> None: def ensure_built() -> None:
+9 -10
View File
@@ -53,6 +53,7 @@ from ...paths import (
HOST_DB_FILENAME, HOST_DB_FILENAME,
host_control_plane_token, host_control_plane_token,
) )
from .. import util as backend_util
from . import util as container_mod from . import util as container_mod
from .gateway import ( from .gateway import (
DEFAULT_CA_TIMEOUT_SECONDS, DEFAULT_CA_TIMEOUT_SECONDS,
@@ -263,18 +264,16 @@ class MacosInfraService:
interception. Read out of the container (the CA lives on a interception. Read out of the container (the CA lives on a
container-internal path, not a host mount); polls because mitmproxy container-internal path, not a host mount); polls because mitmproxy
writes it a beat after start.""" writes it a beat after start."""
deadline = time.monotonic() + timeout def _fetch() -> str | None:
while True:
result = container_mod.run_container_argv( result = container_mod.run_container_argv(
["container", "exec", self._name, "cat", GATEWAY_CA_CERT]) ["container", "exec", self._name, "cat", GATEWAY_CA_CERT])
if result.returncode == 0 and result.stdout.strip(): return result.stdout if result.returncode == 0 and result.stdout.strip() else None
return result.stdout try:
if time.monotonic() >= deadline: return backend_util.poll_ca_cert(_fetch, timeout=timeout)
raise GatewayError( except TimeoutError as exc:
f"gateway CA not available in {self._name} after {timeout:g}s: " raise GatewayError(
f"{(result.stderr or '').strip() or 'empty'}" f"gateway CA not available in {self._name} after {timeout:g}s"
) ) from exc
time.sleep(_CA_POLL_SECONDS)
def stop(self) -> None: def stop(self) -> None:
"""Remove the infra container (idempotent). The DB volume persists.""" """Remove the infra container (idempotent). The DB volume persists."""
+20
View File
@@ -7,6 +7,8 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import ssl import ssl
import time
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -15,6 +17,24 @@ from ..log import die, info
if TYPE_CHECKING: if TYPE_CHECKING:
from ..egress import EgressPlan from ..egress import EgressPlan
_CA_POLL_INTERVAL = 0.5
def poll_ca_cert(fetch: Callable[[], str | None], *, timeout: float) -> str:
"""Poll `fetch` until it returns a non-empty PEM string or `timeout` expires.
`fetch` should return the PEM on success and `None` (or empty string) when
the cert is not yet available. Raises `TimeoutError` if the cert never
appears within `timeout` seconds."""
deadline = time.monotonic() + timeout
while True:
result = fetch()
if result:
return result
if time.monotonic() >= deadline:
raise TimeoutError(f"CA cert not available after {timeout:g}s")
time.sleep(_CA_POLL_INTERVAL)
# Debian-family CA layout, shared by every backend (all guest images # Debian-family CA layout, shared by every backend (all guest images
# are Debian-family). AGENT_CA_PATH is the source path that # are Debian-family). AGENT_CA_PATH is the source path that
@@ -90,8 +90,9 @@ first (`DockerGateway.ensure_running`), then orchestrator. After this PRD:
- Single `docker run` of `bot-bottle-infra:latest` - Single `docker run` of `bot-bottle-infra:latest`
- Container name: `bot-bottle-infra` (replaces `bot-bottle-orch-gateway` + - Container name: `bot-bottle-infra` (replaces `bot-bottle-orch-gateway` +
`bot-bottle-orchestrator`) `bot-bottle-orchestrator`)
- Published ports: `127.0.0.1:{port}:{port}` for the control plane (same as - Published ports: `127.0.0.1:{host_port}:8099` for the control plane
today) (`gateway_init` listens on a fixed internal port 8099; the caller-chosen
host port maps to it)
- Bind mounts: repo root + host root (same as today) - Bind mounts: repo root + host root (same as today)
- `DockerGateway` becomes an implementation detail of `OrchestratorService` - `DockerGateway` becomes an implementation detail of `OrchestratorService`
rather than a separately started container; the gateway image name rather than a separately started container; the gateway image name