refactor(backend): base owns the gateway-attach flow, fail hard (0081 review)
lint / lint (push) Successful in 1m1s
test / unit (pull_request) Successful in 1m5s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
test / integration-docker (pull_request) Failing after 1m30s
test / coverage (pull_request) Has been skipped

Addresses review on #519 (@didericis 6143 + the fail-hard direction over the
codex skip).

Base owns the flow (6143 / ADR 0006). `attach_bottled_agents_to_gateway()` is
now a concrete method on `BottleBackend` that delegates to
`gateway_attach.reconcile_running_bottles`; backends override only three
primitives — `_gateway_attach_resources()`, `_running_bottles()`,
`_attach_bottle_to_gateway()`. The shared control flow + error policy live in
one place so a backend can't drift onto a bespoke loop or a silent skip. Keeps
the reconcile flow + `GatewayAttachResources` out of `backend/base.py` (the
size guardrail) in a dedicated `backend/gateway_attach.py`. New ADR 0006 records
the "shared behaviour in the base backend, subclasses override primitives"
theme.

Fail hard, never skip (the maintainer's direction over the codex review's
skip). Any attach failure now aborts bring-up instead of being logged and
skipped: a bottle that silently can't reach the fresh gateway (its egress just
starts failing TLS) is worse than a loud failure. Every bottle is attempted and
the failures are raised together (aggregate `InfraLaunchError`) so one bring-up
surfaces the full blast radius. Resource gathering (the CA fetch) also fails
hard. PRD 0081 goal + design updated to match (reversed from the earlier
"tolerate per-bottle failures" draft).

Bumps the base.py guardrail cap 580->600 for the new contract surface (the
delegator + 3 abstract primitives); the flow itself lives in gateway_attach.py.

