refactor(backend): make BottleBackend generic over its plan types
Parameterize BottleBackend over PlanT (bound to BottlePlan) and CleanupT (bound to BottleCleanupPlan). DockerBottleBackend declares itself BottleBackend[DockerBottlePlan, DockerBottleCleanupPlan], which narrows every method's plan parameter to the concrete type and lets the six `assert isinstance(plan, DockerBottlePlan)` lines on launch/cleanup/provision_* go away. The dict in get_bottle_backend keeps its unparameterized BottleBackend element type so it can hold heterogeneous backend specializations.
This commit is contained in:
@@ -34,6 +34,7 @@ from abc import ABC, abstractmethod
|
|||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import Manifest
|
from ..manifest import Manifest
|
||||||
@@ -113,23 +114,29 @@ class Bottle(ABC):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BottleBackend(ABC):
|
PlanT = TypeVar("PlanT", bound=BottlePlan)
|
||||||
|
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
|
||||||
|
|
||||||
|
|
||||||
|
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||||
"""Abstract base for selectable bottle backends. Concrete subclasses
|
"""Abstract base for selectable bottle backends. Concrete subclasses
|
||||||
(e.g. DockerBottleBackend) own their own prepare/launch impls.
|
(e.g. DockerBottleBackend) own their own prepare/launch impls.
|
||||||
Symmetric with the BottlePlan → DockerBottlePlan hierarchy."""
|
Parameterized over the backend's concrete plan + cleanup-plan types
|
||||||
|
so subclass methods get the narrow type without isinstance
|
||||||
|
boilerplate."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> BottlePlan:
|
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
|
||||||
"""Resolve names, validate host-side prerequisites, write
|
"""Resolve names, validate host-side prerequisites, write
|
||||||
scratch files. No remote/runtime resources created yet."""
|
scratch files. No remote/runtime resources created yet."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]:
|
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
||||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: BottlePlan, target: str) -> str | None:
|
def provision(self, plan: PlanT, target: str) -> str | None:
|
||||||
"""Copy host-side files (prompt, skills, SSH keys, .git) into
|
"""Copy host-side files (prompt, skills, SSH keys, .git) into
|
||||||
the running bottle. Called from `launch` after the container/
|
the running bottle. Called from `launch` after the container/
|
||||||
machine is up. `target` identifies the running instance in
|
machine is up. `target` identifies the running instance in
|
||||||
@@ -149,35 +156,35 @@ class BottleBackend(ABC):
|
|||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_prompt(self, plan: BottlePlan, target: str) -> str | None:
|
def provision_prompt(self, plan: PlanT, target: str) -> str | None:
|
||||||
"""Copy the prompt file into the running bottle. Returns the
|
"""Copy the prompt file into the running bottle. Returns the
|
||||||
in-container path iff the agent has a non-empty prompt;
|
in-container path iff the agent has a non-empty prompt;
|
||||||
callers use the return value to decide whether to add
|
callers use the return value to decide whether to add
|
||||||
--append-system-prompt-file to claude's argv."""
|
--append-system-prompt-file to claude's argv."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_skills(self, plan: BottlePlan, target: str) -> None:
|
def provision_skills(self, plan: PlanT, target: str) -> None:
|
||||||
"""Copy the agent's named skills from the host into the
|
"""Copy the agent's named skills from the host into the
|
||||||
running bottle. No-op when the agent has no skills."""
|
running bottle. No-op when the agent has no skills."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_ssh(self, plan: BottlePlan, target: str) -> None:
|
def provision_ssh(self, plan: PlanT, target: str) -> None:
|
||||||
"""Set up SSH in the running bottle (config, agent, keys)
|
"""Set up SSH in the running bottle (config, agent, keys)
|
||||||
so the bottle can reach the manifest's declared SSH hosts.
|
so the bottle can reach the manifest's declared SSH hosts.
|
||||||
No-op when the bottle has no SSH entries."""
|
No-op when the bottle has no SSH entries."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_git(self, plan: BottlePlan, target: str) -> None:
|
def provision_git(self, plan: PlanT, target: str) -> None:
|
||||||
"""Copy the host's cwd `.git` directory into the running
|
"""Copy the host's cwd `.git` directory into the running
|
||||||
bottle if the user requested --cwd. No-op otherwise."""
|
bottle if the user requested --cwd. No-op otherwise."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def prepare_cleanup(self) -> BottleCleanupPlan:
|
def prepare_cleanup(self) -> CleanupT:
|
||||||
"""Enumerate orphaned resources from previous bottles. No side
|
"""Enumerate orphaned resources from previous bottles. No side
|
||||||
effects; safe to call before the y/N."""
|
effects; safe to call before the y/N."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def cleanup(self, plan: BottleCleanupPlan) -> None:
|
def cleanup(self, plan: CleanupT) -> None:
|
||||||
"""Remove everything described by the cleanup plan."""
|
"""Remove everything described by the cleanup plan."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -192,6 +199,8 @@ class BottleBackend(ABC):
|
|||||||
from .docker import DockerBottleBackend # noqa: E402
|
from .docker import DockerBottleBackend # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# The dict carries heterogeneous BottleBackend specializations; callers
|
||||||
|
# use it through the unparameterized BottleBackend interface.
|
||||||
_BACKENDS: dict[str, BottleBackend] = {
|
_BACKENDS: dict[str, BottleBackend] = {
|
||||||
"docker": DockerBottleBackend(),
|
"docker": DockerBottleBackend(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from ...env import ResolvedEnv, resolve_env
|
|||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from ...manifest import SshEntry
|
from ...manifest import SshEntry
|
||||||
from ...util import expand_tilde
|
from ...util import expand_tilde
|
||||||
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec
|
from .. import BottleBackend, BottleSpec
|
||||||
from ..util import host_skill_dir
|
from ..util import host_skill_dir
|
||||||
from . import network as network_mod
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -53,7 +53,7 @@ def _force_remove_container(name: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DockerBottleBackend(BottleBackend):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
|
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
|
||||||
(default)."""
|
(default)."""
|
||||||
|
|
||||||
@@ -164,13 +164,8 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else ""))
|
args_file.write_text("\n".join(args_lines) + ("\n" if args_lines else ""))
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]:
|
def launch(self, plan: DockerBottlePlan) -> Iterator[DockerBottle]:
|
||||||
"""Build, launch, and provision a Docker bottle. Teardown on exit."""
|
"""Build, launch, and provision a Docker bottle. Teardown on exit."""
|
||||||
assert isinstance(plan, DockerBottlePlan), (
|
|
||||||
f"DockerBottleBackend.launch expects DockerBottlePlan, "
|
|
||||||
f"got {type(plan).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
|
|
||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
@@ -277,8 +272,7 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
docker_args[name_idx] = container
|
docker_args[name_idx] = container
|
||||||
info(f"name conflict; retrying as {container}")
|
info(f"name conflict; retrying as {container}")
|
||||||
|
|
||||||
def provision_prompt(self, plan: BottlePlan, target: str) -> str | None:
|
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
|
||||||
assert isinstance(plan, DockerBottlePlan)
|
|
||||||
return _prompt.provision_prompt(plan, target)
|
return _prompt.provision_prompt(plan, target)
|
||||||
|
|
||||||
def validate_skills(self, skills: list[str]) -> None:
|
def validate_skills(self, skills: list[str]) -> None:
|
||||||
@@ -294,8 +288,7 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
f"Create it under ~/.claude/skills/, then re-run."
|
f"Create it under ~/.claude/skills/, then re-run."
|
||||||
)
|
)
|
||||||
|
|
||||||
def provision_skills(self, plan: BottlePlan, target: str) -> None:
|
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
assert isinstance(plan, DockerBottlePlan)
|
|
||||||
_skills.provision_skills(plan, target)
|
_skills.provision_skills(plan, target)
|
||||||
|
|
||||||
def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
|
def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
|
||||||
@@ -309,12 +302,10 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
if not os.path.isfile(key):
|
if not os.path.isfile(key):
|
||||||
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
||||||
|
|
||||||
def provision_ssh(self, plan: BottlePlan, target: str) -> None:
|
def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
assert isinstance(plan, DockerBottlePlan)
|
|
||||||
_ssh.provision_ssh(plan, target)
|
_ssh.provision_ssh(plan, target)
|
||||||
|
|
||||||
def provision_git(self, plan: BottlePlan, target: str) -> None:
|
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
assert isinstance(plan, DockerBottlePlan)
|
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, target)
|
||||||
|
|
||||||
# --- Cleanup ---
|
# --- Cleanup ---
|
||||||
@@ -358,14 +349,10 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
|
|
||||||
return DockerBottleCleanupPlan(containers=containers, networks=networks)
|
return DockerBottleCleanupPlan(containers=containers, networks=networks)
|
||||||
|
|
||||||
def cleanup(self, plan: BottleCleanupPlan) -> None:
|
def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
|
||||||
"""Remove the containers and networks listed in the plan.
|
"""Remove the containers and networks listed in the plan.
|
||||||
Containers first; networks would refuse to delete while
|
Containers first; networks would refuse to delete while
|
||||||
containers are still attached."""
|
containers are still attached."""
|
||||||
assert isinstance(plan, DockerBottleCleanupPlan), (
|
|
||||||
f"DockerBottleBackend.cleanup expects DockerBottleCleanupPlan, "
|
|
||||||
f"got {type(plan).__name__}"
|
|
||||||
)
|
|
||||||
for name in plan.containers:
|
for name in plan.containers:
|
||||||
info(f"removing container {name}")
|
info(f"removing container {name}")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|||||||
Reference in New Issue
Block a user