31b6724a52
Follows the base PR's renumber (0081 was already taken; main is on 0082). Update every `PRD 0081` reference in the gateway-attach code, tests, and ADR 0006, plus ADR 0006's link to the PRD file. No behaviour change. Refs #516, #519. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
6.5 KiB
Python
174 lines
6.5 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 TYPE_CHECKING, 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
|
|
|
|
if TYPE_CHECKING:
|
|
from .infra import DockerInfraService
|
|
|
|
|
|
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"
|
|
|
|
def __init__(self, *, infra: "DockerInfraService | None" = None) -> None:
|
|
# The infra service whose gateway just cold-booted, passed in on the
|
|
# bring-up reconcile path (PRD 0083) so `_gateway_attach_resources`
|
|
# reads THAT gateway's CA rather than a fresh default-named service —
|
|
# otherwise a non-default instance (an isolated integration test's
|
|
# `-itest-` gateway) reads the default `bot-bottle-orch-gateway`, which
|
|
# doesn't exist for it (#519 review). None for ordinary construction:
|
|
# falls back to the per-host default singleton.
|
|
self._infra = infra
|
|
|
|
@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
|
|
infra = self._infra if self._infra is not None else DockerInfraService()
|
|
return GatewayAttachResources(ca_pem=infra.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)
|