From 8e43c26ab4822e6b742abaa7cc17fddddf618635 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 20 Jul 2026 23:26:01 +0000 Subject: [PATCH] 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. --- bot_bottle/backend/firecracker/infra_vm.py | 16 +++++++-------- bot_bottle/backend/macos_container/infra.py | 19 +++++++++--------- bot_bottle/backend/util.py | 20 +++++++++++++++++++ ...rd-new-consolidate-docker-infra-backend.md | 5 +++-- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index ba3bdd4..8ccf6e9 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -33,6 +33,7 @@ from pathlib import Path from typing import Generator from ...log import die, info +from .. import util as backend_util from ..docker import util as docker_mod from ..docker.gateway_provision import GatewayProvisionError 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 TLS interception. Generated a moment after boot, so this polls over SSH until it appears (mirrors DockerGateway.ca_cert_pem).""" - deadline = time.monotonic() + timeout - while True: + def _fetch() -> str | None: proc = subprocess.run( util.ssh_base_argv(self.private_key, self.guest_ip) + [f"cat {_GATEWAY_CA_PATH}"], capture_output=True, text=True, timeout=15, check=False, ) - if proc.returncode == 0 and "BEGIN CERTIFICATE" in proc.stdout: - return proc.stdout - if time.monotonic() >= deadline: - die(f"gateway CA not available after {timeout:g}s: " - f"{proc.stderr.strip() or 'empty'}") - time.sleep(_HEALTH_POLL_SECONDS) + ok = proc.returncode == 0 and "BEGIN CERTIFICATE" in proc.stdout + return proc.stdout if ok else None + try: + return backend_util.poll_ca_cert(_fetch, timeout=timeout) + except TimeoutError as exc: + die(str(exc)) def ensure_built() -> None: diff --git a/bot_bottle/backend/macos_container/infra.py b/bot_bottle/backend/macos_container/infra.py index 6bca5c9..c6fcf62 100644 --- a/bot_bottle/backend/macos_container/infra.py +++ b/bot_bottle/backend/macos_container/infra.py @@ -53,6 +53,7 @@ from ...paths import ( HOST_DB_FILENAME, host_control_plane_token, ) +from .. import util as backend_util from . import util as container_mod from .gateway import ( DEFAULT_CA_TIMEOUT_SECONDS, @@ -263,18 +264,16 @@ class MacosInfraService: interception. Read out of the container (the CA lives on a container-internal path, not a host mount); polls because mitmproxy writes it a beat after start.""" - deadline = time.monotonic() + timeout - while True: + def _fetch() -> str | None: result = container_mod.run_container_argv( ["container", "exec", self._name, "cat", GATEWAY_CA_CERT]) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout - if time.monotonic() >= deadline: - raise GatewayError( - f"gateway CA not available in {self._name} after {timeout:g}s: " - f"{(result.stderr or '').strip() or 'empty'}" - ) - time.sleep(_CA_POLL_SECONDS) + return result.stdout if result.returncode == 0 and result.stdout.strip() else None + try: + return backend_util.poll_ca_cert(_fetch, timeout=timeout) + except TimeoutError as exc: + raise GatewayError( + f"gateway CA not available in {self._name} after {timeout:g}s" + ) from exc def stop(self) -> None: """Remove the infra container (idempotent). The DB volume persists.""" diff --git a/bot_bottle/backend/util.py b/bot_bottle/backend/util.py index f5ea929..3d89622 100644 --- a/bot_bottle/backend/util.py +++ b/bot_bottle/backend/util.py @@ -7,6 +7,8 @@ from __future__ import annotations import hashlib import os import ssl +import time +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING @@ -15,6 +17,24 @@ from ..log import die, info if TYPE_CHECKING: 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 # are Debian-family). AGENT_CA_PATH is the source path that diff --git a/docs/prds/prd-new-consolidate-docker-infra-backend.md b/docs/prds/prd-new-consolidate-docker-infra-backend.md index b7e0976..957739f 100644 --- a/docs/prds/prd-new-consolidate-docker-infra-backend.md +++ b/docs/prds/prd-new-consolidate-docker-infra-backend.md @@ -90,8 +90,9 @@ first (`DockerGateway.ensure_running`), then orchestrator. After this PRD: - Single `docker run` of `bot-bottle-infra:latest` - Container name: `bot-bottle-infra` (replaces `bot-bottle-orch-gateway` + `bot-bottle-orchestrator`) -- Published ports: `127.0.0.1:{port}:{port}` for the control plane (same as - today) +- Published ports: `127.0.0.1:{host_port}:8099` for the control plane + (`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) - `DockerGateway` becomes an implementation detail of `OrchestratorService` rather than a separately started container; the gateway image name