3c0d2fb66f
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>
159 lines
5.8 KiB
Python
159 lines
5.8 KiB
Python
"""DockerBottleBackend — the Docker implementation of BottleBackend.
|
|
|
|
This module is a thin façade. The real work lives in four siblings:
|
|
|
|
- resolve_plan.py — Docker-specific resolution into a DockerBottlePlan
|
|
- launch.py — bring-up + teardown context manager
|
|
- cleanup.py — orphan enumeration + removal
|
|
- enumerate.py — active-agent listing
|
|
|
|
The base class's `prepare` template runs cross-backend host-side
|
|
validation before calling `_resolve_plan` here.
|
|
|
|
Per PRD 0050 the per-provider provisioning steps (prompt, skills,
|
|
the declarative provision-plan apply, supervise MCP registration)
|
|
live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The
|
|
Docker backend only owns the steps that are about backend
|
|
infrastructure: CA install and git copy-in.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import io
|
|
from contextlib import contextmanager, redirect_stderr
|
|
from pathlib import Path
|
|
from typing import Generator, Sequence
|
|
|
|
from ...supervisor.types import SUPERVISE_HOSTNAME, SUPERVISE_PORT
|
|
from ...agent_provider import AgentProvisionPlan
|
|
from ...egress import EgressPlan
|
|
from ...env import ResolvedEnv
|
|
from ...git_gate import GitGatePlan
|
|
from ...supervisor.plan import SupervisePlan
|
|
from ...manifest import Manifest
|
|
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
|
|
from ..gateway_attach import GatewayAttachResources
|
|
from . import cleanup as _cleanup
|
|
from . import enumerate as _enumerate
|
|
from . import launch as _launch
|
|
from . import resolve_plan as _resolve_plan
|
|
from .bottle import DockerBottle
|
|
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
|
from .bottle_plan import DockerBottlePlan
|
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan", str]):
|
|
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
|
|
when set to `docker`; retained as a legacy/example backend."""
|
|
|
|
name = "docker"
|
|
|
|
@classmethod
|
|
def is_available(cls) -> bool:
|
|
"""`docker` on PATH is sufficient; we don't probe `docker info`
|
|
eagerly because the cross-backend enumerator runs this on
|
|
every `list active` and we'd pay a subprocess per call. A
|
|
broken daemon will surface its own error during prepare /
|
|
launch."""
|
|
return shutil.which("docker") is not None
|
|
|
|
@classmethod
|
|
def setup(cls) -> int:
|
|
from . import setup as _setup
|
|
return _setup.setup()
|
|
|
|
@classmethod
|
|
def status(cls, *, quiet: bool = False) -> int:
|
|
from . import setup as _setup
|
|
if quiet:
|
|
with redirect_stderr(io.StringIO()):
|
|
return _setup.status()
|
|
return _setup.status()
|
|
|
|
@classmethod
|
|
def teardown(cls) -> int:
|
|
from . import setup as _setup
|
|
return _setup.teardown()
|
|
|
|
def _preflight(self) -> None:
|
|
_resolve_plan.preflight()
|
|
|
|
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
return _resolve_plan.build_guest_env(resolved_env)
|
|
|
|
def _resolve_plan(
|
|
self,
|
|
spec: BottleSpec,
|
|
*,
|
|
manifest: Manifest,
|
|
slug: str,
|
|
resolved_env: ResolvedEnv,
|
|
agent_provision_plan: AgentProvisionPlan,
|
|
egress_plan: EgressPlan,
|
|
git_gate_plan: GitGatePlan,
|
|
supervise_plan: SupervisePlan | None,
|
|
stage_dir: Path,
|
|
) -> DockerBottlePlan:
|
|
return _resolve_plan.resolve_plan(
|
|
spec,
|
|
manifest=manifest,
|
|
slug=slug,
|
|
resolved_env=resolved_env,
|
|
agent_provision_plan=agent_provision_plan,
|
|
egress_plan=egress_plan,
|
|
supervise_plan=supervise_plan,
|
|
git_gate_plan=git_gate_plan,
|
|
stage_dir=stage_dir,
|
|
)
|
|
|
|
def prelaunch_checks(self, plan: DockerBottlePlan) -> None:
|
|
_launch.stale_checks(plan)
|
|
|
|
def _build_or_load_images(self, plan: DockerBottlePlan) -> BottleImages:
|
|
return _launch.build_or_load_images(plan)
|
|
|
|
@contextmanager
|
|
def _launch_impl(self, plan: DockerBottlePlan, images: BottleImages) -> Generator[DockerBottle, None, None]:
|
|
with _launch.launch(plan, images, provision=self.provision) as bottle:
|
|
yield bottle
|
|
|
|
def ensure_orchestrator(self) -> str:
|
|
from .infra import DockerInfraService
|
|
return DockerInfraService().ensure_running()
|
|
|
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
|
"""Docker bottles reach the supervise daemon via the
|
|
compose-network alias `supervise:9100`. No per-bottle URL
|
|
plumbing needed; the alias resolves inside the bridge."""
|
|
if plan.supervise_plan is None:
|
|
return ""
|
|
# Consolidated: the supervise daemon lives on the shared gateway, so
|
|
# the agent registers the gateway address (NO_PROXY bypasses egress),
|
|
# not the per-bottle `supervise` alias.
|
|
if plan.agent_supervise_url:
|
|
return plan.agent_supervise_url
|
|
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
|
|
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
|
return _cleanup.prepare_cleanup()
|
|
|
|
def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
|
|
_cleanup.cleanup(plan)
|
|
|
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
return _enumerate.enumerate_active()
|
|
|
|
def _gateway_attach_resources(self) -> GatewayAttachResources:
|
|
from .infra import DockerInfraService
|
|
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)
|