Refs #516, #519.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 01:50:06 +00:00
parent 4f2ccaed29
commit 3c0d2fb66f
13 changed files with 420 additions and 251 deletions
+31 -19
View File
@@ -21,7 +21,7 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager, contextmanager from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Generator, Generic, Sequence, TypeVar from typing import TYPE_CHECKING, Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider from ..agent_provider import AgentProvisionPlan, get_provider
from ..egress import EgressPlan from ..egress import EgressPlan
@@ -35,6 +35,9 @@ from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
if TYPE_CHECKING:
from .gateway_attach import GatewayAttachResources
class BackendStatus(enum.IntEnum): class BackendStatus(enum.IntEnum):
"""Return codes for BottleBackend.status(). READY == 0 so callsites """Return codes for BottleBackend.status(). READY == 0 so callsites
@@ -262,6 +265,11 @@ class Bottle(ABC):
PlanT = TypeVar("PlanT", bound=BottlePlan) PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan) CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
# The backend-specific handle for one running bottle the reconcile attaches to
# the gateway (a run dir for firecracker, a container name for docker/macOS).
# Registry-style callers that don't care about the handle bind it to `Any`
# (`BottleBackend[Any, Any, Any]`).
AttachTargetT = TypeVar("AttachTargetT")
class InfraLaunchError(RuntimeError): class InfraLaunchError(RuntimeError):
@@ -282,7 +290,7 @@ class BottleImages:
sidecar: str | Path = "" sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT]): class BottleBackend(ABC, Generic[PlanT, CleanupT, AttachTargetT]):
"""Abstract base for selectable bottle backends. Concrete subclasses """Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls. (e.g. DockerBottleBackend) own their own prepare/launch impls.
Parameterized over the backend's concrete plan + cleanup-plan types Parameterized over the backend's concrete plan + cleanup-plan types
@@ -510,26 +518,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
compose ls`; firecracker cross-references its running gateway compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata.""" containers against per-bottle metadata."""
@abstractmethod
def attach_bottled_agents_to_gateway(self) -> None: def attach_bottled_agents_to_gateway(self) -> None:
"""Reconcile every already-running bottle against the freshly-(re)booted """Reconcile every running bottle against the freshly-(re)booted gateway
gateway (PRD 0081). The host calls this on the gateway bring-up path — on the cold-boot bring-up path (PRD 0081). The shared flow + fail-hard
the cold-boot branch only, never on an adopt of a healthy current policy live in `gateway_attach`; backends override only the three
gateway — so a gateway rebuild/restart doesn't silently break running primitives below (ADR 0006)."""
bottles' gateway-dependent state. from .gateway_attach import reconcile_running_bottles
reconcile_running_bottles(self)
Reconciling running bottles is a backend responsibility: only the @abstractmethod
backend can enumerate its agents and reach them (firecracker over SSH, def _gateway_attach_resources(self) -> "GatewayAttachResources":
docker/macOS over `exec`/`cp`), so every backend must implement it — """Gather what every bottle needs to (re)attach to the current gateway
cross-backend by construction. Each per-bottle step is best-effort: one (the CA now). Raises if the gateway isn't reachable (aborts reconcile)."""
unreachable or malformed bottle is logged and skipped so it never blocks
the others or the gateway coming up.
Currently reconciles the **CA**: it replaces each agent's trusted CA @abstractmethod
with the current gateway CA (unconditional — there is one gateway, so no def _running_bottles(self) -> Sequence[AttachTargetT]:
fingerprint match is needed), which rotates the CA for free on every """The live bottles as backend handles for `_attach_bottle_to_gateway`.
bring-up. Git-gate re-provision and egress-token restore fold into this Raises if the live set can't be determined (fail hard, no partial set)."""
same reconcile on the same trigger (see #516)."""
@abstractmethod
def _attach_bottle_to_gateway(
self, bottle: AttachTargetT, resources: "GatewayAttachResources",
) -> None:
"""(Re)attach one bottle to the current gateway (SSH / `exec`+`cp`).
Raises `InfraLaunchError` naming the bottle on failure."""
@classmethod @classmethod
@abstractmethod @abstractmethod
+16 -4
View File
@@ -33,6 +33,7 @@ from ...git_gate import GitGatePlan
from ...supervisor.plan import SupervisePlan from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -40,7 +41,7 @@ from . import resolve_plan as _resolve_plan
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan", str]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
when set to `docker`; retained as a legacy/example backend.""" when set to `docker`; retained as a legacy/example backend."""
@@ -141,6 +142,17 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
def attach_bottled_agents_to_gateway(self) -> None: def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra_launch import attach_bottled_agents_to_gateway from .infra import DockerInfraService
attach_bottled_agents_to_gateway() return GatewayAttachResources(
ca_pem=DockerInfraService().gateway().ca_cert_pem())
def _running_bottles(self) -> Sequence[str]:
from .infra_launch import running_agent_containers
return running_agent_containers()
def _attach_bottle_to_gateway(
self, bottle: str, resources: GatewayAttachResources,
) -> None:
from .infra_launch import push_ca_to_container
push_ca_to_container(bottle, resources.ca_pem)
+28 -45
View File
@@ -73,11 +73,32 @@ def _network_container_ips(network: str) -> list[str]:
return ips return ips
def _push_ca_to_container(container: str, ca_pem: str) -> None: def running_agent_containers(
"""Replace one running agent container's trusted gateway CA with `ca_pem` network: str = GATEWAY_NETWORK, gateway_name: str = INFRA_NAME,
and rebuild its trust store via `docker cp` + `docker exec`. Unconditional ) -> list[str]:
install — there is one gateway, so no fingerprint match is needed. Raises """Every running agent container on the gateway `network` (the gateway
`InfraLaunchError` on failure.""" itself excluded) — the bottles the bring-up reconcile attaches to the fresh
gateway (PRD 0081). Raises `OSError` if `docker` can't be run (fail hard: a
reconcile that can't list its bottles must not look like "no bottles")."""
proc = run_docker([
"docker", "network", "inspect", "--format",
"{{range .Containers}}{{.Name}}\n{{end}}", network,
])
return [
name for name in (line.strip() for line in proc.stdout.splitlines())
if name and name != gateway_name
]
def push_ca_to_container(container: str, ca_pem: str) -> None:
"""(Re)attach one running agent container to the current gateway: replace
its trusted gateway CA with `ca_pem` and rebuild its trust store via
`docker cp` + `docker exec` (PRD 0081). Unconditional install — there is one
gateway, so no fingerprint match is needed.
Fail hard: raises `InfraLaunchError` (naming the container) on any failure —
the base reconcile aggregates and surfaces it rather than leaving the agent
silently unable to reach the gateway."""
mkdir = run_docker( mkdir = run_docker(
["docker", "exec", container, "mkdir", "-p", ["docker", "exec", container, "mkdir", "-p",
"/usr/local/share/ca-certificates"] "/usr/local/share/ca-certificates"]
@@ -105,45 +126,6 @@ def _push_ca_to_container(container: str, ca_pem: str) -> None:
) )
def attach_bottled_agents_to_gateway(
ca_pem: str | None = None,
*,
network: str = GATEWAY_NETWORK,
gateway_name: str = INFRA_NAME,
) -> None:
"""Reconcile every running agent container against the freshly-(re)created
gateway (PRD 0081): replace each agent's trusted CA with the current gateway
CA, so a gateway rebuild doesn't silently break their TLS egress (#510).
Per-container steps are best-effort: one failure is logged and skipped so it
never blocks the others or the gateway coming up."""
if ca_pem is None:
ca_pem = DockerInfraService().gateway().ca_cert_pem()
try:
proc = run_docker([
"docker", "network", "inspect", "--format",
"{{range .Containers}}{{.Name}}\n{{end}}", network,
])
except OSError as exc:
log.info(f"CA reconcile skipped: {exc}")
return
reconciled = 0
for line in proc.stdout.splitlines():
name = line.strip()
if not name or name == gateway_name:
continue
try:
_push_ca_to_container(name, ca_pem)
reconciled += 1
except (OSError, InfraLaunchError) as exc:
log.info(f"CA reconcile skipped for {name}: {exc}")
if reconciled:
log.info(
"reconciled gateway CA into running docker bottle(s)",
context={"count": reconciled},
)
def _reprovision_running_bottles( def _reprovision_running_bottles(
orchestrator_url: str, orchestrator_url: str,
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
@@ -250,7 +232,8 @@ def deprovision_consolidated(
__all__ = [ __all__ = [
"LaunchContext", "LaunchContext",
"launch_consolidated", "launch_consolidated",
"attach_bottled_agents_to_gateway", "running_agent_containers",
"push_ca_to_container",
"deprovision_consolidated", "deprovision_consolidated",
"InfraLaunchError", "InfraLaunchError",
] ]
+14 -4
View File
@@ -20,6 +20,7 @@ from ...git_gate import GitGatePlan
from ...manifest import Manifest from ...manifest import Manifest
from ...supervisor.plan import SupervisePlan from ...supervisor.plan import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -31,7 +32,7 @@ from .bottle_plan import FirecrackerBottlePlan
class FirecrackerBottleBackend( class FirecrackerBottleBackend(
BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan"] BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan", "Path"]
): ):
name = "firecracker" name = "firecracker"
@@ -119,9 +120,18 @@ class FirecrackerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
def attach_bottled_agents_to_gateway(self) -> None: def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra_launch import attach_bottled_agents_to_gateway from .gateway import FirecrackerGateway
attach_bottled_agents_to_gateway() return GatewayAttachResources(ca_pem=FirecrackerGateway().ca_cert_pem())
def _running_bottles(self) -> Sequence[Path]:
return _cleanup.live_run_dirs()
def _attach_bottle_to_gateway(
self, bottle: Path, resources: GatewayAttachResources,
) -> None:
from .infra_launch import attach_ca_to_agent_vm
attach_ca_to_agent_vm(bottle, resources.ca_pem)
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str: def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
return plan.agent_supervise_url return plan.agent_supervise_url
+25 -40
View File
@@ -32,7 +32,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...egress import EgressPlan from ...egress import EgressPlan
from ...gateway import GatewayError
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
@@ -88,56 +87,42 @@ def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> Non
) )
def _push_gateway_ca_to_agent(private_key: Path, guest_ip: str, ca_pem: str) -> None: def attach_ca_to_agent_vm(run_dir: Path, ca_pem: str) -> None:
"""Replace one running agent VM's trusted gateway CA with `ca_pem` and """(Re)attach one running agent VM (identified by its `run_dir`) to the
rebuild its trust store over SSH. Unconditional install — there is one current gateway: replace its trusted gateway CA with `ca_pem` and rebuild
its trust store over SSH (PRD 0081). Unconditional install — there is one
gateway, so no fingerprint match is needed. Bare-pipe input keeps the cert gateway, so no fingerprint match is needed. Bare-pipe input keeps the cert
off argv. Raises `InfraLaunchError` on failure.""" off argv.
Fail hard: raises `InfraLaunchError` (naming the bottle) if the run dir is
malformed or the push fails — the base reconcile aggregates and surfaces it
rather than leaving the agent silently unable to reach the gateway."""
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
raise InfraLaunchError(
f"{run_dir.name}: cannot resolve guest IP / SSH key to attach it "
f"to the gateway"
)
install = ( install = (
"umask 022; mkdir -p /usr/local/share/ca-certificates && " "umask 022; mkdir -p /usr/local/share/ca-certificates && "
f"cat > {AGENT_CA_PATH} && chmod 644 {AGENT_CA_PATH} && " f"cat > {AGENT_CA_PATH} && chmod 644 {AGENT_CA_PATH} && "
"update-ca-certificates" "update-ca-certificates"
) )
proc = subprocess.run( try:
util.ssh_base_argv(private_key, guest_ip) + [install], proc = subprocess.run(
input=ca_pem, capture_output=True, text=True, check=False, util.ssh_base_argv(private_key, guest_ip) + [install],
) input=ca_pem, capture_output=True, text=True, check=False,
)
except OSError as exc:
raise InfraLaunchError(f"{run_dir.name} ({guest_ip}): CA push failed: {exc}") from exc
if proc.returncode != 0: if proc.returncode != 0:
raise InfraLaunchError( raise InfraLaunchError(
f"CA push to {guest_ip} failed: " f"{run_dir.name} ({guest_ip}): CA push failed: "
f"{proc.stderr.strip() or '<no stderr>'}" f"{proc.stderr.strip() or '<no stderr>'}"
) )
def attach_bottled_agents_to_gateway() -> None:
"""Reconcile every running agent VM against the freshly-booted gateway
(PRD 0081). Replaces each agent's trusted CA with the current gateway CA —
the gateway rootfs is ephemeral, so a cold boot mints a fresh CA and this is
what distributes it (a gateway rebuild doubles as a free CA rotation, #510).
Per-bottle steps are best-effort: one unreachable or malformed VM is logged
and skipped so it never blocks the others or the gateway coming up."""
gateway = FirecrackerGateway()
try:
ca_pem = gateway.ca_cert_pem()
except GatewayError as exc:
info(f"bring-up reconcile skipped: gateway CA unavailable: {exc}")
return
reconciled = 0
for run_dir in cleanup.live_run_dirs():
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
continue
try:
_push_gateway_ca_to_agent(private_key, guest_ip, ca_pem)
reconciled += 1
except (OSError, InfraLaunchError) as exc:
info(f"CA reconcile skipped for {guest_ip}: {exc}")
if reconciled:
info(f"reconciled gateway CA into {reconciled} running Firecracker bottle(s)")
def _reprovision_running_bottles(client: OrchestratorClient) -> None: def _reprovision_running_bottles(client: OrchestratorClient) -> None:
"""Read keys from live agent VMs and restore the restarted gateway.""" """Read keys from live agent VMs and restore the restarted gateway."""
try: try:
@@ -210,7 +195,7 @@ def deprovision_consolidated(
__all__ = [ __all__ = [
"LaunchContext", "LaunchContext",
"launch_consolidated", "launch_consolidated",
"attach_bottled_agents_to_gateway", "attach_ca_to_agent_vm",
"deprovision_consolidated", "deprovision_consolidated",
"InfraLaunchError", "InfraLaunchError",
"OrchestratorStartError", "OrchestratorStartError",
+54
View File
@@ -0,0 +1,54 @@
"""The shared gateway-attach reconcile flow (PRD 0081).
`BottleBackend.attach_bottled_agents_to_gateway()` delegates here so every
backend reconciles running bottles against a freshly-booted gateway *the same
way* — the control flow + fail-hard policy live in one place and backends
override only the primitives (see ADR 0006). Kept out of `backend/base.py` so
the backend contract module stays lean (the base.py size guardrail).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from .base import InfraLaunchError
if TYPE_CHECKING:
from .base import BottleBackend
@dataclass(frozen=True)
class GatewayAttachResources:
"""Everything a running bottle needs to (re)attach to the current gateway on
a cold boot (PRD 0081). Gathered once per reconcile and handed to every
bottle. The CA now; egress secrets and git-gate state join as the reconcile
grows (#516)."""
ca_pem: str
def reconcile_running_bottles(backend: "BottleBackend[Any, Any, Any]") -> None:
"""Reconcile every already-running bottle against the freshly-(re)booted
gateway: gather the attach resources once, then attach every live bottle.
Fail hard, never skip: a bottle that silently can't reach the fresh gateway
(its egress just starts failing TLS) is worse than a loud bring-up failure,
so any attach failure aborts bring-up. Every bottle is attempted first and
the failures are raised together, so one bring-up surfaces the full blast
radius rather than one bottle at a time."""
resources = backend._gateway_attach_resources()
failures: list[str] = []
for bottle in backend._running_bottles():
try:
backend._attach_bottle_to_gateway(bottle, resources)
except InfraLaunchError as exc:
failures.append(str(exc))
if failures:
raise InfraLaunchError(
f"could not attach {len(failures)} running bottle(s) to the "
f"freshly-booted gateway:\n " + "\n ".join(failures)
)
__all__ = ["GatewayAttachResources", "reconcile_running_bottles"]
+15 -4
View File
@@ -14,6 +14,7 @@ from ...git_gate import GitGatePlan
from ...supervisor.plan import SupervisePlan from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -25,7 +26,7 @@ from .bottle_plan import MacosContainerBottlePlan
class MacosContainerBottleBackend( class MacosContainerBottleBackend(
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"] BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan", str]
): ):
"""Apple Container backend. Selected by """Apple Container backend. Selected by
`BOT_BOTTLE_BACKEND=macos-container` or `BOT_BOTTLE_BACKEND=macos-container` or
@@ -117,9 +118,19 @@ class MacosContainerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
def attach_bottled_agents_to_gateway(self) -> None: def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra_launch import attach_bottled_agents_to_gateway from .infra import MacosInfraService
attach_bottled_agents_to_gateway() return GatewayAttachResources(ca_pem=MacosInfraService().ca_cert_pem())
def _running_bottles(self) -> Sequence[str]:
from .infra_launch import running_agent_containers
return running_agent_containers()
def _attach_bottle_to_gateway(
self, bottle: str, resources: GatewayAttachResources,
) -> None:
from .infra_launch import push_ca_to_container
push_ca_to_container(bottle, resources.ca_pem)
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str: def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
return plan.agent_supervise_url return plan.agent_supervise_url
@@ -97,11 +97,23 @@ def ensure_gateway(
return endpoint return endpoint
def _push_ca_to_container(name: str, ca_pem: str) -> None: def running_agent_containers() -> list[str]:
"""Replace one running agent container's trusted gateway CA with `ca_pem` """Every running agent container's name — the bottles the bring-up reconcile
and rebuild its trust store via `container cp` + `container exec`. attaches to the fresh gateway (PRD 0081). Raises `EnumerationError` if the
Unconditional install — there is one gateway, so no fingerprint match is live set can't be determined (fail hard rather than reconcile a partial
needed. Raises `InfraLaunchError` on failure.""" set)."""
return [f"{CONTAINER_NAME_PREFIX}{agent.slug}" for agent in enumerate_active()]
def push_ca_to_container(name: str, ca_pem: str) -> None:
"""(Re)attach one running agent container to the current gateway: replace
its trusted gateway CA with `ca_pem` and rebuild its trust store via
`container cp` + `container exec` (PRD 0081). Unconditional install — there
is one gateway, so no fingerprint match is needed.
Fail hard: raises `InfraLaunchError` (naming the container) on any failure —
the base reconcile aggregates and surfaces it rather than leaving the agent
silently unable to reach the gateway."""
mkdir = container_mod.run_container_argv( mkdir = container_mod.run_container_argv(
["container", "exec", name, "mkdir", "-p", ["container", "exec", name, "mkdir", "-p",
"/usr/local/share/ca-certificates"] "/usr/local/share/ca-certificates"]
@@ -131,32 +143,6 @@ def _push_ca_to_container(name: str, ca_pem: str) -> None:
) )
def attach_bottled_agents_to_gateway(ca_pem: str | None = None) -> None:
"""Reconcile every running agent container against the freshly-(re)created
gateway (PRD 0081): replace each agent's trusted CA with the current gateway
CA, so a gateway rebuild doesn't silently break their TLS egress (#510).
Per-container steps are best-effort: one failure is logged and skipped so it
never blocks the others or the gateway coming up."""
if ca_pem is None:
ca_pem = MacosInfraService().ca_cert_pem()
try:
agents = list(enumerate_active())
except (EnumerationError, OSError) as exc:
info(f"CA reconcile skipped: {exc}")
return
reconciled = 0
for agent in agents:
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
try:
_push_ca_to_container(name, ca_pem)
reconciled += 1
except (OSError, InfraLaunchError) as exc:
info(f"CA reconcile skipped for {name}: {exc}")
if reconciled:
info(f"reconciled gateway CA into {reconciled} running macOS bottle(s)")
def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None: def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None:
"""Recover keys from live Apple containers and restore gateway tokens.""" """Recover keys from live Apple containers and restore gateway tokens."""
try: try:
@@ -259,7 +245,8 @@ __all__ = [
"GatewayEndpoint", "GatewayEndpoint",
"LaunchContext", "LaunchContext",
"ensure_gateway", "ensure_gateway",
"attach_bottled_agents_to_gateway", "running_agent_containers",
"push_ca_to_container",
"live_source_ips", "live_source_ips",
"register_agent", "register_agent",
"deprovision_consolidated", "deprovision_consolidated",
+3 -3
View File
@@ -26,10 +26,10 @@ from .base import ActiveAgent, BackendStatus, BottleBackend
# Tests may replace _backends with a {name: fake} dict via patch.object; # Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it # _get_backends() returns the current module-level value as-is when it
# is not None, so test fakes take effect without triggering real imports. # is not None, so test fakes take effect without triggering real imports.
_backends: dict[str, BottleBackend[Any, Any]] | None = None _backends: dict[str, BottleBackend[Any, Any, Any]] | None = None
def _get_backends() -> dict[str, BottleBackend[Any, Any]]: def _get_backends() -> dict[str, BottleBackend[Any, Any, Any]]:
"""Return the registry of all backend instances, loading lazily on first call.""" """Return the registry of all backend instances, loading lazily on first call."""
global _backends # pylint: disable=global-statement global _backends # pylint: disable=global-statement
if _backends is None: if _backends is None:
@@ -48,7 +48,7 @@ def get_bottle_backend(
name: str | None = None, name: str | None = None,
*, *,
prompt: bool = True, prompt: bool = True,
) -> BottleBackend[Any, Any]: ) -> BottleBackend[Any, Any, Any]:
"""Resolve the bottle backend. """Resolve the bottle backend.
`name` precedence: `name` precedence:
@@ -0,0 +1,65 @@
# ADR 0006: Shared behaviour lives in the base backend
- **Status:** Accepted
- **Date:** 2026-07-27
- **Deciders:** didericis
## Context
`BottleBackend` has three concrete implementations (Firecracker, docker,
macOS-container). Cross-backend operations can be expressed two ways:
1. **Abstract method per backend** — the base declares `@abstractmethod foo()`
and each backend writes its own `foo()` end to end.
2. **Template method** — the base owns the *control flow* of `foo()` as a
concrete method and each backend overrides small **primitives** it calls.
Form 1 gives each backend total freedom, which is exactly the problem: the three
implementations drift. The bring-up reconcile (PRD 0081) first shipped as form 1
and immediately diverged — each backend re-implemented "gather CA → list running
bottles → push to each", and one backend's copy silently skipped failures while
another's aborted, an inconsistency caught only in review (#519). The behaviour
that must be identical (the loop, the error policy, the ordering) was duplicated
in the place least likely to stay in sync.
## Decision
Prefer the template-method form for cross-backend behaviour: **encode shared
control flow and policy in the base backend; backends override primitives, not
control flow.** Lean toward *less* freedom in the concrete backends and *more*
consistency in the base.
Concretely, a cross-backend operation on `BottleBackend` should be a concrete
method that:
- owns the sequencing, iteration, and error policy (e.g. fail-hard vs.
best-effort, fail-fast vs. attempt-all-then-aggregate); and
- delegates only the irreducibly backend-specific steps to `@abstractmethod`
primitives with narrow, well-typed signatures.
The first application is `attach_bottled_agents_to_gateway()` (PRD 0081): the
base gathers resources once, attempts every running bottle, and raises an
aggregate `InfraLaunchError` if any failed; backends supply only
`_gateway_attach_resources()`, `_running_bottles()`, and
`_attach_bottle_to_gateway()`.
A backend-specific `@abstractmethod` with no shared control flow (e.g.
`_launch_impl`, `enumerate_active`) stays as form 1 — this ADR is about
operations whose *shape* should be uniform, not about banning abstract methods.
## Consequences
- The error policy, ordering, and loop for a cross-backend operation have one
source of truth; a new backend cannot forget them or diverge.
- New backends implement smaller, more focused primitives instead of re-deriving
a whole flow.
- The base carries more logic and needs generic-enough primitive signatures
(e.g. a backend-specific attach-target type parameter on `BottleBackend`).
- Genuinely backend-specific one-offs still use a plain abstract method; the
guidance is a default, not an absolute.
## Links
- PRD 0081 (`docs/prds/0081-reprovision-gateway-state-on-bringup.md`).
- Review that prompted this: PR #519.
- `bot_bottle/backend/base.py` (`BottleBackend.attach_bottled_agents_to_gateway`).
@@ -47,8 +47,13 @@ destroy (the original #450 failure mode).
different mechanism per service. different mechanism per service.
- The CA **rotates** on every gateway bring-up (no long-lived CA), distributed to - The CA **rotates** on every gateway bring-up (no long-lived CA), distributed to
running agents by the same reconcile. running agents by the same reconcile.
- Per-bottle failures are tolerated: one unreachable or malformed bottle does not - Reconcile failures are **loud, never hidden**: if a running bottle can't be
block the others or the gateway coming up. re-attached to the fresh gateway, the bring-up fails and names the bottle
rather than leaving it silently unable to reach the gateway (a bottle whose
egress just starts failing TLS is worse than a loud failure). Every bottle is
attempted first and the failures are raised together, so one bring-up surfaces
the full blast radius. (Reversed from an earlier "tolerate per-bottle
failures" draft after #519 review.)
- **All backends** (Firecracker, docker, macOS) reconcile through the *same* - **All backends** (Firecracker, docker, macOS) reconcile through the *same*
contract — an abstract method on the backend base class, so a new backend contract — an abstract method on the backend base class, so a new backend
cannot forget to implement it and none drifts onto a bespoke mechanism. cannot forget to implement it and none drifts onto a bespoke mechanism.
@@ -61,15 +66,27 @@ destroy (the original #450 failure mode).
## Design ## Design
**The contract — `attach_bottled_agents_to_gateway()` on the backend ABC.** **The contract — `attach_bottled_agents_to_gateway()`, a concrete flow on the
Reconciling running bottles against the current gateway is a backend backend base.** Reconciling running bottles against the current gateway is a
responsibility (only the backend can enumerate its agents and reach them backend responsibility (only the backend can enumerate its agents and reach them
firecracker over SSH, docker/macOS over `exec`/`cp`), so it is an firecracker over SSH, docker/macOS over `exec`/`cp`). To keep every backend
`@abc.abstractmethod` on `BottleBackend` (`backend/base.py`). Every backend reconciling identically, the *control flow* lives in a concrete
implements it; the host calls it whenever the gateway is (re)brought up. This is `attach_bottled_agents_to_gateway()` on `BottleBackend` (`backend/base.py`) and
what makes the fix cross-backend by construction rather than a per-backend each backend overrides only three primitives:
follow-up. It reprovisions **all** registered bottles' gateway-dependent state:
CA, git-gate, and egress tokens. 1. `_gateway_attach_resources()` — gather what a bottle needs to attach (the
gateway CA now; egress secrets + git-gate state as this grows).
2. `_running_bottles()` — enumerate the live bottles as backend handles.
3. `_attach_bottle_to_gateway(bottle, resources)` — push the resources into one
bottle, raising `InfraLaunchError` (naming it) on failure.
The base gathers resources once, attempts every bottle, and — fail hard, never
skip — raises an aggregate `InfraLaunchError` if any failed, so a backend can't
drift onto a bespoke loop or a silent skip. (Encoding shared behaviour in the
base backend, with subclasses overriding primitives rather than control flow, is
[ADR 0006](../decisions/0006-shared-behavior-in-base-backend.md).) The host
calls it whenever the gateway is (re)brought up. It reprovisions **all**
registered bottles' gateway-dependent state: CA, git-gate, and egress tokens.
**Trigger — the gateway bring-up path.** The host calls **Trigger — the gateway bring-up path.** The host calls
`attach_bottled_agents_to_gateway()` only when the gateway was actually `attach_bottled_agents_to_gateway()` only when the gateway was actually
@@ -98,7 +115,9 @@ and its CA is available:
+ per-repo creds under `/git/<bottle_id>`. + per-repo creds under `/git/<bottle_id>`.
- **egress token:** read the agent's `ENV_VAR_SECRET` and feed - **egress token:** read the agent's `ENV_VAR_SECRET` and feed
`reprovision_bottles`, restoring the orchestrator's in-memory tokens. `reprovision_bottles`, restoring the orchestrator's in-memory tokens.
4. Every per-bottle step is wrapped so one failure is logged and skipped. 4. If any per-bottle step fails, the reconcile attempts the rest and then raises
an aggregate naming every bottle that failed — bring-up fails loudly rather
than leaving a bottle silently unable to reach the gateway.
**Retire the persistence prototype.** No CA data volume, no git-gate data volume **Retire the persistence prototype.** No CA data volume, no git-gate data volume
(the abandoned PRs #511 / #513). The launch-time `_reprovision_running_bottles` (the abandoned PRs #511 / #513). The launch-time `_reprovision_running_bottles`
+6 -1
View File
@@ -83,8 +83,13 @@ class TestRuntimeModuleSizes(unittest.TestCase):
self.assertEqual([], violations) self.assertEqual([], violations)
def test_backend_contract_does_not_absorb_preparation_logic(self) -> None: def test_backend_contract_does_not_absorb_preparation_logic(self) -> None:
# base.py holds the backend *contract*; implementation lives elsewhere.
# The cap is a tripwire against absorbing preparation/impl logic, not a
# ban on new contract surface — bumped 580->600 for the PRD 0081
# gateway-attach contract (delegator + 3 abstract primitives; the flow
# itself lives in backend/gateway_attach.py, not here).
caps = { caps = {
ROOT / "bot_bottle" / "backend" / "base.py": 580, ROOT / "bot_bottle" / "backend" / "base.py": 600,
ROOT / "bot_bottle" / "backend" / "preparation.py": 160, ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
} }
oversized = [ oversized = [
+113 -87
View File
@@ -11,6 +11,8 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from bot_bottle.orchestrator.reprovision import reprovision_bottles from bot_bottle.orchestrator.reprovision import reprovision_bottles
from bot_bottle.backend.base import BottleBackend, InfraLaunchError
from bot_bottle.backend.gateway_attach import GatewayAttachResources
from bot_bottle.backend.firecracker import infra_launch as fc from bot_bottle.backend.firecracker import infra_launch as fc
from bot_bottle.backend.macos_container import infra_launch as mac from bot_bottle.backend.macos_container import infra_launch as mac
from bot_bottle.backend.docker import infra_launch as docker from bot_bottle.backend.docker import infra_launch as docker
@@ -159,9 +161,55 @@ class TestFirecrackerReprovision(unittest.TestCase):
restore.assert_called_once_with(client, {}) restore.assert_called_once_with(client, {})
class TestFirecrackerCaReconcile(unittest.TestCase): class TestAttachFlow(unittest.TestCase):
"""PRD 0081: attach_bottled_agents_to_gateway pushes the current gateway CA """PRD 0081 / #519 review: the base backend owns the reconcile flow and
into every running agent VM over SSH.""" fails hard gather resources, attempt every running bottle, then raise an
aggregate if any failed (never silently skip)."""
def _fake(self, **primitives: object) -> SimpleNamespace:
base: dict[str, object] = {
"_gateway_attach_resources": Mock(
return_value=GatewayAttachResources(ca_pem="PEM")),
"_running_bottles": Mock(return_value=["a", "b", "c"]),
"_attach_bottle_to_gateway": Mock(),
}
base.update(primitives)
return SimpleNamespace(**base)
def test_attaches_every_running_bottle_once(self) -> None:
fake = self._fake()
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
self.assertEqual(3, fake._attach_bottle_to_gateway.call_count)
# Each bottle got the single gathered resource set.
res = fake._gateway_attach_resources.return_value
for call in fake._attach_bottle_to_gateway.call_args_list:
self.assertIs(res, call.args[1])
def test_attempts_all_then_raises_aggregate_on_failure(self) -> None:
def attach(bottle: str, _resources: object) -> None:
if bottle in ("b", "c"):
raise InfraLaunchError(f"{bottle} unreachable")
fake = self._fake(_attach_bottle_to_gateway=Mock(side_effect=attach))
with self.assertRaises(InfraLaunchError) as ctx:
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
msg = str(ctx.exception)
# Every bottle was attempted (not fail-fast), and both failures surface.
self.assertEqual(3, fake._attach_bottle_to_gateway.call_count)
self.assertIn("b unreachable", msg)
self.assertIn("c unreachable", msg)
self.assertIn("2", msg)
def test_resource_gathering_failure_fails_hard_before_enumerating(self) -> None:
fake = self._fake(
_gateway_attach_resources=Mock(side_effect=RuntimeError("no CA yet")))
with self.assertRaises(RuntimeError):
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
fake._running_bottles.assert_not_called()
class TestFirecrackerAttachPrimitive(unittest.TestCase):
"""PRD 0081: attach_ca_to_agent_vm pushes the current gateway CA into one
running agent VM over SSH, failing hard."""
def _run_dir(self, root: Path, ip: str = "10.243.0.3") -> Path: def _run_dir(self, root: Path, ip: str = "10.243.0.3") -> Path:
run_dir = root / "demo" run_dir = root / "demo"
@@ -172,112 +220,90 @@ class TestFirecrackerCaReconcile(unittest.TestCase):
})) }))
return run_dir return run_dir
def test_pushes_current_ca_over_stdin_and_rebuilds_trust_store(self) -> None: def test_pushes_ca_over_stdin_and_rebuilds_trust_store(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp)) run_dir = self._run_dir(Path(tmp))
gw = Mock() with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
gw.ca_cert_pem.return_value = "PEM-DATA" patch.object(fc.subprocess, "run", return_value=_proc()) as run:
with patch.object(fc, "FirecrackerGateway", return_value=gw), \ fc.attach_ca_to_agent_vm(run_dir, "PEM-DATA")
patch.object(fc.cleanup, "live_run_dirs", return_value=(run_dir,)), \
patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc()) as run, \
patch.object(fc, "info"):
fc.attach_bottled_agents_to_gateway()
# The cert goes over stdin (off argv), and the trust store is rebuilt. # The cert goes over stdin (off argv), and the trust store is rebuilt.
self.assertEqual("PEM-DATA", run.call_args.kwargs["input"]) self.assertEqual("PEM-DATA", run.call_args.kwargs["input"])
script = run.call_args.args[0][-1] script = run.call_args.args[0][-1]
self.assertIn(fc.AGENT_CA_PATH, script) self.assertIn(fc.AGENT_CA_PATH, script)
self.assertIn("update-ca-certificates", script) self.assertIn("update-ca-certificates", script)
def test_unreachable_vm_is_skipped_not_fatal(self) -> None: def test_malformed_run_dir_fails_hard(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
empty = Path(tmp) / "no-config"
empty.mkdir()
with self.assertRaises(fc.InfraLaunchError):
fc.attach_ca_to_agent_vm(empty, "PEM")
def test_push_failure_fails_hard(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp)) run_dir = self._run_dir(Path(tmp))
gw = Mock() with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
gw.ca_cert_pem.return_value = "PEM" patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="down")):
with patch.object(fc, "FirecrackerGateway", return_value=gw), \ with self.assertRaisesRegex(fc.InfraLaunchError, "down"):
patch.object(fc.cleanup, "live_run_dirs", return_value=(run_dir,)), \ fc.attach_ca_to_agent_vm(run_dir, "PEM")
patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="down")), \
patch.object(fc, "info") as info:
fc.attach_bottled_agents_to_gateway() # does not raise
self.assertTrue(any("skipped" in c.args[0] for c in info.call_args_list))
def test_missing_gateway_ca_skips_the_whole_reconcile(self) -> None:
gw = Mock()
gw.ca_cert_pem.side_effect = fc.GatewayError("no CA yet")
with patch.object(fc, "FirecrackerGateway", return_value=gw), \
patch.object(fc.cleanup, "live_run_dirs") as live, \
patch.object(fc, "info"):
fc.attach_bottled_agents_to_gateway()
live.assert_not_called() # bailed before enumerating agents
class TestDockerCaReconcile(unittest.TestCase): class TestDockerAttachPrimitives(unittest.TestCase):
def test_pushes_ca_to_each_agent_container_excluding_gateway(self) -> None: def test_running_agent_containers_excludes_gateway(self) -> None:
# network inspect lists the gateway (excluded) + one agent + a blank line. # network inspect lists the gateway (excluded) + one agent + a blank line.
inspect = _proc(stdout="bot-bottle-orch-gateway\nbot-bottle-a\n\n") inspect = _proc(stdout="bot-bottle-orch-gateway\nbot-bottle-a\n\n")
with patch.object(docker, "run_docker", return_value=inspect):
names = docker.running_agent_containers(
network="net", gateway_name="bot-bottle-orch-gateway")
self.assertEqual(["bot-bottle-a"], names)
def test_running_agent_containers_fails_hard_on_docker_error(self) -> None:
with patch.object(docker, "run_docker", side_effect=FileNotFoundError("docker")):
with self.assertRaises(FileNotFoundError):
docker.running_agent_containers()
def test_push_ca_cp_then_rebuilds_trust_store(self) -> None:
with patch.object( with patch.object(
docker, "run_docker", docker, "run_docker", side_effect=[_proc(), _proc(), _proc()],
side_effect=[inspect, _proc(), _proc(), _proc()], ) as run:
) as run, patch.object(docker.log, "info"): docker.push_ca_to_container("bot-bottle-a", "PEM")
docker.attach_bottled_agents_to_gateway(
"PEM", gateway_name="bot-bottle-orch-gateway")
argvs = [c.args[0] for c in run.call_args_list] argvs = [c.args[0] for c in run.call_args_list]
# First call enumerates; the agent then gets mkdir + cp + exec — never
# the gateway container.
self.assertNotIn(
"bot-bottle-orch-gateway",
" ".join(tok for a in argvs[1:] for tok in a),
)
cp = next(a for a in argvs if a[:2] == ["docker", "cp"]) cp = next(a for a in argvs if a[:2] == ["docker", "cp"])
self.assertEqual(f"bot-bottle-a:{docker.AGENT_CA_PATH}", cp[-1]) self.assertEqual(f"bot-bottle-a:{docker.AGENT_CA_PATH}", cp[-1])
exec_argv = next(a for a in argvs if "update-ca-certificates" in a[-1]) self.assertTrue(any("update-ca-certificates" in a[-1] for a in argvs))
self.assertIn(docker.AGENT_CA_PATH, exec_argv[-1])
def test_one_container_failure_does_not_block_others(self) -> None: def test_push_ca_fails_hard(self) -> None:
inspect = _proc(stdout="a\nb\n") with patch.object(docker, "run_docker", return_value=_proc(1, stderr="gone")):
# a: mkdir fails (skip); b: mkdir/cp/exec all succeed. with self.assertRaisesRegex(docker.InfraLaunchError, "gone"):
with patch.object( docker.push_ca_to_container("bot-bottle-a", "PEM")
docker, "run_docker",
side_effect=[inspect, _proc(1, stderr="gone"), _proc(), _proc(), _proc()],
), patch.object(docker.log, "info") as info:
docker.attach_bottled_agents_to_gateway("PEM", gateway_name="gw")
self.assertTrue(
any("skipped for a" in str(c.args[0]) for c in info.call_args_list)
)
def test_fetches_ca_from_gateway_when_not_supplied(self) -> None:
svc = Mock()
svc.gateway.return_value.ca_cert_pem.return_value = "FETCHED"
with patch.object(docker, "DockerInfraService", return_value=svc), \
patch.object(docker, "run_docker", return_value=_proc(stdout="")), \
patch.object(docker.log, "info"):
docker.attach_bottled_agents_to_gateway(network="net", gateway_name="gw")
svc.gateway.return_value.ca_cert_pem.assert_called_once()
class TestMacosCaReconcile(unittest.TestCase): class TestMacosAttachPrimitives(unittest.TestCase):
def test_pushes_ca_to_each_running_agent_container(self) -> None: def test_running_agent_containers_from_enumeration(self) -> None:
agent = SimpleNamespace(slug="demo")
with patch.object(mac, "enumerate_active", return_value=[agent]), \
patch.object(mac.container_mod, "run_container_argv",
return_value=_proc()) as run, \
patch.object(mac, "info"):
mac.attach_bottled_agents_to_gateway("PEM")
argvs = [c.args[0] for c in run.call_args_list]
name = f"{mac.CONTAINER_NAME_PREFIX}demo"
self.assertTrue(any(name in a for a in argvs))
self.assertTrue(
any("update-ca-certificates" in a[-1] for a in argvs if a[-2:-1] == ["-c"])
)
self.assertTrue(any(mac.AGENT_CA_PATH in " ".join(a) for a in argvs))
def test_enumeration_failure_is_best_effort(self) -> None:
with patch.object(mac, "enumerate_active", with patch.object(mac, "enumerate_active",
side_effect=mac.EnumerationError("failed")), \ return_value=[SimpleNamespace(slug="demo")]):
patch.object(mac, "info") as info: names = mac.running_agent_containers()
mac.attach_bottled_agents_to_gateway("PEM") # does not raise self.assertEqual([f"{mac.CONTAINER_NAME_PREFIX}demo"], names)
self.assertIn("skipped", info.call_args.args[0])
def test_running_agent_containers_fails_hard(self) -> None:
with patch.object(mac, "enumerate_active",
side_effect=mac.EnumerationError("failed")):
with self.assertRaises(mac.EnumerationError):
mac.running_agent_containers()
def test_push_ca_cp_then_rebuilds_trust_store(self) -> None:
with patch.object(mac.container_mod, "run_container_argv",
return_value=_proc()) as run:
mac.push_ca_to_container(f"{mac.CONTAINER_NAME_PREFIX}demo", "PEM")
argvs = [c.args[0] for c in run.call_args_list]
self.assertTrue(any(mac.AGENT_CA_PATH in " ".join(a) for a in argvs))
self.assertTrue(any("update-ca-certificates" in a[-1] for a in argvs))
def test_push_ca_fails_hard(self) -> None:
with patch.object(mac.container_mod, "run_container_argv",
return_value=_proc(1, stderr="no")):
with self.assertRaisesRegex(mac.InfraLaunchError, "no"):
mac.push_ca_to_container("x", "PEM")
if __name__ == "__main__": if __name__ == "__main__":