refactor(backend): split the heavy backend/__init__ into base + selection, thin the package init
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Successful in 38s
test / unit (pull_request) Successful in 52s
lint / lint (push) Successful in 1m1s
test / integration-firecracker (pull_request) Successful in 3m33s
test / coverage (pull_request) Successful in 17s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Successful in 38s
test / unit (pull_request) Successful in 52s
lint / lint (push) Successful in 1m1s
test / integration-firecracker (pull_request) Successful in 3m33s
test / coverage (pull_request) Successful in 17s
test / publish-infra (pull_request) Has been skipped
backend/__init__.py eagerly imported the whole framework (manifest, egress,
git-gate, env, workspace, agent-provider), so importing ANY backend.* submodule
paid ~32 modules just to run the package init. Split it:
- backend/base.py — the abstract contract (BottleSpec, BottlePlan,
BottleCleanupPlan, ExecResult, ActiveAgent, Bottle,
BottleImages, BottleBackend). Carries the framework
imports; loaded only when a caller needs the contract.
- backend/selection.py — backend registry / selection / enumeration
(get_bottle_backend, _get_backends, _auto_select_backend,
has_backend, known_backend_names, enumerate_active_agents).
Concrete backends still imported lazily inside.
- backend/__init__.py — thin: a __getattr__ that resolves the public names
from those submodules on first access (+ a TYPE_CHECKING
block for checkers). Existing `from bot_bottle.backend
import X` and patch.object call-sites keep working.
`import bot_bottle.backend` drops from 32 -> 2 bot_bottle modules. Repointed the
backend-selection test's patch targets to the `selection` module (the functions
reference each other there now; the FirecrackerBottleBackend class-method
patches are unchanged — same class object).
Note: backend.docker.util is still heavy because backend/docker/__init__ eagerly
imports DockerBottleBackend — slimming the three sub-package inits is the
follow-up that makes the leaves cheap.
Full unit suite green (2243).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+72
-817
@@ -1,836 +1,87 @@
|
|||||||
"""Per-backend bottle factories.
|
"""The bottle-backend package: abstract contract + backend selection.
|
||||||
|
|
||||||
A bottle is a running, isolated environment with claude inside. Each
|
Thin by design — nothing framework-heavy is imported at package init, so
|
||||||
backend exposes five methods:
|
importing any `backend.*` submodule (e.g. `backend.docker.util`) doesn't drag
|
||||||
|
the manifest / egress / git-gate framework into memory. The public names are
|
||||||
|
re-exported lazily:
|
||||||
|
|
||||||
prepare(spec, stage_dir=...) -> BottlePlan
|
* the abstract contract (`BottleBackend`, `BottleSpec`, `Bottle`, …) lives in
|
||||||
Resolves names, validates host-side prerequisites, and writes
|
`backend.base`;
|
||||||
scratch files. No remote/runtime resources are created yet.
|
* backend selection / enumeration (`get_bottle_backend`,
|
||||||
Safe to call before the y/N preflight.
|
`enumerate_active_agents`, …) in `backend.selection`;
|
||||||
|
* the concrete backends and the freeze helpers in their own submodules.
|
||||||
|
|
||||||
launch(plan) -> ContextManager[Bottle]
|
`from bot_bottle.backend import X` resolves X on first access via `__getattr__`
|
||||||
Brings up the container (or VM, or remote machine), provisions
|
and caches it at package level, so existing call-sites (and
|
||||||
it, yields a Bottle handle, and tears everything down on exit.
|
`patch.object(backend_mod, X, …)`) keep working.
|
||||||
|
|
||||||
prepare_cleanup() -> BottleCleanupPlan
|
|
||||||
Enumerates orphaned resources left behind by previous bottles
|
|
||||||
(containers, networks, ...). Idempotent; no side effects.
|
|
||||||
|
|
||||||
cleanup(plan) -> None
|
|
||||||
Actually removes everything described by the cleanup plan.
|
|
||||||
|
|
||||||
enumerate_active() -> Sequence[ActiveAgent]
|
|
||||||
Return every currently-running bottle on this backend, with
|
|
||||||
enough metadata for callers (CLI `list active`, dashboard
|
|
||||||
agents pane) to render a row.
|
|
||||||
|
|
||||||
Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND
|
|
||||||
(env var). When neither is set, compatible macOS hosts default to
|
|
||||||
`macos-container`; Linux hosts with KVM default to `firecracker`;
|
|
||||||
otherwise `docker`. Per PRD 0003 the manifest does not carry a
|
|
||||||
backend field; the host picks.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from typing import TYPE_CHECKING, Any
|
||||||
import shlex
|
|
||||||
import sys
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from contextlib import AbstractContextManager, contextmanager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, Generator, Generic, Sequence, TypeVar
|
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
|
||||||
from ..egress import EgressPlan
|
|
||||||
from ..git_gate import GitGatePlan
|
|
||||||
from ..log import die, info, warn
|
|
||||||
from ..util import read_tty_line
|
|
||||||
from ..manifest import Manifest, ManifestIndex
|
|
||||||
from ..supervisor.plan import SupervisePlan
|
|
||||||
from ..util import expand_tilde
|
|
||||||
from ..env import resolve_env, ResolvedEnv
|
|
||||||
from ..workspace import WorkspacePlan, workspace_plan
|
|
||||||
from .print_util import print_multi, visible_agent_env_names
|
|
||||||
from .util import host_skill_dir
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from .base import (
|
||||||
|
ActiveAgent,
|
||||||
|
Bottle,
|
||||||
|
BottleBackend,
|
||||||
|
BottleCleanupPlan,
|
||||||
|
BottleImages,
|
||||||
|
BottlePlan,
|
||||||
|
BottleSpec,
|
||||||
|
ExecResult,
|
||||||
|
)
|
||||||
|
from .selection import (
|
||||||
|
enumerate_active_agents,
|
||||||
|
get_bottle_backend,
|
||||||
|
has_backend,
|
||||||
|
known_backend_names,
|
||||||
|
)
|
||||||
|
from .docker import DockerBottleBackend
|
||||||
|
from .firecracker import FirecrackerBottleBackend
|
||||||
|
from .macos_container import MacosContainerBottleBackend
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer
|
from .freeze import CommitCancelled, Freezer, get_freezer
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
# Public name -> submodule that defines it. Contract types resolve from `base`,
|
||||||
class BottleSpec:
|
# selection/enumeration from `selection`, the concrete backends + freeze helpers
|
||||||
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
|
# from their own subpackages.
|
||||||
step consumes it and produces its own backend-specific plan.
|
_LAZY_MODULES: dict[str, str] = {
|
||||||
Resolved values (image names, container name, scratch paths, runsc
|
"BottleSpec": "base",
|
||||||
availability) live on the plan, not the spec."""
|
"BottlePlan": "base",
|
||||||
|
"BottleCleanupPlan": "base",
|
||||||
manifest: ManifestIndex
|
"ExecResult": "base",
|
||||||
agent_name: str
|
"ActiveAgent": "base",
|
||||||
copy_cwd: bool
|
"Bottle": "base",
|
||||||
user_cwd: str
|
"BottleImages": "base",
|
||||||
# PRD 0016 follow-up: when set, the backend's prepare step uses
|
"BottleBackend": "base",
|
||||||
# this identity instead of minting a fresh one — the resume path
|
"get_bottle_backend": "selection",
|
||||||
# (`cli.py resume <identity>`) sets this to continue an existing
|
"known_backend_names": "selection",
|
||||||
# bottle's state. Empty string for a fresh `start`.
|
"has_backend": "selection",
|
||||||
identity: str = ""
|
"enumerate_active_agents": "selection",
|
||||||
label: str = ""
|
"_print_vm_install_instructions": "selection",
|
||||||
color: str = ""
|
"DockerBottleBackend": "docker",
|
||||||
# Ordered bottle names selected at launch (issue #269). When non-empty
|
"FirecrackerBottleBackend": "firecracker",
|
||||||
# they are merged in order and replace the agent's `bottle:` field.
|
"MacosContainerBottleBackend": "macos_container",
|
||||||
bottle_names: tuple[str, ...] = ()
|
"CommitCancelled": "freeze",
|
||||||
# True when launched via --headless (no TTY, no interactive prompts).
|
"Freezer": "freeze",
|
||||||
# The git-gate host-key preflight uses this to error rather than prompt.
|
"get_freezer": "freeze",
|
||||||
headless: bool = False
|
|
||||||
# Image startup policy. "fresh" preserves the normal build path;
|
|
||||||
# "cached" reuses the current local image/artifact without rebuilding.
|
|
||||||
image_policy: str = "fresh"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottlePlan(ABC):
|
|
||||||
"""Base output of a backend's prepare step. Concrete subclasses
|
|
||||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
|
||||||
|
|
||||||
spec: BottleSpec
|
|
||||||
manifest: Manifest
|
|
||||||
stage_dir: Path
|
|
||||||
git_gate_plan: GitGatePlan
|
|
||||||
|
|
||||||
@property
|
|
||||||
def guest_home(self) -> str:
|
|
||||||
return self.agent_provision.guest_home
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_host(self) -> str:
|
|
||||||
"""Host (and optional port) used in git-gate insteadOf URLs.
|
|
||||||
Docker uses the compose-network DNS alias; VM backends may
|
|
||||||
override with an IP:port when the guest has no DNS."""
|
|
||||||
return "git-gate"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def git_gate_insteadof_scheme(self) -> str:
|
|
||||||
"""URL scheme for git-gate insteadOf rewrites. 'git' for
|
|
||||||
Docker (git daemon); VM backends may override (e.g. 'http'
|
|
||||||
over a published host port)."""
|
|
||||||
return "git"
|
|
||||||
egress_plan: EgressPlan
|
|
||||||
supervise_plan: SupervisePlan | None
|
|
||||||
agent_provision: AgentProvisionPlan
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workspace_plan(self) -> WorkspacePlan:
|
|
||||||
return workspace_plan(self.spec, guest_home=self.guest_home)
|
|
||||||
|
|
||||||
def print(self) -> None:
|
|
||||||
"""Render the y/N preflight summary to stderr."""
|
|
||||||
spec = self.spec
|
|
||||||
manifest = self.manifest
|
|
||||||
agent = manifest.agent
|
|
||||||
bottle = manifest.bottle
|
|
||||||
|
|
||||||
env_names = visible_agent_env_names(
|
|
||||||
sorted(
|
|
||||||
set(bottle.env.keys())
|
|
||||||
| set(self.agent_provision.guest_env.keys())
|
|
||||||
),
|
|
||||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(file=sys.stderr)
|
|
||||||
info(f"agent : {spec.agent_name}")
|
|
||||||
info(f"provider : {self.agent_provision.template}")
|
|
||||||
print_multi("env ", env_names)
|
|
||||||
print_multi("skills ", list(agent.skills))
|
|
||||||
effective_bottles = (
|
|
||||||
list(spec.bottle_names) if spec.bottle_names
|
|
||||||
else ([agent.bottle] if agent.bottle else [])
|
|
||||||
)
|
|
||||||
print_multi("bottle ", effective_bottles)
|
|
||||||
|
|
||||||
identity = manifest.git_identity_summary()
|
|
||||||
if identity:
|
|
||||||
info(f" git identity : {identity}")
|
|
||||||
|
|
||||||
git_lines = [
|
|
||||||
f"{u.name} → {u.upstream_host}:{u.upstream_port}"
|
|
||||||
for u in self.git_gate_plan.upstreams
|
|
||||||
]
|
|
||||||
if git_lines:
|
|
||||||
print_multi(" git gate ", git_lines)
|
|
||||||
|
|
||||||
if self.egress_plan.routes:
|
|
||||||
egress_lines = []
|
|
||||||
for r in self.egress_plan.routes:
|
|
||||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
|
||||||
egress_lines.append(f"{r.host}{auth}")
|
|
||||||
print_multi(" egress ", egress_lines)
|
|
||||||
print(file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottleCleanupPlan(ABC):
|
|
||||||
"""Base output of a backend's prepare_cleanup step. Concrete
|
|
||||||
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
|
|
||||||
lists of resources to be removed and implement `print` + `empty`."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def print(self) -> None:
|
|
||||||
"""Render the cleanup y/N summary to stderr."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def empty(self) -> bool:
|
|
||||||
"""True iff there is nothing to clean up; the CLI uses this to
|
|
||||||
short-circuit before showing the y/N."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ExecResult:
|
|
||||||
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
|
|
||||||
impl populates it from a `subprocess.CompletedProcess`, but a
|
|
||||||
VM backend could populate it from any source that produces a
|
|
||||||
returncode + captured streams."""
|
|
||||||
|
|
||||||
returncode: int
|
|
||||||
stdout: str
|
|
||||||
stderr: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ActiveAgent:
|
|
||||||
"""One currently-running agent, as the CLI `list active` and
|
|
||||||
dashboard agents pane render it. ("Agent" is the project's
|
|
||||||
consistent name for the thing running inside a bottle — the
|
|
||||||
bottle is the container, the agent is what runs in it.)
|
|
||||||
|
|
||||||
Fields are deliberately backend-neutral. `services` is the set
|
|
||||||
of gateway daemons currently up for this bottle (`egress`,
|
|
||||||
`git-gate`, `supervise`); the dashboard uses it to
|
|
||||||
gate edit verbs. `backend_name` is the matching key in
|
|
||||||
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
|
|
||||||
list rendering to disambiguate and by the dashboard's
|
|
||||||
re-attach path."""
|
|
||||||
|
|
||||||
backend_name: str
|
|
||||||
slug: str
|
|
||||||
agent_name: str # from metadata.json; "?" if missing
|
|
||||||
started_at: str # ISO 8601 from metadata.json; "" if missing
|
|
||||||
services: tuple[str, ...] # alphabetical
|
|
||||||
label: str = ""
|
|
||||||
color: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class Bottle(ABC):
|
|
||||||
"""Handle to a running bottle. Yielded by a backend's launch step.
|
|
||||||
|
|
||||||
`exec_agent` runs the selected agent CLI inside the bottle and
|
|
||||||
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
|
|
||||||
and returns the captured result. `cp_in` copies a host path into
|
|
||||||
the bottle. `close` is an idempotent alias for context-manager
|
|
||||||
teardown.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def agent_argv(
|
|
||||||
self, argv: list[str], *, tty: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return the host-side argv that runs the selected agent
|
|
||||||
inside the bottle. Used by `exec_agent` for foreground
|
|
||||||
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
|
||||||
which needs the argv up front (it spawns claude in a tmux
|
|
||||||
pane rather than as a child of the current process).
|
|
||||||
|
|
||||||
Implementations transparently inject
|
|
||||||
`--append-system-prompt-file` when the bottle was launched
|
|
||||||
with a provisioned prompt path."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
"""Run `script` as a POSIX shell script inside the bottle as
|
|
||||||
`user` (default `node`, matching the agent image's USER
|
|
||||||
directive) and return the captured stdout/stderr/returncode.
|
|
||||||
The bottle's environment (including HTTPS_PROXY pointing at
|
|
||||||
the egress daemon) is inherited by the child. Non-zero
|
|
||||||
exit does not raise — callers inspect `returncode`
|
|
||||||
themselves.
|
|
||||||
|
|
||||||
Pass `user="root"` for shell-outs that need privileged file
|
|
||||||
writes / package install — provisioning calls that need root
|
|
||||||
bypass `Bottle.exec` and use the backend-specific raw
|
|
||||||
machine-exec helper, but the tests have a legitimate use
|
|
||||||
case for arbitrary-user runs."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def cp_in(self, host_path: str, container_path: str) -> None: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def close(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
PlanT = TypeVar("PlanT", bound=BottlePlan)
|
|
||||||
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BottleImages:
|
|
||||||
"""Resolved image references (or artifact paths) for a bottle launch.
|
|
||||||
|
|
||||||
For Docker/macOS-container backends, `agent` and `sidecar` are string
|
|
||||||
image refs. For the smolmachines backend they are Path objects pointing
|
|
||||||
to pre-built `.smolmachine` artifacts."""
|
|
||||||
|
|
||||||
agent: str | Path
|
|
||||||
sidecar: str | Path = ""
|
|
||||||
|
|
||||||
|
|
||||||
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|
||||||
"""Abstract base for selectable bottle backends. Concrete subclasses
|
|
||||||
(e.g. DockerBottleBackend) own their own prepare/launch impls.
|
|
||||||
Parameterized over the backend's concrete plan + cleanup-plan types
|
|
||||||
so subclass methods get the narrow type without isinstance
|
|
||||||
boilerplate."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
|
|
||||||
# Whether this backend can run a container engine *inside* the bottle.
|
|
||||||
# Backends that cannot must reject `nested_containers: true` rather than
|
|
||||||
# reach for a host daemon socket (issue #392).
|
|
||||||
supports_nested_containers: bool = False
|
|
||||||
|
|
||||||
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
|
||||||
"""Template method: run cross-backend host-side validation, then
|
|
||||||
delegate to the subclass's `_resolve_plan` for the
|
|
||||||
backend-specific resolution (names, scratch files, etc.). The
|
|
||||||
validation step is enforced here so a future backend cannot
|
|
||||||
accidentally skip it. No remote/runtime resources are created."""
|
|
||||||
from .resolve_common import (
|
|
||||||
merge_provision_env_vars,
|
|
||||||
mint_slug,
|
|
||||||
prepare_agent_state_dir,
|
|
||||||
prepare_egress,
|
|
||||||
prepare_git_gate,
|
|
||||||
prepare_supervise,
|
|
||||||
reject_nested_containers,
|
|
||||||
resolve_manifest_dockerfile,
|
|
||||||
write_launch_metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = self._validate(spec)
|
|
||||||
|
|
||||||
if not self.supports_nested_containers:
|
|
||||||
reject_nested_containers(self.name, manifest)
|
|
||||||
|
|
||||||
self._preflight()
|
|
||||||
|
|
||||||
from ..git_gate_host_key import preflight_host_keys
|
|
||||||
manifest = preflight_host_keys(
|
|
||||||
manifest,
|
|
||||||
headless=spec.headless,
|
|
||||||
home_md=spec.manifest.home_md,
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest_bottle = manifest.bottle
|
|
||||||
manifest_agent_provider = manifest_bottle.agent_provider
|
|
||||||
agent_provider = get_provider(manifest_agent_provider.template)
|
|
||||||
resolved_env = resolve_env(manifest)
|
|
||||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
|
||||||
|
|
||||||
slug = mint_slug(spec)
|
|
||||||
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
|
|
||||||
|
|
||||||
# Manifest may override the Dockerfile per-bottle; otherwise fall
|
|
||||||
# back to the provider plugin's bundled Dockerfile (next to its
|
|
||||||
# agent_provider.py module).
|
|
||||||
if manifest_agent_provider.dockerfile:
|
|
||||||
agent_dockerfile_path = resolve_manifest_dockerfile(
|
|
||||||
manifest_agent_provider.dockerfile, spec,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
|
||||||
|
|
||||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
|
||||||
|
|
||||||
agent_provision_plan = build_agent_provision_plan(
|
|
||||||
template=manifest_agent_provider.template,
|
|
||||||
dockerfile=agent_dockerfile_path,
|
|
||||||
state_dir=agent_dir,
|
|
||||||
instance_name=f"bot-bottle-{slug}",
|
|
||||||
prompt_file=prompt_file,
|
|
||||||
guest_env=self._build_guest_env(resolved_env),
|
|
||||||
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
|
|
||||||
auth_token=manifest_agent_provider.auth_token,
|
|
||||||
host_env=dict(os.environ),
|
|
||||||
trusted_project_path=workspace.workdir,
|
|
||||||
label=spec.label,
|
|
||||||
color=spec.color,
|
|
||||||
provider_settings=manifest_agent_provider.settings,
|
|
||||||
)
|
|
||||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
|
||||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
|
||||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
|
||||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
|
||||||
|
|
||||||
return self._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 _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
"""
|
|
||||||
tasks to do before resolving a plan
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> Manifest:
|
|
||||||
"""Cross-backend pre-launch checks. Parses the selected agent and
|
|
||||||
its bottle (raising ManifestError on invalid content), confirms
|
|
||||||
skills are present on the host, and every git IdentityFile resolves.
|
|
||||||
|
|
||||||
Returns the loaded Manifest for the selected agent. Subclasses with
|
|
||||||
additional preconditions should override and call
|
|
||||||
`super()._validate(spec)` first."""
|
|
||||||
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
|
|
||||||
self._validate_skills(manifest.agent.skills)
|
|
||||||
self._validate_agent_provider_dockerfile(spec, manifest)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
|
||||||
"""Each named skill must be a directory under the host's
|
|
||||||
`~/.claude/skills/`. The check is purely host-side, so the
|
|
||||||
default impl covers every backend."""
|
|
||||||
for name in skills:
|
|
||||||
path = host_skill_dir(name)
|
|
||||||
if not os.path.isdir(path):
|
|
||||||
die(
|
|
||||||
f"skill '{name}' not found on host at {path}. "
|
|
||||||
f"Create it under ~/.claude/skills/, then re-run."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
|
||||||
bottle = manifest.bottle
|
|
||||||
dockerfile = bottle.agent_provider.dockerfile
|
|
||||||
if not dockerfile:
|
|
||||||
return
|
|
||||||
path = Path(expand_tilde(dockerfile))
|
|
||||||
if not path.is_absolute():
|
|
||||||
path = Path(spec.user_cwd) / path
|
|
||||||
if not path.is_file():
|
|
||||||
effective = (
|
|
||||||
", ".join(spec.bottle_names) if spec.bottle_names else manifest.agent.bottle
|
|
||||||
)
|
|
||||||
die(
|
|
||||||
f"agent_provider.dockerfile for bottle "
|
|
||||||
f"'{effective}' not found: {path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
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) -> PlanT:
|
|
||||||
"""Backend-specific plan resolution: image/container names,
|
|
||||||
env-file, prompt-file, proxy plan, runtime detection. Called by
|
|
||||||
`prepare` after `_validate` succeeds. Instance name, image,
|
|
||||||
prompt file, Dockerfile path, and guest home all live on
|
|
||||||
`agent_provision_plan` — the source of truth."""
|
|
||||||
|
|
||||||
def prelaunch_checks(self, plan: PlanT) -> None:
|
|
||||||
"""Raise StaleImageError if any cached image used by this plan is stale.
|
|
||||||
No-op default; backends override to call the shared check_stale*
|
|
||||||
helpers on their image/artifact timestamps. Called by the CLI before
|
|
||||||
launch so the operator can be prompted outside the launch context."""
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(self, plan: PlanT) -> Generator[Bottle, None, None]:
|
|
||||||
"""Template: build or load images, then delegate to _launch_impl."""
|
|
||||||
images = self._build_or_load_images(plan)
|
|
||||||
with self._launch_impl(plan, images) as bottle:
|
|
||||||
yield bottle
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _build_or_load_images(self, plan: PlanT) -> BottleImages:
|
|
||||||
"""Return the agent and sidecar image references (or artifact paths)
|
|
||||||
for this plan, building fresh images when the policy requires it."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _launch_impl(self, plan: PlanT, images: BottleImages) -> AbstractContextManager[Bottle]:
|
|
||||||
"""Bring up the bottle using pre-resolved images; yield a handle; tear down on exit."""
|
|
||||||
|
|
||||||
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
|
||||||
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
|
||||||
the running bottle. Called from `launch` after the container
|
|
||||||
/ machine is up. Returns the in-container prompt path if a
|
|
||||||
prompt was provisioned, else None — the Bottle handle uses it
|
|
||||||
to decide whether to add provider-specific prompt args to the
|
|
||||||
agent's argv.
|
|
||||||
|
|
||||||
Default orchestration: ca → prompt → provider apply → skills
|
|
||||||
→ workspace → git → supervise-mcp. CA install runs first so
|
|
||||||
the agent's trust store is rebuilt before anything inside the
|
|
||||||
agent makes a TLS call.
|
|
||||||
|
|
||||||
Per PRD 0050 the per-provider steps (prompt, skills,
|
|
||||||
declarative provision-plan apply, supervise MCP registration)
|
|
||||||
live on the `AgentProvider` plugin. The backend only owns the
|
|
||||||
steps that are about backend infrastructure (CA, workspace,
|
|
||||||
git) and surfaces the supervise daemon URL its launch step
|
|
||||||
knows about via `supervise_mcp_url`.
|
|
||||||
|
|
||||||
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
|
||||||
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
|
||||||
on the agent's HTTP_PROXY path so every tool that respects
|
|
||||||
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
|
||||||
intercepted without per-tool reconfiguration."""
|
|
||||||
provider = get_provider(plan.agent_provision.template)
|
|
||||||
provider.provision_ca(bottle, plan)
|
|
||||||
prompt_path = provider.provision_prompt(plan, bottle)
|
|
||||||
provider.provision(plan, bottle)
|
|
||||||
provider.provision_skills(plan, bottle)
|
|
||||||
self.provision_workspace(plan, bottle)
|
|
||||||
provider.provision_git(bottle, plan)
|
|
||||||
provider.provision_supervise_mcp(
|
|
||||||
plan, bottle, self.supervise_mcp_url(plan),
|
|
||||||
)
|
|
||||||
return prompt_path
|
|
||||||
|
|
||||||
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
|
|
||||||
"""Copy the operator workspace into the running bottle.
|
|
||||||
|
|
||||||
This is the only supported workspace-provisioning path: Docker
|
|
||||||
does not build a derived image containing the current
|
|
||||||
workspace."""
|
|
||||||
workspace = plan.workspace_plan
|
|
||||||
if not (workspace.enabled and workspace.copy_contents):
|
|
||||||
return
|
|
||||||
|
|
||||||
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
|
|
||||||
guest_path = shlex.quote(workspace.guest_path)
|
|
||||||
guest_parent = shlex.quote(guest_parent)
|
|
||||||
owner = shlex.quote(workspace.owner)
|
|
||||||
mode = shlex.quote(workspace.mode)
|
|
||||||
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
|
||||||
bottle.exec(
|
|
||||||
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
|
||||||
bottle.exec(
|
|
||||||
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
|
|
||||||
user="root",
|
|
||||||
)
|
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: PlanT) -> str:
|
|
||||||
"""Return the agent-side URL of the per-bottle supervise
|
|
||||||
gateway, or "" when this bottle has no gateway. The provider
|
|
||||||
plugin's `provision_supervise_mcp` uses it to register the
|
|
||||||
MCP entry inside the guest.
|
|
||||||
|
|
||||||
Default returns "" so backends without supervise support
|
|
||||||
don't have to implement it. Docker and firecracker override."""
|
|
||||||
del plan
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def ensure_orchestrator(self) -> str:
|
|
||||||
"""Bring up this backend's per-host orchestrator + shared gateway
|
|
||||||
(idempotent) and return the host-reachable control-plane URL.
|
|
||||||
|
|
||||||
This is the backend-agnostic bring-up entry point: `launch` calls
|
|
||||||
it as part of starting a bottle, and operator tools (`supervise`)
|
|
||||||
call it to start the control plane on demand when none is running
|
|
||||||
yet. Docker starts the orchestrator + gateway containers;
|
|
||||||
firecracker boots the infra VM. Backends with no orchestrator
|
|
||||||
(macos-container) die with a pointer — the default here."""
|
|
||||||
die(f"backend {self.name!r} has no orchestrator control plane")
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
|
||||||
"""Enumerate orphaned resources from previous bottles. No side
|
|
||||||
effects; safe to call before the y/N."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def cleanup(self, plan: CleanupT) -> None:
|
|
||||||
"""Remove everything described by the cleanup plan."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
||||||
"""Return every currently-running agent on this backend.
|
|
||||||
Empty when none. Backend-specific: docker queries `docker
|
|
||||||
compose ls`; firecracker cross-references its running gateway
|
|
||||||
containers against per-bottle metadata."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
"""Whether this backend's runtime prerequisites are satisfied
|
|
||||||
on the current host. Docker → `docker` on PATH; firecracker →
|
|
||||||
Linux + KVM. Used by the cross-backend
|
|
||||||
`enumerate_active_agents` / `cmd_cleanup` to skip backends
|
|
||||||
the operator hasn't installed, so a docker-only host
|
|
||||||
doesn't fail when `cli.py list active` walks past
|
|
||||||
firecracker."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def setup(cls) -> int:
|
|
||||||
"""Emit this backend's one-time host setup — privileged network
|
|
||||||
pool, daemon bring-up, install pointers, etc. — as
|
|
||||||
host-appropriate config or commands. Prints to stdout/stderr and
|
|
||||||
returns a shell exit code (0 = nothing to report / success). A
|
|
||||||
backend that needs no host setup prints a short note and returns
|
|
||||||
0. Invoked generically by `./cli.py backend setup [--backend=…]`
|
|
||||||
so operators can provision any backend without a
|
|
||||||
backend-specific command. Classmethod (like `is_available`) —
|
|
||||||
it's a host query, not per-bottle state."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def status(cls) -> int:
|
|
||||||
"""Report whether this backend's prerequisites are satisfied on
|
|
||||||
the host — binaries, daemon reachability, network pool, range
|
|
||||||
conflicts, etc. Prints a human-readable summary; returns 0 when
|
|
||||||
the backend is ready to launch and non-zero when something is
|
|
||||||
missing. Invoked by `./cli.py backend status [--backend=…]`."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def teardown(cls) -> int:
|
|
||||||
"""Undo `setup()` — the inverse operation, surfaced as
|
|
||||||
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
|
|
||||||
with setup: where setup is advisory (prints the privileged
|
|
||||||
commands / declarative config to apply), teardown prints the
|
|
||||||
commands / config change to remove the host prerequisites. A
|
|
||||||
backend with no host setup prints a short note and returns 0.
|
|
||||||
Not called by the launch path or the test suite."""
|
|
||||||
|
|
||||||
|
|
||||||
# _backends is None until the first call to _get_backends(), at which
|
|
||||||
# point all three concrete backend classes are imported and instantiated.
|
|
||||||
# Keeping the imports out of module scope means that importing any
|
|
||||||
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
|
|
||||||
# firecracker and macos-container implementations into memory.
|
|
||||||
#
|
|
||||||
# Tests may replace _backends with a {name: fake} dict via patch.object;
|
|
||||||
# _get_backends() returns the current module-level value as-is when it
|
|
||||||
# is not None, so test fakes take effect without triggering real imports.
|
|
||||||
_backends: dict[str, BottleBackend[Any, Any]] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
|
|
||||||
"""Return the registry of all backend instances, loading lazily on first call."""
|
|
||||||
global _backends # pylint: disable=global-statement
|
|
||||||
if _backends is None:
|
|
||||||
from .docker import DockerBottleBackend
|
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
from .macos_container import MacosContainerBottleBackend
|
|
||||||
_backends = {
|
|
||||||
"docker": DockerBottleBackend(),
|
|
||||||
"firecracker": FirecrackerBottleBackend(),
|
|
||||||
"macos-container": MacosContainerBottleBackend(),
|
|
||||||
}
|
}
|
||||||
return _backends
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> Any:
|
def __getattr__(name: str) -> Any:
|
||||||
"""Lazily surface concrete backend classes and freeze symbols at the
|
"""Lazily surface the package's public names from their submodules and
|
||||||
package level so existing `from bot_bottle.backend import X` and
|
cache them at package level — so `from bot_bottle.backend import X` and
|
||||||
`patch.object(backend_mod, X, ...)` call-sites keep working without
|
`patch.object(backend_mod, X, …)` keep working without importing the
|
||||||
forcing an import of every backend at module-init time."""
|
framework (or every backend) at package-init time."""
|
||||||
if name == "DockerBottleBackend":
|
mod = _LAZY_MODULES.get(name)
|
||||||
from .docker import DockerBottleBackend
|
if mod is None:
|
||||||
globals()[name] = DockerBottleBackend
|
|
||||||
return DockerBottleBackend
|
|
||||||
if name == "FirecrackerBottleBackend":
|
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
globals()[name] = FirecrackerBottleBackend
|
|
||||||
return FirecrackerBottleBackend
|
|
||||||
if name == "MacosContainerBottleBackend":
|
|
||||||
from .macos_container import MacosContainerBottleBackend
|
|
||||||
globals()[name] = MacosContainerBottleBackend
|
|
||||||
return MacosContainerBottleBackend
|
|
||||||
if name == "CommitCancelled":
|
|
||||||
from .freeze import CommitCancelled
|
|
||||||
globals()[name] = CommitCancelled
|
|
||||||
return CommitCancelled
|
|
||||||
if name == "Freezer":
|
|
||||||
from .freeze import Freezer
|
|
||||||
globals()[name] = Freezer
|
|
||||||
return Freezer
|
|
||||||
if name == "get_freezer":
|
|
||||||
from .freeze import get_freezer
|
|
||||||
globals()[name] = get_freezer
|
|
||||||
return get_freezer
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
value = getattr(import_module(f"{__name__}.{mod}"), name)
|
||||||
def get_bottle_backend(
|
globals()[name] = value
|
||||||
name: str | None = None,
|
return value
|
||||||
*,
|
|
||||||
prompt: bool = True,
|
|
||||||
) -> BottleBackend[Any, Any]:
|
|
||||||
"""Resolve the bottle backend.
|
|
||||||
|
|
||||||
`name` precedence:
|
|
||||||
1. explicit arg (e.g. resume passes the recorded backend name)
|
|
||||||
2. BOT_BOTTLE_BACKEND env var
|
|
||||||
3. auto-selection: VM backend first, docker fallback with prompt
|
|
||||||
|
|
||||||
`prompt` controls whether auto-selection may block on an interactive
|
|
||||||
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
|
|
||||||
non-interactive contexts (headless launches, CI) so the call dies
|
|
||||||
with an actionable message instead of hanging.
|
|
||||||
|
|
||||||
Dies with a pointer at the known backends if the chosen name
|
|
||||||
isn't implemented."""
|
|
||||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
|
|
||||||
if resolved is None:
|
|
||||||
resolved = _auto_select_backend(prompt=prompt)
|
|
||||||
backends = _get_backends()
|
|
||||||
if resolved not in backends:
|
|
||||||
known = ", ".join(sorted(backends))
|
|
||||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
|
||||||
return backends[resolved]
|
|
||||||
|
|
||||||
|
|
||||||
def _platform_vm_suggestion() -> str:
|
|
||||||
"""Platform-appropriate VM backend name for install suggestions."""
|
|
||||||
return "macos-container" if sys.platform == "darwin" else "firecracker"
|
|
||||||
|
|
||||||
|
|
||||||
def _print_vm_install_instructions() -> None:
|
|
||||||
"""Print platform-appropriate VM backend install instructions to stderr."""
|
|
||||||
vm = _platform_vm_suggestion()
|
|
||||||
if vm == "macos-container":
|
|
||||||
info("Install Apple Container: https://github.com/apple/container/releases")
|
|
||||||
info("Then start the service: container system start")
|
|
||||||
else:
|
|
||||||
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
|
|
||||||
info("Configure the host: ./cli.py backend setup")
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_select_backend(prompt: bool = True) -> str:
|
|
||||||
"""Tier-1 / tier-2 backend auto-selection.
|
|
||||||
|
|
||||||
Tier 1: VM backend — macos-container on macOS when Apple Container is
|
|
||||||
installed; firecracker on KVM-capable Linux even before the binary is
|
|
||||||
present (its preflight prints an install pointer).
|
|
||||||
|
|
||||||
Tier 2: docker, with a security warning and an interactive prompt.
|
|
||||||
When `prompt=False` (headless / CI), dies with an actionable message
|
|
||||||
instead of blocking on a TTY read. When docker is also absent, prints
|
|
||||||
VM install instructions and exits.
|
|
||||||
"""
|
|
||||||
# --- Tier 1: VM backend -----------------------------------------
|
|
||||||
if has_backend("macos-container"):
|
|
||||||
return "macos-container"
|
|
||||||
# A KVM-capable Linux host defaults to firecracker even when the
|
|
||||||
# `firecracker` binary isn't installed yet: selecting it here routes
|
|
||||||
# start through firecracker's preflight, which prints an install
|
|
||||||
# pointer, instead of silently falling back to docker.
|
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
if FirecrackerBottleBackend.is_host_capable():
|
|
||||||
return "firecracker"
|
|
||||||
|
|
||||||
# --- Tier 2: docker fallback ------------------------------------
|
|
||||||
if not has_backend("docker"):
|
|
||||||
info("No backend available on this host.")
|
|
||||||
_print_vm_install_instructions()
|
|
||||||
die("no backend available; install a VM backend and re-run")
|
|
||||||
|
|
||||||
vm = _platform_vm_suggestion()
|
|
||||||
warn(
|
|
||||||
"docker is less secure than VM backends — "
|
|
||||||
"containers share the host kernel."
|
|
||||||
)
|
|
||||||
if not prompt:
|
|
||||||
die(
|
|
||||||
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
|
|
||||||
f"with docker, or install the {vm!r} backend."
|
|
||||||
)
|
|
||||||
sys.stderr.write(
|
|
||||||
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
|
|
||||||
f" [i] show {vm} install instructions and exit\n"
|
|
||||||
" [d] use docker anyway\n"
|
|
||||||
" [q] quit\n"
|
|
||||||
"bot-bottle: choice [i/d/q]: "
|
|
||||||
)
|
|
||||||
sys.stderr.flush()
|
|
||||||
reply = read_tty_line().strip().lower()
|
|
||||||
if reply == "d":
|
|
||||||
return "docker"
|
|
||||||
if reply == "i":
|
|
||||||
_print_vm_install_instructions()
|
|
||||||
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
|
|
||||||
|
|
||||||
|
|
||||||
def known_backend_names() -> tuple[str, ...]:
|
|
||||||
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
|
|
||||||
argparse (`--backend` choices) and the dashboard's backend
|
|
||||||
picker."""
|
|
||||||
return tuple(sorted(_get_backends()))
|
|
||||||
|
|
||||||
|
|
||||||
def has_backend(name: str) -> bool:
|
|
||||||
"""Whether the named backend's runtime prerequisites are
|
|
||||||
available on the current host. Cross-backend callers (list,
|
|
||||||
cleanup) skip unavailable backends so a docker-only host
|
|
||||||
doesn't fail when the firecracker backend isn't usable,
|
|
||||||
and vice versa.
|
|
||||||
|
|
||||||
Returns False for unknown names so callers can pass
|
|
||||||
arbitrary input without separate validation."""
|
|
||||||
backends = _get_backends()
|
|
||||||
if name not in backends:
|
|
||||||
return False
|
|
||||||
return backends[name].is_available()
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active_agents() -> list[ActiveAgent]:
|
|
||||||
"""All currently-running agents, across every available
|
|
||||||
backend. Used by CLI `list active` and the dashboard's agents
|
|
||||||
pane so neither has to know which backends exist. Skips
|
|
||||||
backends whose `is_available()` reports False.
|
|
||||||
|
|
||||||
Sorted by `(started_at, slug)` so the list is stable across
|
|
||||||
dashboard refresh ticks — agents don't shift position while
|
|
||||||
the operator navigates with arrow keys. ISO 8601 timestamps
|
|
||||||
sort lexicographically in chronological order; `slug` is the
|
|
||||||
deterministic tiebreaker. Agents with missing metadata
|
|
||||||
(`started_at == ""`) sort first."""
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
backends = _get_backends()
|
|
||||||
for name in sorted(backends):
|
|
||||||
if not backends[name].is_available():
|
|
||||||
continue
|
|
||||||
out.extend(backends[name].enumerate_active())
|
|
||||||
out.sort(key=lambda a: (a.started_at, a.slug))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -838,14 +89,18 @@ __all__ = [
|
|||||||
"Bottle",
|
"Bottle",
|
||||||
"BottleBackend",
|
"BottleBackend",
|
||||||
"BottleCleanupPlan",
|
"BottleCleanupPlan",
|
||||||
|
"BottleImages",
|
||||||
"BottlePlan",
|
"BottlePlan",
|
||||||
"BottleSpec",
|
"BottleSpec",
|
||||||
"CommitCancelled",
|
|
||||||
"ExecResult",
|
"ExecResult",
|
||||||
|
"CommitCancelled",
|
||||||
"Freezer",
|
"Freezer",
|
||||||
|
"get_freezer",
|
||||||
|
"DockerBottleBackend",
|
||||||
|
"FirecrackerBottleBackend",
|
||||||
|
"MacosContainerBottleBackend",
|
||||||
"enumerate_active_agents",
|
"enumerate_active_agents",
|
||||||
"get_bottle_backend",
|
"get_bottle_backend",
|
||||||
"get_freezer",
|
|
||||||
"has_backend",
|
"has_backend",
|
||||||
"known_backend_names",
|
"known_backend_names",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
"""The abstract backend contract (PRD 0018 / 0070).
|
||||||
|
|
||||||
|
The backend-neutral types every bottle backend implements: the launch
|
||||||
|
`BottleSpec`, the `BottlePlan` / `BottleCleanupPlan` ABCs, the running-`Bottle`
|
||||||
|
+ `ExecResult` shapes, `BottleImages`, and the `BottleBackend` ABC itself.
|
||||||
|
|
||||||
|
This carries the framework imports (manifest, egress, git-gate, env, workspace,
|
||||||
|
agent-provider) the contract's signatures and helpers need — which is why it
|
||||||
|
lives here rather than in `backend/__init__.py`: touching an unrelated
|
||||||
|
`backend.*` module then doesn't drag the whole framework into memory. The
|
||||||
|
thin package `__init__` re-exports these names lazily.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from contextlib import AbstractContextManager, contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
|
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||||
|
from ..egress import EgressPlan
|
||||||
|
from ..git_gate import GitGatePlan
|
||||||
|
from ..log import die, info
|
||||||
|
from ..util import expand_tilde
|
||||||
|
from ..manifest import Manifest, ManifestIndex
|
||||||
|
from ..supervisor.plan import SupervisePlan
|
||||||
|
from ..env import resolve_env, ResolvedEnv
|
||||||
|
from ..workspace import WorkspacePlan, workspace_plan
|
||||||
|
from .print_util import print_multi, visible_agent_env_names
|
||||||
|
from .util import host_skill_dir
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BottleSpec:
|
||||||
|
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
|
||||||
|
step consumes it and produces its own backend-specific plan.
|
||||||
|
Resolved values (image names, container name, scratch paths, runsc
|
||||||
|
availability) live on the plan, not the spec."""
|
||||||
|
|
||||||
|
manifest: ManifestIndex
|
||||||
|
agent_name: str
|
||||||
|
copy_cwd: bool
|
||||||
|
user_cwd: str
|
||||||
|
# PRD 0016 follow-up: when set, the backend's prepare step uses
|
||||||
|
# this identity instead of minting a fresh one — the resume path
|
||||||
|
# (`cli.py resume <identity>`) sets this to continue an existing
|
||||||
|
# bottle's state. Empty string for a fresh `start`.
|
||||||
|
identity: str = ""
|
||||||
|
label: str = ""
|
||||||
|
color: str = ""
|
||||||
|
# Ordered bottle names selected at launch (issue #269). When non-empty
|
||||||
|
# they are merged in order and replace the agent's `bottle:` field.
|
||||||
|
bottle_names: tuple[str, ...] = ()
|
||||||
|
# True when launched via --headless (no TTY, no interactive prompts).
|
||||||
|
# The git-gate host-key preflight uses this to error rather than prompt.
|
||||||
|
headless: bool = False
|
||||||
|
# Image startup policy. "fresh" preserves the normal build path;
|
||||||
|
# "cached" reuses the current local image/artifact without rebuilding.
|
||||||
|
image_policy: str = "fresh"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BottlePlan(ABC):
|
||||||
|
"""Base output of a backend's prepare step. Concrete subclasses
|
||||||
|
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||||
|
|
||||||
|
spec: BottleSpec
|
||||||
|
manifest: Manifest
|
||||||
|
stage_dir: Path
|
||||||
|
git_gate_plan: GitGatePlan
|
||||||
|
|
||||||
|
@property
|
||||||
|
def guest_home(self) -> str:
|
||||||
|
return self.agent_provision.guest_home
|
||||||
|
|
||||||
|
@property
|
||||||
|
def git_gate_insteadof_host(self) -> str:
|
||||||
|
"""Host (and optional port) used in git-gate insteadOf URLs.
|
||||||
|
Docker uses the compose-network DNS alias; VM backends may
|
||||||
|
override with an IP:port when the guest has no DNS."""
|
||||||
|
return "git-gate"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def git_gate_insteadof_scheme(self) -> str:
|
||||||
|
"""URL scheme for git-gate insteadOf rewrites. 'git' for
|
||||||
|
Docker (git daemon); VM backends may override (e.g. 'http'
|
||||||
|
over a published host port)."""
|
||||||
|
return "git"
|
||||||
|
egress_plan: EgressPlan
|
||||||
|
supervise_plan: SupervisePlan | None
|
||||||
|
agent_provision: AgentProvisionPlan
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workspace_plan(self) -> WorkspacePlan:
|
||||||
|
return workspace_plan(self.spec, guest_home=self.guest_home)
|
||||||
|
|
||||||
|
def print(self) -> None:
|
||||||
|
"""Render the y/N preflight summary to stderr."""
|
||||||
|
spec = self.spec
|
||||||
|
manifest = self.manifest
|
||||||
|
agent = manifest.agent
|
||||||
|
bottle = manifest.bottle
|
||||||
|
|
||||||
|
env_names = visible_agent_env_names(
|
||||||
|
sorted(
|
||||||
|
set(bottle.env.keys())
|
||||||
|
| set(self.agent_provision.guest_env.keys())
|
||||||
|
),
|
||||||
|
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(file=sys.stderr)
|
||||||
|
info(f"agent : {spec.agent_name}")
|
||||||
|
info(f"provider : {self.agent_provision.template}")
|
||||||
|
print_multi("env ", env_names)
|
||||||
|
print_multi("skills ", list(agent.skills))
|
||||||
|
effective_bottles = (
|
||||||
|
list(spec.bottle_names) if spec.bottle_names
|
||||||
|
else ([agent.bottle] if agent.bottle else [])
|
||||||
|
)
|
||||||
|
print_multi("bottle ", effective_bottles)
|
||||||
|
|
||||||
|
identity = manifest.git_identity_summary()
|
||||||
|
if identity:
|
||||||
|
info(f" git identity : {identity}")
|
||||||
|
|
||||||
|
git_lines = [
|
||||||
|
f"{u.name} → {u.upstream_host}:{u.upstream_port}"
|
||||||
|
for u in self.git_gate_plan.upstreams
|
||||||
|
]
|
||||||
|
if git_lines:
|
||||||
|
print_multi(" git gate ", git_lines)
|
||||||
|
|
||||||
|
if self.egress_plan.routes:
|
||||||
|
egress_lines = []
|
||||||
|
for r in self.egress_plan.routes:
|
||||||
|
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||||
|
egress_lines.append(f"{r.host}{auth}")
|
||||||
|
print_multi(" egress ", egress_lines)
|
||||||
|
print(file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BottleCleanupPlan(ABC):
|
||||||
|
"""Base output of a backend's prepare_cleanup step. Concrete
|
||||||
|
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
|
||||||
|
lists of resources to be removed and implement `print` + `empty`."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def print(self) -> None:
|
||||||
|
"""Render the cleanup y/N summary to stderr."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def empty(self) -> bool:
|
||||||
|
"""True iff there is nothing to clean up; the CLI uses this to
|
||||||
|
short-circuit before showing the y/N."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecResult:
|
||||||
|
"""Captured result of `Bottle.exec`. Backend-neutral: the Docker
|
||||||
|
impl populates it from a `subprocess.CompletedProcess`, but a
|
||||||
|
VM backend could populate it from any source that produces a
|
||||||
|
returncode + captured streams."""
|
||||||
|
|
||||||
|
returncode: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActiveAgent:
|
||||||
|
"""One currently-running agent, as the CLI `list active` and
|
||||||
|
dashboard agents pane render it. ("Agent" is the project's
|
||||||
|
consistent name for the thing running inside a bottle — the
|
||||||
|
bottle is the container, the agent is what runs in it.)
|
||||||
|
|
||||||
|
Fields are deliberately backend-neutral. `services` is the set
|
||||||
|
of gateway daemons currently up for this bottle (`egress`,
|
||||||
|
`git-gate`, `supervise`); the dashboard uses it to
|
||||||
|
gate edit verbs. `backend_name` is the matching key in
|
||||||
|
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
|
||||||
|
list rendering to disambiguate and by the dashboard's
|
||||||
|
re-attach path."""
|
||||||
|
|
||||||
|
backend_name: str
|
||||||
|
slug: str
|
||||||
|
agent_name: str # from metadata.json; "?" if missing
|
||||||
|
started_at: str # ISO 8601 from metadata.json; "" if missing
|
||||||
|
services: tuple[str, ...] # alphabetical
|
||||||
|
label: str = ""
|
||||||
|
color: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class Bottle(ABC):
|
||||||
|
"""Handle to a running bottle. Yielded by a backend's launch step.
|
||||||
|
|
||||||
|
`exec_agent` runs the selected agent CLI inside the bottle and
|
||||||
|
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
|
||||||
|
and returns the captured result. `cp_in` copies a host path into
|
||||||
|
the bottle. `close` is an idempotent alias for context-manager
|
||||||
|
teardown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def agent_argv(
|
||||||
|
self, argv: list[str], *, tty: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the host-side argv that runs the selected agent
|
||||||
|
inside the bottle. Used by `exec_agent` for foreground
|
||||||
|
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
||||||
|
which needs the argv up front (it spawns claude in a tmux
|
||||||
|
pane rather than as a child of the current process).
|
||||||
|
|
||||||
|
Implementations transparently inject
|
||||||
|
`--append-system-prompt-file` when the bottle was launched
|
||||||
|
with a provisioned prompt path."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
|
"""Run `script` as a POSIX shell script inside the bottle as
|
||||||
|
`user` (default `node`, matching the agent image's USER
|
||||||
|
directive) and return the captured stdout/stderr/returncode.
|
||||||
|
The bottle's environment (including HTTPS_PROXY pointing at
|
||||||
|
the egress daemon) is inherited by the child. Non-zero
|
||||||
|
exit does not raise — callers inspect `returncode`
|
||||||
|
themselves.
|
||||||
|
|
||||||
|
Pass `user="root"` for shell-outs that need privileged file
|
||||||
|
writes / package install — provisioning calls that need root
|
||||||
|
bypass `Bottle.exec` and use the backend-specific raw
|
||||||
|
machine-exec helper, but the tests have a legitimate use
|
||||||
|
case for arbitrary-user runs."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def cp_in(self, host_path: str, container_path: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
PlanT = TypeVar("PlanT", bound=BottlePlan)
|
||||||
|
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BottleImages:
|
||||||
|
"""Resolved image references (or artifact paths) for a bottle launch.
|
||||||
|
|
||||||
|
For Docker/macOS-container backends, `agent` and `sidecar` are string
|
||||||
|
image refs. For the smolmachines backend they are Path objects pointing
|
||||||
|
to pre-built `.smolmachine` artifacts."""
|
||||||
|
|
||||||
|
agent: str | Path
|
||||||
|
sidecar: str | Path = ""
|
||||||
|
|
||||||
|
|
||||||
|
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||||
|
"""Abstract base for selectable bottle backends. Concrete subclasses
|
||||||
|
(e.g. DockerBottleBackend) own their own prepare/launch impls.
|
||||||
|
Parameterized over the backend's concrete plan + cleanup-plan types
|
||||||
|
so subclass methods get the narrow type without isinstance
|
||||||
|
boilerplate."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
# Whether this backend can run a container engine *inside* the bottle.
|
||||||
|
# Backends that cannot must reject `nested_containers: true` rather than
|
||||||
|
# reach for a host daemon socket (issue #392).
|
||||||
|
supports_nested_containers: bool = False
|
||||||
|
|
||||||
|
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
||||||
|
"""Template method: run cross-backend host-side validation, then
|
||||||
|
delegate to the subclass's `_resolve_plan` for the
|
||||||
|
backend-specific resolution (names, scratch files, etc.). The
|
||||||
|
validation step is enforced here so a future backend cannot
|
||||||
|
accidentally skip it. No remote/runtime resources are created."""
|
||||||
|
from .resolve_common import (
|
||||||
|
merge_provision_env_vars,
|
||||||
|
mint_slug,
|
||||||
|
prepare_agent_state_dir,
|
||||||
|
prepare_egress,
|
||||||
|
prepare_git_gate,
|
||||||
|
prepare_supervise,
|
||||||
|
reject_nested_containers,
|
||||||
|
resolve_manifest_dockerfile,
|
||||||
|
write_launch_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = self._validate(spec)
|
||||||
|
|
||||||
|
if not self.supports_nested_containers:
|
||||||
|
reject_nested_containers(self.name, manifest)
|
||||||
|
|
||||||
|
self._preflight()
|
||||||
|
|
||||||
|
from ..git_gate_host_key import preflight_host_keys
|
||||||
|
manifest = preflight_host_keys(
|
||||||
|
manifest,
|
||||||
|
headless=spec.headless,
|
||||||
|
home_md=spec.manifest.home_md,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest_bottle = manifest.bottle
|
||||||
|
manifest_agent_provider = manifest_bottle.agent_provider
|
||||||
|
agent_provider = get_provider(manifest_agent_provider.template)
|
||||||
|
resolved_env = resolve_env(manifest)
|
||||||
|
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||||
|
|
||||||
|
slug = mint_slug(spec)
|
||||||
|
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
|
||||||
|
|
||||||
|
# Manifest may override the Dockerfile per-bottle; otherwise fall
|
||||||
|
# back to the provider plugin's bundled Dockerfile (next to its
|
||||||
|
# agent_provider.py module).
|
||||||
|
if manifest_agent_provider.dockerfile:
|
||||||
|
agent_dockerfile_path = resolve_manifest_dockerfile(
|
||||||
|
manifest_agent_provider.dockerfile, spec,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||||
|
|
||||||
|
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||||
|
|
||||||
|
agent_provision_plan = build_agent_provision_plan(
|
||||||
|
template=manifest_agent_provider.template,
|
||||||
|
dockerfile=agent_dockerfile_path,
|
||||||
|
state_dir=agent_dir,
|
||||||
|
instance_name=f"bot-bottle-{slug}",
|
||||||
|
prompt_file=prompt_file,
|
||||||
|
guest_env=self._build_guest_env(resolved_env),
|
||||||
|
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
|
||||||
|
auth_token=manifest_agent_provider.auth_token,
|
||||||
|
host_env=dict(os.environ),
|
||||||
|
trusted_project_path=workspace.workdir,
|
||||||
|
label=spec.label,
|
||||||
|
color=spec.color,
|
||||||
|
provider_settings=manifest_agent_provider.settings,
|
||||||
|
)
|
||||||
|
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
||||||
|
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
||||||
|
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
||||||
|
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||||
|
|
||||||
|
return self._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 _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _preflight(self) -> None:
|
||||||
|
"""
|
||||||
|
tasks to do before resolving a plan
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _validate(self, spec: BottleSpec) -> Manifest:
|
||||||
|
"""Cross-backend pre-launch checks. Parses the selected agent and
|
||||||
|
its bottle (raising ManifestError on invalid content), confirms
|
||||||
|
skills are present on the host, and every git IdentityFile resolves.
|
||||||
|
|
||||||
|
Returns the loaded Manifest for the selected agent. Subclasses with
|
||||||
|
additional preconditions should override and call
|
||||||
|
`super()._validate(spec)` first."""
|
||||||
|
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
|
||||||
|
self._validate_skills(manifest.agent.skills)
|
||||||
|
self._validate_agent_provider_dockerfile(spec, manifest)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||||
|
"""Each named skill must be a directory under the host's
|
||||||
|
`~/.claude/skills/`. The check is purely host-side, so the
|
||||||
|
default impl covers every backend."""
|
||||||
|
for name in skills:
|
||||||
|
path = host_skill_dir(name)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
die(
|
||||||
|
f"skill '{name}' not found on host at {path}. "
|
||||||
|
f"Create it under ~/.claude/skills/, then re-run."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
||||||
|
bottle = manifest.bottle
|
||||||
|
dockerfile = bottle.agent_provider.dockerfile
|
||||||
|
if not dockerfile:
|
||||||
|
return
|
||||||
|
path = Path(expand_tilde(dockerfile))
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = Path(spec.user_cwd) / path
|
||||||
|
if not path.is_file():
|
||||||
|
effective = (
|
||||||
|
", ".join(spec.bottle_names) if spec.bottle_names else manifest.agent.bottle
|
||||||
|
)
|
||||||
|
die(
|
||||||
|
f"agent_provider.dockerfile for bottle "
|
||||||
|
f"'{effective}' not found: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
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) -> PlanT:
|
||||||
|
"""Backend-specific plan resolution: image/container names,
|
||||||
|
env-file, prompt-file, proxy plan, runtime detection. Called by
|
||||||
|
`prepare` after `_validate` succeeds. Instance name, image,
|
||||||
|
prompt file, Dockerfile path, and guest home all live on
|
||||||
|
`agent_provision_plan` — the source of truth."""
|
||||||
|
|
||||||
|
def prelaunch_checks(self, plan: PlanT) -> None:
|
||||||
|
"""Raise StaleImageError if any cached image used by this plan is stale.
|
||||||
|
No-op default; backends override to call the shared check_stale*
|
||||||
|
helpers on their image/artifact timestamps. Called by the CLI before
|
||||||
|
launch so the operator can be prompted outside the launch context."""
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def launch(self, plan: PlanT) -> Generator[Bottle, None, None]:
|
||||||
|
"""Template: build or load images, then delegate to _launch_impl."""
|
||||||
|
images = self._build_or_load_images(plan)
|
||||||
|
with self._launch_impl(plan, images) as bottle:
|
||||||
|
yield bottle
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _build_or_load_images(self, plan: PlanT) -> BottleImages:
|
||||||
|
"""Return the agent and sidecar image references (or artifact paths)
|
||||||
|
for this plan, building fresh images when the policy requires it."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _launch_impl(self, plan: PlanT, images: BottleImages) -> AbstractContextManager[Bottle]:
|
||||||
|
"""Bring up the bottle using pre-resolved images; yield a handle; tear down on exit."""
|
||||||
|
|
||||||
|
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
||||||
|
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||||
|
the running bottle. Called from `launch` after the container
|
||||||
|
/ machine is up. Returns the in-container prompt path if a
|
||||||
|
prompt was provisioned, else None — the Bottle handle uses it
|
||||||
|
to decide whether to add provider-specific prompt args to the
|
||||||
|
agent's argv.
|
||||||
|
|
||||||
|
Default orchestration: ca → prompt → provider apply → skills
|
||||||
|
→ workspace → git → supervise-mcp. CA install runs first so
|
||||||
|
the agent's trust store is rebuilt before anything inside the
|
||||||
|
agent makes a TLS call.
|
||||||
|
|
||||||
|
Per PRD 0050 the per-provider steps (prompt, skills,
|
||||||
|
declarative provision-plan apply, supervise MCP registration)
|
||||||
|
live on the `AgentProvider` plugin. The backend only owns the
|
||||||
|
steps that are about backend infrastructure (CA, workspace,
|
||||||
|
git) and surfaces the supervise daemon URL its launch step
|
||||||
|
knows about via `supervise_mcp_url`.
|
||||||
|
|
||||||
|
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||||
|
~/.gitconfig insteadOf, tea config) are gone. Egress-proxy is
|
||||||
|
on the agent's HTTP_PROXY path so every tool that respects
|
||||||
|
HTTPS_PROXY (claude-code, git over HTTPS, npm, curl) is
|
||||||
|
intercepted without per-tool reconfiguration."""
|
||||||
|
provider = get_provider(plan.agent_provision.template)
|
||||||
|
provider.provision_ca(bottle, plan)
|
||||||
|
prompt_path = provider.provision_prompt(plan, bottle)
|
||||||
|
provider.provision(plan, bottle)
|
||||||
|
provider.provision_skills(plan, bottle)
|
||||||
|
self.provision_workspace(plan, bottle)
|
||||||
|
provider.provision_git(bottle, plan)
|
||||||
|
provider.provision_supervise_mcp(
|
||||||
|
plan, bottle, self.supervise_mcp_url(plan),
|
||||||
|
)
|
||||||
|
return prompt_path
|
||||||
|
|
||||||
|
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
|
||||||
|
"""Copy the operator workspace into the running bottle.
|
||||||
|
|
||||||
|
This is the only supported workspace-provisioning path: Docker
|
||||||
|
does not build a derived image containing the current
|
||||||
|
workspace."""
|
||||||
|
workspace = plan.workspace_plan
|
||||||
|
if not (workspace.enabled and workspace.copy_contents):
|
||||||
|
return
|
||||||
|
|
||||||
|
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
|
||||||
|
guest_path = shlex.quote(workspace.guest_path)
|
||||||
|
guest_parent = shlex.quote(guest_parent)
|
||||||
|
owner = shlex.quote(workspace.owner)
|
||||||
|
mode = shlex.quote(workspace.mode)
|
||||||
|
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
|
||||||
|
bottle.exec(
|
||||||
|
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
|
||||||
|
bottle.exec(
|
||||||
|
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
|
||||||
|
user="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
def supervise_mcp_url(self, plan: PlanT) -> str:
|
||||||
|
"""Return the agent-side URL of the per-bottle supervise
|
||||||
|
gateway, or "" when this bottle has no gateway. The provider
|
||||||
|
plugin's `provision_supervise_mcp` uses it to register the
|
||||||
|
MCP entry inside the guest.
|
||||||
|
|
||||||
|
Default returns "" so backends without supervise support
|
||||||
|
don't have to implement it. Docker and firecracker override."""
|
||||||
|
del plan
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def ensure_orchestrator(self) -> str:
|
||||||
|
"""Bring up this backend's per-host orchestrator + shared gateway
|
||||||
|
(idempotent) and return the host-reachable control-plane URL.
|
||||||
|
|
||||||
|
This is the backend-agnostic bring-up entry point: `launch` calls
|
||||||
|
it as part of starting a bottle, and operator tools (`supervise`)
|
||||||
|
call it to start the control plane on demand when none is running
|
||||||
|
yet. Docker starts the orchestrator + gateway containers;
|
||||||
|
firecracker boots the infra VM. Backends with no orchestrator
|
||||||
|
(macos-container) die with a pointer — the default here."""
|
||||||
|
die(f"backend {self.name!r} has no orchestrator control plane")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def prepare_cleanup(self) -> CleanupT:
|
||||||
|
"""Enumerate orphaned resources from previous bottles. No side
|
||||||
|
effects; safe to call before the y/N."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def cleanup(self, plan: CleanupT) -> None:
|
||||||
|
"""Remove everything described by the cleanup plan."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||||
|
"""Return every currently-running agent on this backend.
|
||||||
|
Empty when none. Backend-specific: docker queries `docker
|
||||||
|
compose ls`; firecracker cross-references its running gateway
|
||||||
|
containers against per-bottle metadata."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
"""Whether this backend's runtime prerequisites are satisfied
|
||||||
|
on the current host. Docker → `docker` on PATH; firecracker →
|
||||||
|
Linux + KVM. Used by the cross-backend
|
||||||
|
`enumerate_active_agents` / `cmd_cleanup` to skip backends
|
||||||
|
the operator hasn't installed, so a docker-only host
|
||||||
|
doesn't fail when `cli.py list active` walks past
|
||||||
|
firecracker."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def setup(cls) -> int:
|
||||||
|
"""Emit this backend's one-time host setup — privileged network
|
||||||
|
pool, daemon bring-up, install pointers, etc. — as
|
||||||
|
host-appropriate config or commands. Prints to stdout/stderr and
|
||||||
|
returns a shell exit code (0 = nothing to report / success). A
|
||||||
|
backend that needs no host setup prints a short note and returns
|
||||||
|
0. Invoked generically by `./cli.py backend setup [--backend=…]`
|
||||||
|
so operators can provision any backend without a
|
||||||
|
backend-specific command. Classmethod (like `is_available`) —
|
||||||
|
it's a host query, not per-bottle state."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def status(cls) -> int:
|
||||||
|
"""Report whether this backend's prerequisites are satisfied on
|
||||||
|
the host — binaries, daemon reachability, network pool, range
|
||||||
|
conflicts, etc. Prints a human-readable summary; returns 0 when
|
||||||
|
the backend is ready to launch and non-zero when something is
|
||||||
|
missing. Invoked by `./cli.py backend status [--backend=…]`."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
"""Undo `setup()` — the inverse operation, surfaced as
|
||||||
|
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
|
||||||
|
with setup: where setup is advisory (prints the privileged
|
||||||
|
commands / declarative config to apply), teardown prints the
|
||||||
|
commands / config change to remove the host prerequisites. A
|
||||||
|
backend with no host setup prints a short note and returns 0.
|
||||||
|
Not called by the launch path or the test suite."""
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Backend registry, selection, and active-agent enumeration.
|
||||||
|
|
||||||
|
Resolves which bottle backend to use (explicit name / `BOT_BOTTLE_BACKEND` /
|
||||||
|
auto-select), and enumerates running agents across every available backend. The
|
||||||
|
three concrete backends are imported lazily inside `_get_backends` so this
|
||||||
|
module — and anything that only needs to *select* a backend — stays cheap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..log import die, info, warn
|
||||||
|
from ..util import read_tty_line
|
||||||
|
from .base import ActiveAgent, BottleBackend
|
||||||
|
|
||||||
|
|
||||||
|
# _backends is None until the first call to _get_backends(), at which
|
||||||
|
# point all three concrete backend classes are imported and instantiated.
|
||||||
|
# Keeping the imports out of module scope means that importing any
|
||||||
|
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
|
||||||
|
# firecracker and macos-container implementations into memory.
|
||||||
|
#
|
||||||
|
# Tests may replace _backends with a {name: fake} dict via patch.object;
|
||||||
|
# _get_backends() returns the current module-level value as-is when it
|
||||||
|
# is not None, so test fakes take effect without triggering real imports.
|
||||||
|
_backends: dict[str, BottleBackend[Any, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
|
||||||
|
"""Return the registry of all backend instances, loading lazily on first call."""
|
||||||
|
global _backends # pylint: disable=global-statement
|
||||||
|
if _backends is None:
|
||||||
|
from .docker import DockerBottleBackend
|
||||||
|
from .firecracker import FirecrackerBottleBackend
|
||||||
|
from .macos_container import MacosContainerBottleBackend
|
||||||
|
_backends = {
|
||||||
|
"docker": DockerBottleBackend(),
|
||||||
|
"firecracker": FirecrackerBottleBackend(),
|
||||||
|
"macos-container": MacosContainerBottleBackend(),
|
||||||
|
}
|
||||||
|
return _backends
|
||||||
|
|
||||||
|
|
||||||
|
def get_bottle_backend(
|
||||||
|
name: str | None = None,
|
||||||
|
*,
|
||||||
|
prompt: bool = True,
|
||||||
|
) -> BottleBackend[Any, Any]:
|
||||||
|
"""Resolve the bottle backend.
|
||||||
|
|
||||||
|
`name` precedence:
|
||||||
|
1. explicit arg (e.g. resume passes the recorded backend name)
|
||||||
|
2. BOT_BOTTLE_BACKEND env var
|
||||||
|
3. auto-selection: VM backend first, docker fallback with prompt
|
||||||
|
|
||||||
|
`prompt` controls whether auto-selection may block on an interactive
|
||||||
|
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
|
||||||
|
non-interactive contexts (headless launches, CI) so the call dies
|
||||||
|
with an actionable message instead of hanging.
|
||||||
|
|
||||||
|
Dies with a pointer at the known backends if the chosen name
|
||||||
|
isn't implemented."""
|
||||||
|
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
|
||||||
|
if resolved is None:
|
||||||
|
resolved = _auto_select_backend(prompt=prompt)
|
||||||
|
backends = _get_backends()
|
||||||
|
if resolved not in backends:
|
||||||
|
known = ", ".join(sorted(backends))
|
||||||
|
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||||
|
return backends[resolved]
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_vm_suggestion() -> str:
|
||||||
|
"""Platform-appropriate VM backend name for install suggestions."""
|
||||||
|
return "macos-container" if sys.platform == "darwin" else "firecracker"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_vm_install_instructions() -> None:
|
||||||
|
"""Print platform-appropriate VM backend install instructions to stderr."""
|
||||||
|
vm = _platform_vm_suggestion()
|
||||||
|
if vm == "macos-container":
|
||||||
|
info("Install Apple Container: https://github.com/apple/container/releases")
|
||||||
|
info("Then start the service: container system start")
|
||||||
|
else:
|
||||||
|
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
|
||||||
|
info("Configure the host: ./cli.py backend setup")
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_select_backend(prompt: bool = True) -> str:
|
||||||
|
"""Tier-1 / tier-2 backend auto-selection.
|
||||||
|
|
||||||
|
Tier 1: VM backend — macos-container on macOS when Apple Container is
|
||||||
|
installed; firecracker on KVM-capable Linux even before the binary is
|
||||||
|
present (its preflight prints an install pointer).
|
||||||
|
|
||||||
|
Tier 2: docker, with a security warning and an interactive prompt.
|
||||||
|
When `prompt=False` (headless / CI), dies with an actionable message
|
||||||
|
instead of blocking on a TTY read. When docker is also absent, prints
|
||||||
|
VM install instructions and exits.
|
||||||
|
"""
|
||||||
|
# --- Tier 1: VM backend -----------------------------------------
|
||||||
|
if has_backend("macos-container"):
|
||||||
|
return "macos-container"
|
||||||
|
# A KVM-capable Linux host defaults to firecracker even when the
|
||||||
|
# `firecracker` binary isn't installed yet: selecting it here routes
|
||||||
|
# start through firecracker's preflight, which prints an install
|
||||||
|
# pointer, instead of silently falling back to docker.
|
||||||
|
from .firecracker import FirecrackerBottleBackend
|
||||||
|
if FirecrackerBottleBackend.is_host_capable():
|
||||||
|
return "firecracker"
|
||||||
|
|
||||||
|
# --- Tier 2: docker fallback ------------------------------------
|
||||||
|
if not has_backend("docker"):
|
||||||
|
info("No backend available on this host.")
|
||||||
|
_print_vm_install_instructions()
|
||||||
|
die("no backend available; install a VM backend and re-run")
|
||||||
|
|
||||||
|
vm = _platform_vm_suggestion()
|
||||||
|
warn(
|
||||||
|
"docker is less secure than VM backends — "
|
||||||
|
"containers share the host kernel."
|
||||||
|
)
|
||||||
|
if not prompt:
|
||||||
|
die(
|
||||||
|
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
|
||||||
|
f"with docker, or install the {vm!r} backend."
|
||||||
|
)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
|
||||||
|
f" [i] show {vm} install instructions and exit\n"
|
||||||
|
" [d] use docker anyway\n"
|
||||||
|
" [q] quit\n"
|
||||||
|
"bot-bottle: choice [i/d/q]: "
|
||||||
|
)
|
||||||
|
sys.stderr.flush()
|
||||||
|
reply = read_tty_line().strip().lower()
|
||||||
|
if reply == "d":
|
||||||
|
return "docker"
|
||||||
|
if reply == "i":
|
||||||
|
_print_vm_install_instructions()
|
||||||
|
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
|
||||||
|
|
||||||
|
|
||||||
|
def known_backend_names() -> tuple[str, ...]:
|
||||||
|
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
|
||||||
|
argparse (`--backend` choices) and the dashboard's backend
|
||||||
|
picker."""
|
||||||
|
return tuple(sorted(_get_backends()))
|
||||||
|
|
||||||
|
|
||||||
|
def has_backend(name: str) -> bool:
|
||||||
|
"""Whether the named backend's runtime prerequisites are
|
||||||
|
available on the current host. Cross-backend callers (list,
|
||||||
|
cleanup) skip unavailable backends so a docker-only host
|
||||||
|
doesn't fail when the firecracker backend isn't usable,
|
||||||
|
and vice versa.
|
||||||
|
|
||||||
|
Returns False for unknown names so callers can pass
|
||||||
|
arbitrary input without separate validation."""
|
||||||
|
backends = _get_backends()
|
||||||
|
if name not in backends:
|
||||||
|
return False
|
||||||
|
return backends[name].is_available()
|
||||||
|
|
||||||
|
|
||||||
|
def enumerate_active_agents() -> list[ActiveAgent]:
|
||||||
|
"""All currently-running agents, across every available
|
||||||
|
backend. Used by CLI `list active` and the dashboard's agents
|
||||||
|
pane so neither has to know which backends exist. Skips
|
||||||
|
backends whose `is_available()` reports False.
|
||||||
|
|
||||||
|
Sorted by `(started_at, slug)` so the list is stable across
|
||||||
|
dashboard refresh ticks — agents don't shift position while
|
||||||
|
the operator navigates with arrow keys. ISO 8601 timestamps
|
||||||
|
sort lexicographically in chronological order; `slug` is the
|
||||||
|
deterministic tiebreaker. Agents with missing metadata
|
||||||
|
(`started_at == ""`) sort first."""
|
||||||
|
out: list[ActiveAgent] = []
|
||||||
|
backends = _get_backends()
|
||||||
|
for name in sorted(backends):
|
||||||
|
if not backends[name].is_available():
|
||||||
|
continue
|
||||||
|
out.extend(backends[name].enumerate_active())
|
||||||
|
out.sort(key=lambda a: (a.started_at, a.slug))
|
||||||
|
return out
|
||||||
@@ -13,6 +13,7 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle import backend as backend_mod
|
from bot_bottle import backend as backend_mod
|
||||||
|
from bot_bottle.backend import selection as _sel
|
||||||
from bot_bottle.backend import (
|
from bot_bottle.backend import (
|
||||||
ActiveAgent,
|
ActiveAgent,
|
||||||
enumerate_active_agents,
|
enumerate_active_agents,
|
||||||
@@ -40,7 +41,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend(),
|
"macos-container": _FakeBackend(),
|
||||||
"docker": _FakeBackend(),
|
"docker": _FakeBackend(),
|
||||||
}):
|
}):
|
||||||
@@ -62,11 +63,11 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
}), \
|
}), \
|
||||||
patch.object(backend_mod, "read_tty_line", return_value="d"):
|
patch.object(_sel, "read_tty_line", return_value="d"):
|
||||||
b = get_bottle_backend()
|
b = get_bottle_backend()
|
||||||
self.assertEqual("docker", b.name)
|
self.assertEqual("docker", b.name)
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: True)), \
|
"is_host_capable", classmethod(lambda cls: True)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"firecracker": _FakeBackend("firecracker", False),
|
"firecracker": _FakeBackend("firecracker", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
@@ -94,7 +95,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
self.assertEqual("firecracker", b.name)
|
self.assertEqual("firecracker", b.name)
|
||||||
|
|
||||||
def test_unknown_dies(self):
|
def test_unknown_dies(self):
|
||||||
with patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
with patch.object(_sel, "die", side_effect=SystemExit("die")):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
get_bottle_backend("nonexistent")
|
get_bottle_backend("nonexistent")
|
||||||
|
|
||||||
@@ -111,11 +112,11 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", False),
|
"docker": _FakeBackend("docker", False),
|
||||||
}), \
|
}), \
|
||||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
patch.object(_sel, "die", side_effect=SystemExit("die")):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
get_bottle_backend()
|
get_bottle_backend()
|
||||||
|
|
||||||
@@ -132,12 +133,12 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
}), \
|
}), \
|
||||||
patch.object(backend_mod, "read_tty_line", return_value="q"), \
|
patch.object(_sel, "read_tty_line", return_value="q"), \
|
||||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
patch.object(_sel, "die", side_effect=SystemExit("die")):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
get_bottle_backend()
|
get_bottle_backend()
|
||||||
|
|
||||||
@@ -155,11 +156,11 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
}), \
|
}), \
|
||||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
patch.object(_sel, "die", side_effect=SystemExit("die")):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
get_bottle_backend(prompt=False)
|
get_bottle_backend(prompt=False)
|
||||||
|
|
||||||
@@ -176,13 +177,13 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(_sel, "_backends", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
}), \
|
}), \
|
||||||
patch.object(backend_mod, "read_tty_line", return_value="i"), \
|
patch.object(_sel, "read_tty_line", return_value="i"), \
|
||||||
patch.object(backend_mod, "_print_vm_install_instructions") as mock_inst, \
|
patch.object(_sel, "_print_vm_install_instructions") as mock_inst, \
|
||||||
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
|
patch.object(_sel, "die", side_effect=SystemExit("die")):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
get_bottle_backend()
|
get_bottle_backend()
|
||||||
mock_inst.assert_called_once()
|
mock_inst.assert_called_once()
|
||||||
@@ -218,9 +219,9 @@ class TestPrintVmInstallInstructions(unittest.TestCase):
|
|||||||
def test_linux_prints_firecracker_instructions(self):
|
def test_linux_prints_firecracker_instructions(self):
|
||||||
from bot_bottle.backend import _print_vm_install_instructions
|
from bot_bottle.backend import _print_vm_install_instructions
|
||||||
|
|
||||||
with patch.object(backend_mod, "_platform_vm_suggestion",
|
with patch.object(_sel, "_platform_vm_suggestion",
|
||||||
return_value="firecracker"), \
|
return_value="firecracker"), \
|
||||||
patch.object(backend_mod, "info") as mock_info:
|
patch.object(_sel, "info") as mock_info:
|
||||||
_print_vm_install_instructions()
|
_print_vm_install_instructions()
|
||||||
|
|
||||||
messages = [str(c[0][0]) for c in mock_info.call_args_list]
|
messages = [str(c[0][0]) for c in mock_info.call_args_list]
|
||||||
@@ -229,9 +230,9 @@ class TestPrintVmInstallInstructions(unittest.TestCase):
|
|||||||
def test_macos_prints_apple_container_instructions(self):
|
def test_macos_prints_apple_container_instructions(self):
|
||||||
from bot_bottle.backend import _print_vm_install_instructions
|
from bot_bottle.backend import _print_vm_install_instructions
|
||||||
|
|
||||||
with patch.object(backend_mod, "_platform_vm_suggestion",
|
with patch.object(_sel, "_platform_vm_suggestion",
|
||||||
return_value="macos-container"), \
|
return_value="macos-container"), \
|
||||||
patch.object(backend_mod, "info") as mock_info:
|
patch.object(_sel, "info") as mock_info:
|
||||||
_print_vm_install_instructions()
|
_print_vm_install_instructions()
|
||||||
|
|
||||||
messages = [str(c[0][0]) for c in mock_info.call_args_list]
|
messages = [str(c[0][0]) for c in mock_info.call_args_list]
|
||||||
@@ -274,7 +275,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
_sel, "_backends",
|
||||||
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
||||||
):
|
):
|
||||||
self.assertEqual([a, b], enumerate_active_agents())
|
self.assertEqual([a, b], enumerate_active_agents())
|
||||||
@@ -308,7 +309,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
_sel, "_backends",
|
||||||
{
|
{
|
||||||
"docker": _FakeBackend([newer, tie_b]),
|
"docker": _FakeBackend([newer, tie_b]),
|
||||||
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
||||||
@@ -328,7 +329,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
_sel, "_backends",
|
||||||
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
||||||
):
|
):
|
||||||
self.assertEqual([], enumerate_active_agents())
|
self.assertEqual([], enumerate_active_agents())
|
||||||
@@ -359,7 +360,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
_sel, "_backends",
|
||||||
{
|
{
|
||||||
"docker": _FakeBackend([present], available=True),
|
"docker": _FakeBackend([present], available=True),
|
||||||
"firecracker": _FakeBackend([hidden], available=False),
|
"firecracker": _FakeBackend([hidden], available=False),
|
||||||
@@ -375,7 +376,7 @@ class TestHasBackend(unittest.TestCase):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends", {"docker": _FakeBackend()},
|
_sel, "_backends", {"docker": _FakeBackend()},
|
||||||
):
|
):
|
||||||
from bot_bottle.backend import has_backend
|
from bot_bottle.backend import has_backend
|
||||||
self.assertFalse(has_backend("docker"))
|
self.assertFalse(has_backend("docker"))
|
||||||
|
|||||||
Reference in New Issue
Block a user