Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d14f889c | |||
| 0afd9c8698 | |||
| d1e121febe | |||
| 537df7a784 | |||
| 7ea7f3926c | |||
| 2362973a0c | |||
| 82a0b73d48 | |||
| 114126e162 | |||
| 1b4f64767a | |||
| 814c7338a1 |
@@ -276,6 +276,18 @@ PlanT = TypeVar("PlanT", bound=BottlePlan)
|
|||||||
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
|
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]):
|
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.
|
||||||
@@ -435,26 +447,27 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
prompt file, Dockerfile path, and guest home all live on
|
prompt file, Dockerfile path, and guest home all live on
|
||||||
`agent_provision_plan` — the source of truth."""
|
`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
|
@contextmanager
|
||||||
def launch(
|
def launch(self, plan: PlanT) -> Generator[Bottle, None, None]:
|
||||||
self, plan: PlanT, *, skip_stale: bool = False
|
"""Template: build or load images, then delegate to _launch_impl."""
|
||||||
) -> Generator[Bottle, None, None]:
|
images = self._build_or_load_images(plan)
|
||||||
"""Template: optionally check for stale cached images, then delegate
|
with self._launch_impl(plan, images) as bottle:
|
||||||
to `_launch_impl`. Pass `skip_stale=True` to bypass the stale check
|
|
||||||
(used by the interactive CLI after the operator confirms)."""
|
|
||||||
if not skip_stale:
|
|
||||||
self._image_stale_checks(plan)
|
|
||||||
with self._launch_impl(plan) as bottle:
|
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def _image_stale_checks(self, plan: PlanT) -> None:
|
@abstractmethod
|
||||||
"""Raise StaleImageError if any cached image used by this plan is stale.
|
def _build_or_load_images(self, plan: PlanT) -> BottleImages:
|
||||||
No-op default; backends override to call the shared `check_stale*`
|
"""Return the agent and sidecar image references (or artifact paths)
|
||||||
helpers on their image/artifact timestamps."""
|
for this plan, building fresh images when the policy requires it."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _launch_impl(self, plan: PlanT) -> AbstractContextManager[Bottle]:
|
def _launch_impl(self, plan: PlanT, images: BottleImages) -> AbstractContextManager[Bottle]:
|
||||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
"""Bring up the bottle using pre-resolved images; yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
|
||||||
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ...env import ResolvedEnv
|
|||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from ...manifest import Manifest
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -85,12 +85,15 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _image_stale_checks(self, plan: DockerBottlePlan) -> None:
|
def prelaunch_checks(self, plan: DockerBottlePlan) -> None:
|
||||||
_launch.stale_checks(plan)
|
_launch.stale_checks(plan)
|
||||||
|
|
||||||
|
def _build_or_load_images(self, plan: DockerBottlePlan) -> BottleImages:
|
||||||
|
return _launch.build_or_load_images(plan)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _launch_impl(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
def _launch_impl(self, plan: DockerBottlePlan, images: BottleImages) -> Generator[DockerBottle, None, None]:
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, images, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from ...git_gate import (
|
|||||||
)
|
)
|
||||||
from ...image_cache import check_stale
|
from ...image_cache import check_stale
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from .. import BottleImages
|
||||||
from . import network as network_mod
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
@@ -71,16 +72,50 @@ from .sidecar_bundle import SIDECAR_BUNDLE_IMAGE
|
|||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||||
|
"""Resolve the agent and sidecar image refs for this plan.
|
||||||
|
|
||||||
|
Returns the committed snapshot if one exists, the local cached images
|
||||||
|
when the policy is 'cached', or builds fresh images and returns those."""
|
||||||
|
committed = read_committed_image(plan.slug)
|
||||||
|
if committed and docker_mod.image_exists(committed):
|
||||||
|
info(f"using committed image {committed!r}")
|
||||||
|
return BottleImages(agent=committed, sidecar=SIDECAR_BUNDLE_IMAGE)
|
||||||
|
if plan.spec.image_policy == "cached":
|
||||||
|
if not docker_mod.image_exists(plan.image):
|
||||||
|
die(
|
||||||
|
f"cached agent image {plan.image!r} not found; "
|
||||||
|
"run without --cached-images to build it"
|
||||||
|
)
|
||||||
|
if not docker_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
||||||
|
die(
|
||||||
|
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
||||||
|
"run without --cached-images to build it"
|
||||||
|
)
|
||||||
|
info(f"using cached agent image {plan.image!r}")
|
||||||
|
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
||||||
|
return BottleImages(agent=plan.image, sidecar=SIDECAR_BUNDLE_IMAGE)
|
||||||
|
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||||
|
return BottleImages(agent=plan.image, sidecar=SIDECAR_BUNDLE_IMAGE)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
|
images: BottleImages,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
|
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
"""Build, launch, and provision a Docker bottle via compose.
|
"""Launch and provision a Docker bottle via compose. Teardown on exit."""
|
||||||
Teardown on exit."""
|
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
|
|
||||||
|
# Stamp the resolved agent image ref into the plan so compose rendering
|
||||||
|
# picks up the right image (may be a committed snapshot or cached ref).
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
agent_provision=dataclasses.replace(plan.agent_provision, image=str(images.agent)),
|
||||||
|
)
|
||||||
|
|
||||||
_bottle_for_revoke = plan.manifest.bottle
|
_bottle_for_revoke = plan.manifest.bottle
|
||||||
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
|
|
||||||
@@ -97,36 +132,6 @@ def launch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: agent image. Use a committed snapshot when one exists
|
|
||||||
# and is present in the local daemon; otherwise build from the
|
|
||||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
|
||||||
# up` via the renderer's `build:` directives.
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
cached_policy = plan.spec.image_policy == "cached"
|
|
||||||
if committed and docker_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
plan = dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
||||||
)
|
|
||||||
elif cached_policy:
|
|
||||||
if not docker_mod.image_exists(plan.image):
|
|
||||||
die(
|
|
||||||
f"cached agent image {plan.image!r} not found; "
|
|
||||||
"run without --cached-images to build it"
|
|
||||||
)
|
|
||||||
if not docker_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
|
||||||
die(
|
|
||||||
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
|
||||||
"run without --cached-images to build it"
|
|
||||||
)
|
|
||||||
info(f"using cached agent image {plan.image!r}")
|
|
||||||
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
|
||||||
else:
|
|
||||||
docker_mod.build_image(
|
|
||||||
plan.image, _REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from ...env import ResolvedEnv
|
|||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from ...manifest import Manifest
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -67,14 +67,17 @@ class MacosContainerBottleBackend(
|
|||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _image_stale_checks(self, plan: MacosContainerBottlePlan) -> None:
|
def prelaunch_checks(self, plan: MacosContainerBottlePlan) -> None:
|
||||||
_launch.stale_checks(plan)
|
_launch.stale_checks(plan)
|
||||||
|
|
||||||
|
def _build_or_load_images(self, plan: MacosContainerBottlePlan) -> BottleImages:
|
||||||
|
return _launch.build_or_load_images(plan)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _launch_impl(
|
def _launch_impl(
|
||||||
self, plan: MacosContainerBottlePlan
|
self, plan: MacosContainerBottlePlan, images: BottleImages
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
) -> Generator[MacosContainerBottle, None, None]:
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, images, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from ...git_gate import (
|
|||||||
)
|
)
|
||||||
from ...image_cache import check_stale
|
from ...image_cache import check_stale
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from .. import BottleImages
|
||||||
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
||||||
from ...util import expand_tilde
|
from ...util import expand_tilde
|
||||||
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
||||||
@@ -72,17 +73,51 @@ def sidecar_container_name(slug: str) -> str:
|
|||||||
return f"bot-bottle-sidecars-{slug}"
|
return f"bot-bottle-sidecars-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_or_load_images(plan: MacosContainerBottlePlan) -> BottleImages:
|
||||||
|
"""Resolve agent and sidecar image refs. Builds the sidecar when needed,
|
||||||
|
but never builds the agent when policy is 'cached'."""
|
||||||
|
committed = read_committed_image(plan.slug)
|
||||||
|
if committed and container_mod.image_exists(committed):
|
||||||
|
info(f"using committed image {committed!r}")
|
||||||
|
if plan.spec.image_policy != "cached":
|
||||||
|
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||||
|
return BottleImages(agent=committed, sidecar=SIDECAR_BUNDLE_IMAGE)
|
||||||
|
if plan.spec.image_policy == "cached":
|
||||||
|
if not container_mod.image_exists(plan.image):
|
||||||
|
die(
|
||||||
|
f"cached agent image {plan.image!r} not found; "
|
||||||
|
"run without --cached-images to build it"
|
||||||
|
)
|
||||||
|
if not container_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
||||||
|
die(
|
||||||
|
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
||||||
|
"run without --cached-images to build it"
|
||||||
|
)
|
||||||
|
info(f"using cached agent image {plan.image!r}")
|
||||||
|
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
||||||
|
return BottleImages(agent=plan.image, sidecar=SIDECAR_BUNDLE_IMAGE)
|
||||||
|
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
||||||
|
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||||
|
return BottleImages(agent=plan.image, sidecar=SIDECAR_BUNDLE_IMAGE)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: MacosContainerBottlePlan,
|
plan: MacosContainerBottlePlan,
|
||||||
|
images: BottleImages,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
) -> Generator[MacosContainerBottle, None, None]:
|
||||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
"""Run, provision, and yield an Apple Container bottle."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
bottle_for_revoke = plan.manifest.bottle
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
|
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
agent_provision=dataclasses.replace(plan.agent_provision, image=str(images.agent)),
|
||||||
|
)
|
||||||
|
|
||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
teardown_exc: BaseException | None = None
|
teardown_exc: BaseException | None = None
|
||||||
try:
|
try:
|
||||||
@@ -96,7 +131,6 @@ def launch(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
plan = _mint_certs(plan)
|
plan = _mint_certs(plan)
|
||||||
plan = _build_images(plan)
|
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
internal_network = internal_network_name(plan.slug)
|
||||||
egress_network = egress_network_name(plan.slug)
|
egress_network = egress_network_name(plan.slug)
|
||||||
@@ -149,35 +183,6 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
return dataclasses.replace(plan, egress_plan=egress_plan)
|
||||||
|
|
||||||
|
|
||||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|
||||||
cached = plan.spec.image_policy == "cached"
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and container_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
if not cached:
|
|
||||||
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
||||||
)
|
|
||||||
if cached:
|
|
||||||
if not container_mod.image_exists(plan.image):
|
|
||||||
die(
|
|
||||||
f"cached agent image {plan.image!r} not found; "
|
|
||||||
"run without --cached-images to build it"
|
|
||||||
)
|
|
||||||
if not container_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
|
||||||
die(
|
|
||||||
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
|
||||||
"run without --cached-images to build it"
|
|
||||||
)
|
|
||||||
info(f"using cached agent image {plan.image!r}")
|
|
||||||
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
|
||||||
return plan
|
|
||||||
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
|
||||||
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def stale_checks(plan: MacosContainerBottlePlan) -> None:
|
def stale_checks(plan: MacosContainerBottlePlan) -> None:
|
||||||
"""Raise StaleImageError if a cached image is older than the configured
|
"""Raise StaleImageError if a cached image is older than the configured
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from ...env import ResolvedEnv
|
|||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from ...manifest import Manifest
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
|
||||||
from . import cleanup as _cleanup
|
from . import cleanup as _cleanup
|
||||||
from . import enumerate as _enumerate
|
from . import enumerate as _enumerate
|
||||||
from . import launch as _launch
|
from . import launch as _launch
|
||||||
@@ -77,14 +77,14 @@ class SmolmachinesBottleBackend(
|
|||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _image_stale_checks(self, plan: SmolmachinesBottlePlan) -> None:
|
def _build_or_load_images(self, plan: SmolmachinesBottlePlan) -> BottleImages:
|
||||||
_launch.stale_checks(plan)
|
return _launch.build_or_load_images(plan)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _launch_impl(
|
def _launch_impl(
|
||||||
self, plan: SmolmachinesBottlePlan
|
self, plan: SmolmachinesBottlePlan, images: BottleImages
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
) -> Generator[SmolmachinesBottle, None, None]:
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, images, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
|
||||||
|
|||||||
@@ -1,51 +1,20 @@
|
|||||||
"""Egress apply for the smolmachines backend.
|
"""Egress apply for the smolmachines backend.
|
||||||
|
|
||||||
The smolmachines sidecar bundle runs as a sidecar smolVM. Route-file
|
The smolmachines sidecar bundle runs as a host-side Docker container,
|
||||||
inspection and reload signalling therefore go through ``smolvm machine
|
so egress signalling is identical to the docker backend.
|
||||||
exec`` instead of Docker.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
from ..docker.egress_apply import ( # noqa: F401
|
||||||
from ...log import warn
|
DockerEgressApplicator,
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
EgressApplyError,
|
||||||
from . import sidecar_bundle as _bundle
|
applicator,
|
||||||
from . import smolvm as _smolvm
|
fetch_current_routes,
|
||||||
|
)
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
|
||||||
machine = _bundle.bundle_machine_name(slug)
|
|
||||||
result = _smolvm.machine_exec(machine, ["cat", EGRESS_ROUTES_IN_CONTAINER])
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not read routes.yaml from {machine}: "
|
|
||||||
f"{(result.stderr or '').strip() or 'sidecar VM not running?'}"
|
|
||||||
)
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesEgressApplicator(EgressApplicator):
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
|
||||||
machine = _bundle.bundle_machine_name(slug)
|
|
||||||
result = _smolvm.machine_exec(machine, ["sh", "-c", "kill -HUP 1"])
|
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'smolvm exec failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {machine}: "
|
|
||||||
f"{last_error or 'smolvm exec failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = SmolmachinesEgressApplicator()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SmolmachinesEgressApplicator",
|
"DockerEgressApplicator",
|
||||||
"EgressApplyError",
|
"EgressApplyError",
|
||||||
"applicator",
|
"applicator",
|
||||||
"fetch_current_routes",
|
"fetch_current_routes",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from . import sidecar_bundle as _bundle
|
|||||||
# matching the bundle container name pattern. We use the prefix
|
# matching the bundle container name pattern. We use the prefix
|
||||||
# both as a filter and to strip back to the slug.
|
# both as a filter and to strip back to the slug.
|
||||||
_VM_NAME_PREFIX = "bot-bottle-"
|
_VM_NAME_PREFIX = "bot-bottle-"
|
||||||
_SIDECAR_VM_PREFIX = _bundle.bundle_machine_name("")
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
@@ -55,11 +54,7 @@ def enumerate_active() -> list[ActiveAgent]:
|
|||||||
for m in machines:
|
for m in machines:
|
||||||
name = m.get("name") or ""
|
name = m.get("name") or ""
|
||||||
state = m.get("state") or ""
|
state = m.get("state") or ""
|
||||||
if (
|
if state != "running" or not name.startswith(_VM_NAME_PREFIX):
|
||||||
state != "running"
|
|
||||||
or not name.startswith(_VM_NAME_PREFIX)
|
|
||||||
or name.startswith(_SIDECAR_VM_PREFIX)
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
slug = name[len(_VM_NAME_PREFIX):]
|
slug = name[len(_VM_NAME_PREFIX):]
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
@@ -45,15 +46,14 @@ from ...git_gate import (
|
|||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
from ...image_cache import check_stale_path
|
from ...log import info, warn
|
||||||
from ...log import die, info, warn
|
|
||||||
from ...bottle_state import (
|
from ...bottle_state import (
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
git_gate_state_dir,
|
git_gate_state_dir,
|
||||||
read_committed_image,
|
read_committed_image,
|
||||||
)
|
)
|
||||||
|
from .. import BottleImages
|
||||||
from . import loopback_alias as _loopback
|
from . import loopback_alias as _loopback
|
||||||
from . import port_forward as _forward
|
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
from .bottle import SmolmachinesBottle
|
from .bottle import SmolmachinesBottle
|
||||||
@@ -80,25 +80,31 @@ _GIT_HTTP_PORT = 9420
|
|||||||
_SUPERVISE_PORT = SUPERVISE_PORT
|
_SUPERVISE_PORT = SUPERVISE_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def build_or_load_images(plan: SmolmachinesBottlePlan) -> BottleImages:
|
||||||
|
"""Build or resolve the agent smolmachine artifact and sidecar bundle image."""
|
||||||
|
_bundle.ensure_bundle_image()
|
||||||
|
return BottleImages(agent=_agent_from_path(plan), sidecar=_bundle.SIDECAR_BUNDLE_IMAGE)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
|
images: BottleImages,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[SmolmachinesBottlePlan, "SmolmachinesBottle"], str | None],
|
provision: Callable[[SmolmachinesBottlePlan, "SmolmachinesBottle"], str | None],
|
||||||
) -> Generator[SmolmachinesBottle, None, None]:
|
) -> Generator[SmolmachinesBottle, None, None]:
|
||||||
"""Build + run the bottle and yield a handle; tear everything
|
"""Run the bottle from pre-built images and yield a handle; tear everything
|
||||||
down on exit. Errors during bringup unwind any partial state
|
down on exit. Errors during bringup unwind any partial state
|
||||||
via the ExitStack."""
|
via the ExitStack."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
try:
|
try:
|
||||||
loopback_ip, network = _allocate_resources(plan, stack)
|
loopback_ip, network = _allocate_resources(plan, stack)
|
||||||
plan = _mint_certs(plan)
|
plan = _mint_certs(plan)
|
||||||
proxy_host = loopback_ip
|
proxy_host = _proxy_host(plan, loopback_ip)
|
||||||
plan = _start_bundle(plan, network, proxy_host, stack)
|
plan = _start_bundle(plan, network, proxy_host, stack)
|
||||||
|
plan = _discover_urls(plan, proxy_host)
|
||||||
|
|
||||||
agent_from_path = _agent_from_path(plan)
|
_launch_vm(plan, Path(images.agent), proxy_host, stack)
|
||||||
|
|
||||||
_launch_vm(plan, agent_from_path, proxy_host, stack)
|
|
||||||
_init_vm(plan)
|
_init_vm(plan)
|
||||||
|
|
||||||
bottle = SmolmachinesBottle(
|
bottle = SmolmachinesBottle(
|
||||||
@@ -144,17 +150,19 @@ def _allocate_resources(
|
|||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
stack: ExitStack,
|
stack: ExitStack,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Reserve a per-bottle host address.
|
"""Reserve a loopback alias and create the per-bottle docker bridge.
|
||||||
|
|
||||||
The per-bottle address scopes TSI's allowlist to this bottle's
|
The per-bottle alias scopes TSI's allowlist to this bottle's
|
||||||
forwarder-published ports so the agent can't reach other bottles'
|
published ports so the agent can't reach other bottles' or host
|
||||||
or host services. The returned network name remains in the bundle
|
services' ports on loopback. On macOS `ensure_pool` first
|
||||||
spec for compatibility with older helper tests; no Docker sidecar
|
sudo-aliases the pool on `lo0`; on Linux that's a no-op since
|
||||||
container is launched."""
|
all of 127.0.0.0/8 is already loopback, but the per-bottle
|
||||||
del stack
|
allocation runs on both."""
|
||||||
_loopback.ensure_pool()
|
_loopback.ensure_pool()
|
||||||
loopback_ip = _loopback.allocate(plan.slug)
|
loopback_ip = _loopback.allocate(plan.slug)
|
||||||
network = _bundle.bundle_network_name(plan.slug)
|
network = _bundle.bundle_network_name(plan.slug)
|
||||||
|
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
||||||
|
stack.callback(_bundle.remove_bundle_network, network)
|
||||||
return loopback_ip, network
|
return loopback_ip, network
|
||||||
|
|
||||||
|
|
||||||
@@ -177,43 +185,14 @@ def _start_bundle(
|
|||||||
proxy_host: str,
|
proxy_host: str,
|
||||||
stack: ExitStack,
|
stack: ExitStack,
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
"""Build the BundleLaunchSpec, start the sidecar VM, wrap its raw
|
"""Build the BundleLaunchSpec, resolve token env, start the
|
||||||
smolVM-published loopback ports with per-bottle forwarders, stamp
|
sidecar bundle container, and register teardown."""
|
||||||
agent URLs from those forwarder ports, and register teardown."""
|
|
||||||
plan = _provision_git_gate_keys(plan)
|
plan = _provision_git_gate_keys(plan)
|
||||||
bundle_spec = _bundle_launch_spec(plan, network, proxy_host)
|
bundle_spec = _bundle_launch_spec(plan, network, proxy_host)
|
||||||
token_env = _resolve_token_env(plan, dict(os.environ))
|
token_env = _resolve_token_env(plan, dict(os.environ))
|
||||||
if _image_policy(plan) == "cached":
|
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
||||||
artifact = _cached_smolmachine(bundle_spec.image, label="sidecar")
|
stack.callback(_bundle.stop_bundle, plan.slug)
|
||||||
else:
|
return plan
|
||||||
artifact = _ensure_smolmachine(
|
|
||||||
bundle_spec.image,
|
|
||||||
dockerfile=_bundle.SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
)
|
|
||||||
launch = _bundle.start_bundle_vm(
|
|
||||||
bundle_spec,
|
|
||||||
from_path=artifact,
|
|
||||||
host_env={**os.environ, **token_env},
|
|
||||||
)
|
|
||||||
stack.callback(_bundle.stop_bundle_vm, plan.slug)
|
|
||||||
|
|
||||||
forward_specs = tuple(
|
|
||||||
_forward.ForwardSpec(
|
|
||||||
label=_label_for_port(container_port),
|
|
||||||
listen_host=proxy_host,
|
|
||||||
listen_port=0,
|
|
||||||
target_host="127.0.0.1",
|
|
||||||
target_port=host_port,
|
|
||||||
)
|
|
||||||
for container_port, host_port in launch.raw_ports.items()
|
|
||||||
)
|
|
||||||
handle = _forward.start_forwarder(forward_specs)
|
|
||||||
stack.callback(_forward.stop_forwarder, handle)
|
|
||||||
published_ports = {
|
|
||||||
_port_for_label(spec.label): spec.listen_port
|
|
||||||
for spec in handle.forwards
|
|
||||||
}
|
|
||||||
return _discover_urls(plan, proxy_host, published_ports)
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_keys(
|
def _provision_git_gate_keys(
|
||||||
@@ -232,27 +211,35 @@ def _provision_git_gate_keys(
|
|||||||
def _discover_urls(
|
def _discover_urls(
|
||||||
plan: SmolmachinesBottlePlan,
|
plan: SmolmachinesBottlePlan,
|
||||||
proxy_host: str,
|
proxy_host: str,
|
||||||
published_ports: dict[int, int],
|
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
"""Stamp URLs + guest_env from per-bottle forwarder ports.
|
"""Discover host-side ports for published container ports and
|
||||||
|
return the plan with URLs + guest_env stamped in.
|
||||||
|
|
||||||
`proxy_host` is the host IP that both TSI's allowlist and the
|
`proxy_host` is the host IP that both TSI's allowlist and
|
||||||
forwarder listeners are keyed to. The raw smolVM-published ports
|
docker's port-forward bindings are keyed to. On macOS it is the
|
||||||
are intentionally not advertised to the agent.
|
per-bottle loopback alias; on Linux it is the per-bottle bridge
|
||||||
|
gateway (see `_proxy_host`). The agent dials the published port
|
||||||
|
on this IP for all bundle-hosted services.
|
||||||
|
|
||||||
NO_PROXY includes `proxy_host` so supervise + git-gate URLs
|
NO_PROXY includes `proxy_host` so supervise + git-gate URLs
|
||||||
bypass HTTPS_PROXY."""
|
bypass HTTPS_PROXY."""
|
||||||
agent_facing_host_port = published_ports[_EGRESS_PORT]
|
agent_facing_host_port = _bundle.bundle_host_port(
|
||||||
|
plan.slug, _EGRESS_PORT, host_ip=proxy_host,
|
||||||
|
)
|
||||||
agent_proxy_url = f"http://{proxy_host}:{agent_facing_host_port}"
|
agent_proxy_url = f"http://{proxy_host}:{agent_facing_host_port}"
|
||||||
|
|
||||||
agent_git_gate_host = ""
|
agent_git_gate_host = ""
|
||||||
if plan.git_gate_plan.upstreams:
|
if plan.git_gate_plan.upstreams:
|
||||||
git_gate_host_port = published_ports[_GIT_HTTP_PORT]
|
git_gate_host_port = _bundle.bundle_host_port(
|
||||||
|
plan.slug, _GIT_HTTP_PORT, host_ip=proxy_host,
|
||||||
|
)
|
||||||
agent_git_gate_host = f"{proxy_host}:{git_gate_host_port}"
|
agent_git_gate_host = f"{proxy_host}:{git_gate_host_port}"
|
||||||
|
|
||||||
agent_supervise_url = ""
|
agent_supervise_url = ""
|
||||||
if plan.supervise_plan is not None:
|
if plan.supervise_plan is not None:
|
||||||
supervise_host_port = published_ports[_SUPERVISE_PORT]
|
supervise_host_port = _bundle.bundle_host_port(
|
||||||
|
plan.slug, _SUPERVISE_PORT, host_ip=proxy_host,
|
||||||
|
)
|
||||||
agent_supervise_url = f"http://{proxy_host}:{supervise_host_port}/"
|
agent_supervise_url = f"http://{proxy_host}:{supervise_host_port}/"
|
||||||
|
|
||||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
||||||
@@ -291,13 +278,14 @@ def _launch_vm(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Create, patch, and start the smolvm VM; register teardown.
|
"""Create, patch, and start the smolvm VM; register teardown.
|
||||||
|
|
||||||
--allow-cidr is `proxy_host/32` — the per-bottle loopback alias.
|
--allow-cidr is `proxy_host/32` — the per-bottle loopback alias
|
||||||
This ensures the guest can only reach sidecar forwarders published
|
on macOS or the bridge gateway on Linux (see `_proxy_host`). This
|
||||||
on that IP, not host localhost or another bottle's alias.
|
ensures the guest can only reach bundle ports published on that IP,
|
||||||
force_allowlist confirms the allowlist persisted (patching smolvm
|
not the container IP directly. force_allowlist confirms the
|
||||||
0.8.0's silent-drop of --allow-cidr when combined with --from) and
|
allowlist persisted (patching smolvm 0.8.0's silent-drop of
|
||||||
fails closed if it can't. Smolfile isn't usable here — smolvm 0.8.0
|
--allow-cidr when combined with --from) and fails closed if it
|
||||||
makes --from and --smolfile mutually exclusive."""
|
can't. Smolfile isn't usable here — smolvm 0.8.0 makes --from
|
||||||
|
and --smolfile mutually exclusive."""
|
||||||
tsi_cidr = f"{proxy_host}/32"
|
tsi_cidr = f"{proxy_host}/32"
|
||||||
_smolvm.machine_create(
|
_smolvm.machine_create(
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
@@ -344,26 +332,25 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
|||||||
_smolvm.wait_exec_ready(plan.machine_name)
|
_smolvm.wait_exec_ready(plan.machine_name)
|
||||||
|
|
||||||
|
|
||||||
def _label_for_port(port: int) -> str:
|
def _proxy_host(plan: SmolmachinesBottlePlan, loopback_ip: str) -> str:
|
||||||
if port == _EGRESS_PORT:
|
"""Return the host IP for TSI's allowlist and docker port-forward bindings.
|
||||||
return "egress"
|
|
||||||
if port == _GIT_HTTP_PORT:
|
|
||||||
return "git-http"
|
|
||||||
if port == _SUPERVISE_PORT:
|
|
||||||
return "supervise"
|
|
||||||
return f"port-{port}"
|
|
||||||
|
|
||||||
|
On macOS, the per-bottle loopback alias (e.g. ``127.0.0.16``) works
|
||||||
|
because macOS's network stack lets TSI intercept 127.x.x.x connects
|
||||||
|
from the guest before they reach the host's own loopback.
|
||||||
|
|
||||||
def _port_for_label(label: str) -> int:
|
On Linux, the guest kernel's LOCAL routing table routes all
|
||||||
if label == "egress":
|
``127.0.0.0/8`` to the guest's own loopback — those packets never
|
||||||
return _EGRESS_PORT
|
reach eth0 and TSI never sees them. Using the per-bottle bridge
|
||||||
if label == "git-http":
|
gateway (e.g. ``192.168.N.1``) instead sidesteps the problem: it
|
||||||
return _GIT_HTTP_PORT
|
is not a loopback address, so the guest routes it via eth0 and TSI
|
||||||
if label == "supervise":
|
intercepts it normally. The TSI allowlist is ``gateway/32``, which
|
||||||
return _SUPERVISE_PORT
|
is distinct from the container IP (``192.168.N.2``), so the agent
|
||||||
if label.startswith("port-"):
|
still can't reach the egress daemon directly — TSI blocks any
|
||||||
return int(label.removeprefix("port-"))
|
connection to the container IP that isn't via the published port."""
|
||||||
raise ValueError(f"unknown sidecar forward label: {label}")
|
if platform.system() == "Linux":
|
||||||
|
return plan.bundle_gateway
|
||||||
|
return loopback_ip
|
||||||
|
|
||||||
|
|
||||||
def _bundle_launch_spec(
|
def _bundle_launch_spec(
|
||||||
@@ -473,9 +460,6 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
|||||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
info(f"using committed smolmachine {str(committed_path)!r}")
|
||||||
return committed_path
|
return committed_path
|
||||||
|
|
||||||
if _image_policy(plan) == "cached":
|
|
||||||
return _cached_smolmachine(plan.agent_image, label="agent")
|
|
||||||
|
|
||||||
# Build the agent image and pack it into a `.smolmachine`
|
# Build the agent image and pack it into a `.smolmachine`
|
||||||
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
||||||
# not in prepare, so the docker-build output doesn't garble the
|
# not in prepare, so the docker-build output doesn't garble the
|
||||||
@@ -486,60 +470,6 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def stale_checks(plan: SmolmachinesBottlePlan) -> None:
|
|
||||||
"""Raise StaleImageError if a cached smolmachine artifact is older than the
|
|
||||||
configured threshold. Only runs when image_policy is 'cached'. Checks the
|
|
||||||
committed agent artifact first, then the cache-keyed agent and sidecar
|
|
||||||
artifacts. Called by the backend class's _image_stale_checks before any
|
|
||||||
resources are allocated."""
|
|
||||||
if _image_policy(plan) != "cached":
|
|
||||||
return
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed:
|
|
||||||
committed_path = Path(committed)
|
|
||||||
if committed_path.is_file():
|
|
||||||
check_stale_path("agent smolmachine artifact", committed_path)
|
|
||||||
elif docker_mod.image_exists(plan.agent_image):
|
|
||||||
digest = docker_mod.image_id(plan.agent_image).split(":", 1)[-1][:16]
|
|
||||||
artifact = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
|
||||||
if artifact.is_file():
|
|
||||||
check_stale_path("agent smolmachine artifact", artifact)
|
|
||||||
sidecar_image = _bundle.SIDECAR_BUNDLE_IMAGE
|
|
||||||
if docker_mod.image_exists(sidecar_image):
|
|
||||||
digest = docker_mod.image_id(sidecar_image).split(":", 1)[-1][:16]
|
|
||||||
sidecar_artifact = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
|
||||||
if sidecar_artifact.is_file():
|
|
||||||
check_stale_path("sidecar smolmachine artifact", sidecar_artifact)
|
|
||||||
|
|
||||||
|
|
||||||
def _image_policy(plan: object) -> str:
|
|
||||||
spec = getattr(plan, "spec", None)
|
|
||||||
return str(getattr(spec, "image_policy", "fresh"))
|
|
||||||
|
|
||||||
|
|
||||||
def _cached_smolmachine(image_ref: str, *, label: str) -> Path:
|
|
||||||
"""Return the cached smolmachine artifact for the current local image.
|
|
||||||
|
|
||||||
This is intentionally buildless: it inspects the existing local Docker
|
|
||||||
image ID and looks for the artifact keyed by that ID. If either side is
|
|
||||||
missing, the caller must use the fresh path to build/pack it.
|
|
||||||
"""
|
|
||||||
if not docker_mod.image_exists(image_ref):
|
|
||||||
die(
|
|
||||||
f"cached {label} image {image_ref!r} not found; "
|
|
||||||
"run without --cached-images to build it"
|
|
||||||
)
|
|
||||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
|
||||||
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
|
||||||
if not sidecar.is_file():
|
|
||||||
die(
|
|
||||||
f"cached {label} smolmachine artifact for {image_ref!r} not found; "
|
|
||||||
"run without --cached-images to build it"
|
|
||||||
)
|
|
||||||
info(f"using cached {label} smolmachine {str(sidecar)!r}")
|
|
||||||
return sidecar
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||||
"""Build the agent docker image and convert it into a
|
"""Build the agent docker image and convert it into a
|
||||||
`.smolmachine` artifact, caching the result under
|
`.smolmachine` artifact, caching the result under
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
"""Per-bottle TCP forwarder for smolmachines sidecar VMs.
|
|
||||||
|
|
||||||
smolVM currently publishes guest ports on host loopback as
|
|
||||||
``HOST:GUEST`` port pairs, without an address-scoped bind. The agent
|
|
||||||
VM's TSI allowlist is IP-only, so bot-bottle exposes a second, stricter
|
|
||||||
surface: listeners bound only to the bottle's allocated host address,
|
|
||||||
forwarding to the raw smolVM-published loopback ports.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import selectors
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from typing import Any, Iterable, Sequence
|
|
||||||
|
|
||||||
from ...log import die, warn
|
|
||||||
|
|
||||||
|
|
||||||
_BUFFER_SIZE = 64 * 1024
|
|
||||||
_LOOPBACK_TARGETS = {"127.0.0.1", "::1"}
|
|
||||||
_FORBIDDEN_LISTEN_HOSTS = {"", "0.0.0.0", "::", "127.0.0.1", "::1"}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ForwardSpec:
|
|
||||||
label: str
|
|
||||||
listen_host: str
|
|
||||||
listen_port: int
|
|
||||||
target_host: str
|
|
||||||
target_port: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ForwarderHandle:
|
|
||||||
process: Any
|
|
||||||
forwards: tuple[ForwardSpec, ...]
|
|
||||||
|
|
||||||
|
|
||||||
def start_forwarder(specs: Sequence[ForwardSpec]) -> ForwarderHandle:
|
|
||||||
"""Start one helper process for a bottle and wait until all
|
|
||||||
listeners are bound. ``listen_port`` may be 0; the returned specs
|
|
||||||
contain the kernel-assigned ports printed by the helper."""
|
|
||||||
if not specs:
|
|
||||||
return ForwarderHandle(process=_CompletedProcess(), forwards=())
|
|
||||||
_validate_specs(specs)
|
|
||||||
argv = [
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"from bot_bottle.backend.smolmachines.port_forward import main; "
|
|
||||||
"raise SystemExit(main())",
|
|
||||||
"--spec-json",
|
|
||||||
json.dumps([asdict(s) for s in specs], separators=(",", ":")),
|
|
||||||
]
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
argv,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=None,
|
|
||||||
text=True,
|
|
||||||
env=os.environ,
|
|
||||||
)
|
|
||||||
assert proc.stdout is not None
|
|
||||||
line = proc.stdout.readline()
|
|
||||||
proc.stdout.close()
|
|
||||||
if not line:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
die("smolmachines forwarder failed before reporting readiness")
|
|
||||||
try:
|
|
||||||
payload = json.loads(line)
|
|
||||||
bound = tuple(ForwardSpec(**item) for item in payload["forwards"])
|
|
||||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
die(f"smolmachines forwarder returned invalid readiness payload: {exc}")
|
|
||||||
return ForwarderHandle(process=proc, forwards=bound)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_forwarder(handle: ForwarderHandle) -> None:
|
|
||||||
proc = handle.process
|
|
||||||
if proc.poll() is not None:
|
|
||||||
return
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_specs(specs: Iterable[ForwardSpec]) -> None:
|
|
||||||
for spec in specs:
|
|
||||||
if spec.listen_host in _FORBIDDEN_LISTEN_HOSTS:
|
|
||||||
die(f"refusing unsafe smolmachines forwarder bind: {spec.listen_host!r}")
|
|
||||||
if spec.target_host not in _LOOPBACK_TARGETS:
|
|
||||||
die(f"refusing non-loopback smolmachines forwarder target: {spec.target_host!r}")
|
|
||||||
if not 0 <= spec.listen_port <= 65535:
|
|
||||||
die(f"invalid smolmachines forwarder listen port: {spec.listen_port}")
|
|
||||||
if not 1 <= spec.target_port <= 65535:
|
|
||||||
die(f"invalid smolmachines forwarder target port: {spec.target_port}")
|
|
||||||
|
|
||||||
|
|
||||||
class _CompletedProcess:
|
|
||||||
"""Popen-like no-op used when a bottle has no forwards."""
|
|
||||||
|
|
||||||
def poll(self) -> int:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def terminate(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def kill(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def wait(self, timeout: float | None = None) -> int:
|
|
||||||
del timeout
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
class _Forwarder:
|
|
||||||
def __init__(self, specs: Sequence[ForwardSpec]):
|
|
||||||
_validate_specs(specs)
|
|
||||||
self.specs = tuple(specs)
|
|
||||||
self.selector = selectors.DefaultSelector()
|
|
||||||
self.stop_event = threading.Event()
|
|
||||||
self.listeners: list[tuple[socket.socket, ForwardSpec]] = []
|
|
||||||
self.bound: list[ForwardSpec] = []
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
for spec in self.specs:
|
|
||||||
listener = socket.create_server(
|
|
||||||
(spec.listen_host, spec.listen_port),
|
|
||||||
reuse_port=False,
|
|
||||||
backlog=64,
|
|
||||||
)
|
|
||||||
listener.setblocking(False)
|
|
||||||
host, port = listener.getsockname()[:2]
|
|
||||||
bound = ForwardSpec(
|
|
||||||
label=spec.label,
|
|
||||||
listen_host=str(host),
|
|
||||||
listen_port=int(port),
|
|
||||||
target_host=spec.target_host,
|
|
||||||
target_port=spec.target_port,
|
|
||||||
)
|
|
||||||
self.listeners.append((listener, bound))
|
|
||||||
self.bound.append(bound)
|
|
||||||
self.selector.register(listener, selectors.EVENT_READ, bound)
|
|
||||||
|
|
||||||
def serve(self) -> None:
|
|
||||||
while not self.stop_event.is_set():
|
|
||||||
try:
|
|
||||||
events = self.selector.select(timeout=0.25)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
for key, _ in events:
|
|
||||||
listener = key.fileobj
|
|
||||||
if not isinstance(listener, socket.socket):
|
|
||||||
continue
|
|
||||||
spec = key.data
|
|
||||||
try:
|
|
||||||
client, _ = listener.accept()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
threading.Thread(
|
|
||||||
target=self._handle_client,
|
|
||||||
args=(client, spec),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self.stop_event.set()
|
|
||||||
for listener, _ in self.listeners:
|
|
||||||
try:
|
|
||||||
self.selector.unregister(listener)
|
|
||||||
except Exception: # noqa: BLE001 - best effort shutdown
|
|
||||||
pass
|
|
||||||
listener.close()
|
|
||||||
self.selector.close()
|
|
||||||
|
|
||||||
def _handle_client(self, client: socket.socket, spec: ForwardSpec) -> None:
|
|
||||||
with client:
|
|
||||||
try:
|
|
||||||
target = socket.create_connection((spec.target_host, spec.target_port))
|
|
||||||
except OSError as exc:
|
|
||||||
warn(f"smolmachines forwarder {spec.label}: target connect failed: {exc}")
|
|
||||||
return
|
|
||||||
with target:
|
|
||||||
left = threading.Thread(target=_pipe, args=(client, target), daemon=True)
|
|
||||||
right = threading.Thread(target=_pipe, args=(target, client), daemon=True)
|
|
||||||
left.start()
|
|
||||||
right.start()
|
|
||||||
left.join()
|
|
||||||
right.join()
|
|
||||||
|
|
||||||
|
|
||||||
def _pipe(src: socket.socket, dst: socket.socket) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
data = src.recv(_BUFFER_SIZE)
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
dst.sendall(data)
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
dst.shutdown(socket.SHUT_WR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--spec-json", required=True)
|
|
||||||
ns = parser.parse_args(argv)
|
|
||||||
specs = tuple(ForwardSpec(**item) for item in json.loads(ns.spec_json))
|
|
||||||
forwarder = _Forwarder(specs)
|
|
||||||
|
|
||||||
def request_stop(signum: int, _frame: object) -> None:
|
|
||||||
del signum
|
|
||||||
forwarder.stop()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, request_stop)
|
|
||||||
signal.signal(signal.SIGINT, request_stop)
|
|
||||||
forwarder.start()
|
|
||||||
print(json.dumps({"forwards": [asdict(s) for s in forwarder.bound]}), flush=True)
|
|
||||||
try:
|
|
||||||
forwarder.serve()
|
|
||||||
finally:
|
|
||||||
forwarder.stop()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -23,8 +23,6 @@ Plans (EgressPlan, …) lands in chunk 2d."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -36,7 +34,6 @@ from ..docker.sidecar_bundle import (
|
|||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
)
|
)
|
||||||
from . import smolvm as _smolvm
|
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
@@ -57,11 +54,6 @@ def bundle_container_name(slug: str) -> str:
|
|||||||
return f"bot-bottle-sidecars-{slug}"
|
return f"bot-bottle-sidecars-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def bundle_machine_name(slug: str) -> str:
|
|
||||||
"""Sidecar smolVM machine name."""
|
|
||||||
return bundle_container_name(slug)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BundleLaunchSpec:
|
class BundleLaunchSpec:
|
||||||
"""Everything `start_bundle` needs to bring up one bundle
|
"""Everything `start_bundle` needs to bring up one bundle
|
||||||
@@ -100,11 +92,6 @@ class BundleLaunchSpec:
|
|||||||
publish_host_ip: str = "127.0.0.1"
|
publish_host_ip: str = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BundleVmLaunch:
|
|
||||||
raw_ports: dict[int, int]
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
||||||
"""Build the sidecar bundle image before `docker run`.
|
"""Build the sidecar bundle image before `docker run`.
|
||||||
|
|
||||||
@@ -120,70 +107,6 @@ def ensure_bundle_image(image: str = SIDECAR_BUNDLE_IMAGE) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def allocate_raw_host_ports(container_ports: Sequence[int]) -> dict[int, int]:
|
|
||||||
"""Reserve candidate host loopback ports for smolVM `-p HOST:GUEST`.
|
|
||||||
|
|
||||||
The sockets are closed before smolVM binds them, so this remains
|
|
||||||
best-effort. smolVM failing to bind a selected port is fatal in
|
|
||||||
the caller's launch path."""
|
|
||||||
out: dict[int, int] = {}
|
|
||||||
for container_port in container_ports:
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.bind(("127.0.0.1", 0))
|
|
||||||
out[container_port] = int(sock.getsockname()[1])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def start_bundle_vm(
|
|
||||||
spec: BundleLaunchSpec,
|
|
||||||
*,
|
|
||||||
from_path: Path,
|
|
||||||
host_env: dict[str, str] | None = None,
|
|
||||||
) -> BundleVmLaunch:
|
|
||||||
"""Create and start the sidecar bundle as a smolVM.
|
|
||||||
|
|
||||||
smolVM's own published ports are raw host-loopback ports. The
|
|
||||||
launch flow wraps them with per-bottle address-bound forwarders
|
|
||||||
before exposing anything to the agent VM."""
|
|
||||||
raw_ports = allocate_raw_host_ports(spec.ports_to_publish)
|
|
||||||
effective_host_env = host_env if host_env is not None else os.environ
|
|
||||||
env: dict[str, str] = {"BOT_BOTTLE_SIDECAR_DAEMONS": spec.daemons_csv}
|
|
||||||
for entry in spec.environment:
|
|
||||||
name, sep, value = entry.partition("=")
|
|
||||||
if sep:
|
|
||||||
env[name] = value
|
|
||||||
elif entry in effective_host_env:
|
|
||||||
env[entry] = effective_host_env[entry]
|
|
||||||
name = bundle_machine_name(spec.slug)
|
|
||||||
_smolvm.machine_create(
|
|
||||||
name,
|
|
||||||
from_path=from_path,
|
|
||||||
net=True,
|
|
||||||
env=env,
|
|
||||||
volumes=spec.volumes,
|
|
||||||
ports=tuple(
|
|
||||||
(host_port, guest_port)
|
|
||||||
for guest_port, host_port in raw_ports.items()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
_smolvm.machine_start(name)
|
|
||||||
_smolvm.wait_exec_ready(name)
|
|
||||||
return BundleVmLaunch(raw_ports=raw_ports)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_bundle_vm(slug: str) -> None:
|
|
||||||
"""Best-effort sidecar VM teardown."""
|
|
||||||
name = bundle_machine_name(slug)
|
|
||||||
try:
|
|
||||||
_smolvm.machine_stop(name)
|
|
||||||
except _smolvm.SmolvmError as exc:
|
|
||||||
warn(f"smolvm machine stop {name} failed: {exc}")
|
|
||||||
try:
|
|
||||||
_smolvm.machine_delete(name)
|
|
||||||
except _smolvm.SmolvmError as exc:
|
|
||||||
warn(f"smolvm machine delete {name} failed: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
||||||
"""`docker network create` with an explicit subnet + gateway
|
"""`docker network create` with an explicit subnet + gateway
|
||||||
so the bundle's `--ip` lands on the address the Smolfile's
|
so the bundle's `--ip` lands on the address the Smolfile's
|
||||||
|
|||||||
@@ -113,11 +113,8 @@ def machine_create(
|
|||||||
*,
|
*,
|
||||||
image: str | None = None,
|
image: str | None = None,
|
||||||
from_path: Path | None = None,
|
from_path: Path | None = None,
|
||||||
net: bool = False,
|
|
||||||
allow_cidrs: Sequence[str] = (),
|
allow_cidrs: Sequence[str] = (),
|
||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
ports: Sequence[tuple[int, int]] = (),
|
|
||||||
volumes: Sequence[tuple[str, str, bool]] = (),
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""`smolvm machine create --name NAME [--image IMG | --from PATH]
|
"""`smolvm machine create --name NAME [--image IMG | --from PATH]
|
||||||
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is passed as
|
[--allow-cidr CIDR ...] [-e K=V ...]`. NAME is passed as
|
||||||
@@ -134,24 +131,19 @@ def machine_create(
|
|||||||
no-pull-at-start property. The flag form gives the same
|
no-pull-at-start property. The flag form gives the same
|
||||||
result without the Smolfile complication.
|
result without the Smolfile complication.
|
||||||
|
|
||||||
`--net` is sent explicitly when `net=True` or `allow_cidrs` is
|
`--net` is sent explicitly when `allow_cidrs` is non-empty.
|
||||||
non-empty. `--allow-cidr` implies `--net` per the CLI help, but
|
`--allow-cidr` implies `--net` per the CLI help, but sending
|
||||||
sending `--net` explicitly is harmless and ensures the guest has
|
`--net` explicitly is harmless and ensures the guest has
|
||||||
network access even if that implication changes across versions."""
|
network access even if that implication changes across versions."""
|
||||||
args: list[str] = ["machine", "create", "--name", name]
|
args: list[str] = ["machine", "create", "--name", name]
|
||||||
if image is not None:
|
if image is not None:
|
||||||
args += ["--image", image]
|
args += ["--image", image]
|
||||||
if from_path is not None:
|
if from_path is not None:
|
||||||
args += ["--from", str(from_path)]
|
args += ["--from", str(from_path)]
|
||||||
if net or allow_cidrs:
|
if allow_cidrs:
|
||||||
args.append("--net")
|
args.append("--net")
|
||||||
for cidr in allow_cidrs:
|
for cidr in allow_cidrs:
|
||||||
args += ["--allow-cidr", cidr]
|
args += ["--allow-cidr", cidr]
|
||||||
for host_port, guest_port in ports:
|
|
||||||
args += ["-p", f"{host_port}:{guest_port}"]
|
|
||||||
for host_path, guest_path, read_only in volumes:
|
|
||||||
suffix = ":ro" if read_only else ""
|
|
||||||
args += ["-v", f"{host_path}:{guest_path}{suffix}"]
|
|
||||||
if env:
|
if env:
|
||||||
for k, v in env.items():
|
for k, v in env.items():
|
||||||
args += ["-e", f"{k}={v}"]
|
args += ["-e", f"{k}={v}"]
|
||||||
|
|||||||
+28
-31
@@ -556,38 +556,35 @@ def _launch_bottle(
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
backend = get_bottle_backend(backend_name)
|
backend = get_bottle_backend(backend_name)
|
||||||
skip_stale = False
|
try:
|
||||||
while True:
|
backend.prelaunch_checks(plan)
|
||||||
try:
|
except StaleImageError as exc:
|
||||||
with backend.launch(plan, skip_stale=skip_stale) as bottle:
|
if assume_yes:
|
||||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
die(str(exc))
|
||||||
extra_args: tuple[str, ...] = ()
|
sys.stderr.write(f"bot-bottle: {exc}\nLaunch anyway? [y/N] ")
|
||||||
if headless_prompt_text:
|
sys.stderr.flush()
|
||||||
extra_args = tuple(
|
if read_tty_line() not in ("y", "Y", "yes", "YES"):
|
||||||
get_provider(agent_provider_template).headless_prompt(
|
return 0
|
||||||
headless_prompt_text
|
with backend.launch(plan) as bottle:
|
||||||
)
|
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||||
)
|
extra_args: tuple[str, ...] = ()
|
||||||
exit_code = attach_agent(
|
if headless_prompt_text:
|
||||||
bottle,
|
extra_args = tuple(
|
||||||
agent_provider_template=agent_provider_template,
|
get_provider(agent_provider_template).headless_prompt(
|
||||||
startup_args=plan.agent_provision.startup_args + extra_args,
|
headless_prompt_text
|
||||||
)
|
)
|
||||||
info(
|
)
|
||||||
f"session ended (exit {exit_code}); "
|
exit_code = attach_agent(
|
||||||
f"container {bottle.name} will be removed"
|
bottle,
|
||||||
)
|
agent_provider_template=agent_provider_template,
|
||||||
if agent_provider_template == "claude":
|
startup_args=plan.agent_provision.startup_args + extra_args,
|
||||||
capture_claude_session_state(identity, exit_code)
|
)
|
||||||
break
|
info(
|
||||||
except StaleImageError as exc:
|
f"session ended (exit {exit_code}); "
|
||||||
if assume_yes:
|
f"container {bottle.name} will be removed"
|
||||||
die(str(exc))
|
)
|
||||||
sys.stderr.write(f"bot-bottle: {exc}\nLaunch anyway? [y/N] ")
|
if agent_provider_template == "claude":
|
||||||
sys.stderr.flush()
|
capture_claude_session_state(identity, exit_code)
|
||||||
if read_tty_line() not in ("y", "Y", "yes", "YES"):
|
|
||||||
break
|
|
||||||
skip_stale = True
|
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
# PRD prd-new: smolmachines sidecar VM
|
|
||||||
|
|
||||||
- **Status:** Active
|
|
||||||
- **Author:** codex
|
|
||||||
- **Created:** 2026-07-09
|
|
||||||
- **Issue:** #332
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Run the smolmachines backend's trusted sidecar bundle as its own smolVM instead
|
|
||||||
of a Docker container. A bottle then consists of an agent VM plus a sidecar VM,
|
|
||||||
with the agent VM's TSI allowlist limited to the per-bottle agent-facing
|
|
||||||
sidecar surface.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The smolmachines backend currently runs the agent in smolVM but keeps
|
|
||||||
egress/git-gate/supervise in a Docker sidecar bundle. That hybrid launch path
|
|
||||||
keeps Docker in the trusted runtime path and leaves smolmachines with a
|
|
||||||
different isolation boundary than its agent VM design suggests.
|
|
||||||
|
|
||||||
The existing Docker sidecar path also uses Docker port publishing to bind only
|
|
||||||
agent-facing services to the per-bottle host address. TSI is IP-only, so the
|
|
||||||
smolVM replacement must preserve that scoping: publishing sidecar services on
|
|
||||||
generic host localhost would let the agent reach unrelated host services or
|
|
||||||
other bottle sidecars if those services share the allowed address.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
1. `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` runs with both the
|
|
||||||
agent and sidecar as smolVMs.
|
|
||||||
2. The smolmachines launch path no longer uses `docker run` for the sidecar
|
|
||||||
bundle.
|
|
||||||
3. Agent-facing egress, git-gate, and supervise endpoints are exposed only
|
|
||||||
through the bottle's own published sidecar surface.
|
|
||||||
4. Internal sidecar-only ports are not reachable from the agent VM.
|
|
||||||
5. The agent VM TSI allowlist remains fail-closed and limited to the per-bottle
|
|
||||||
address.
|
|
||||||
6. Sidecar config, secrets, and state are delivered to the sidecar VM without
|
|
||||||
exposing provider or forge credentials to the agent VM.
|
|
||||||
7. Teardown removes both VMs and any host-side published port state.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Rewriting the egress, git-gate, or supervise daemons.
|
|
||||||
- Changing the Docker backend's sidecar bundle behavior.
|
|
||||||
- Replacing the existing agent image build and pack pipeline except where shared
|
|
||||||
helper extraction is needed for sidecar image packing.
|
|
||||||
- Weakening the current smolmachines TSI allowlist checks.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The sidecar VM continues to use the existing sidecar bundle image and
|
|
||||||
`/app/sidecar_init.py` supervisor. The launch flow builds the sidecar image,
|
|
||||||
packs it into a `.smolmachine` artifact, creates a per-bottle sidecar VM from
|
|
||||||
that artifact, passes the same daemon-selection environment the Docker bundle
|
|
||||||
uses today, mounts or copies the same daemon-private config/state inputs, starts
|
|
||||||
the sidecar VM, wraps the sidecar VM's raw smolVM-published ports with a local
|
|
||||||
per-bottle forwarder, then starts the agent VM with
|
|
||||||
`--allow-cidr <per-bottle-address>/32`.
|
|
||||||
|
|
||||||
smolVM currently exposes guest ports as `HOST:GUEST` port pairs bound on host
|
|
||||||
loopback. It does not expose an address-scoped bind like
|
|
||||||
`127.0.0.16:<port>:9099`. Because TSI is IP-only, bot-bottle must not advertise
|
|
||||||
those raw loopback ports directly to the agent. Instead, bot-bottle runs one
|
|
||||||
small stdlib Python TCP forwarder process per bottle:
|
|
||||||
|
|
||||||
```text
|
|
||||||
agent VM
|
|
||||||
-> TSI allows only <bottle-address>/32
|
|
||||||
-> bot-bottle forwarder bound to <bottle-address>:<random-port>
|
|
||||||
-> raw smolVM-published sidecar port on 127.0.0.1:<raw-port>
|
|
||||||
-> sidecar VM service
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent-facing URLs are stamped from the forwarder ports, never the raw smolVM
|
|
||||||
ports:
|
|
||||||
|
|
||||||
- `HTTP_PROXY` / `HTTPS_PROXY` point at the published egress proxy port.
|
|
||||||
- `GIT_GATE_URL` points at the published git HTTP port when git-gate is enabled.
|
|
||||||
- `MCP_SUPERVISE_URL` points at the published supervise port when supervise is
|
|
||||||
enabled.
|
|
||||||
|
|
||||||
Internal sidecar-only services stay bound inside the sidecar VM and are not
|
|
||||||
published.
|
|
||||||
|
|
||||||
### Forwarder constraints
|
|
||||||
|
|
||||||
- Reject wildcard listener binds (`0.0.0.0`, `::`) and generic localhost
|
|
||||||
listener binds (`127.0.0.1`, `::1`) for agent-facing forwards.
|
|
||||||
- Accept only fixed loopback targets selected by launch; the client cannot
|
|
||||||
choose or influence the forwarding target.
|
|
||||||
- Forward bytes transparently without parsing HTTP, CONNECT, TLS, Git, or MCP.
|
|
||||||
- Log lifecycle and endpoint metadata only; never log payload bytes.
|
|
||||||
- Create forwards only for egress, git HTTP when enabled, and supervise when
|
|
||||||
enabled.
|
|
||||||
- Fail closed if any listener cannot bind exactly the requested per-bottle
|
|
||||||
address.
|
|
||||||
|
|
||||||
## Implementation chunks
|
|
||||||
|
|
||||||
1. Extract the existing image-to-smolmachine pack helper so agent and sidecar
|
|
||||||
artifacts share the same cache and registry path.
|
|
||||||
2. Add a strict stdlib host TCP forwarder for per-bottle address-bound
|
|
||||||
forwarding to raw smolVM-published sidecar ports.
|
|
||||||
3. Add a sidecar VM launch spec and lifecycle helpers: pack, create, start,
|
|
||||||
publish/discover ports, stop, delete.
|
|
||||||
4. Switch smolmachines launch from Docker sidecar bundle lifecycle to sidecar VM
|
|
||||||
plus forwarder lifecycle.
|
|
||||||
5. Update egress apply / reload paths for the sidecar VM supervisor.
|
|
||||||
6. Add unit tests for argv shape, URL stamping, teardown, and fail-closed
|
|
||||||
behavior.
|
|
||||||
7. Add or update integration coverage for agent-to-sidecar reachability, host
|
|
||||||
localhost denial, other-bottle alias denial, internal port denial, and
|
|
||||||
teardown cleanup.
|
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
# Landscape: containerized Claude Code agent tools
|
# Landscape: containerized AI coding agent tools
|
||||||
|
|
||||||
Research into whether bot-bottle is redundant with existing projects, and
|
Research into whether bot-bottle is redundant with existing projects, and
|
||||||
whether it's worth publishing.
|
whether it's worth publishing.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
The "Claude Code in Docker" space is active but not saturated. bot-bottle
|
The "AI coding agents in isolated sandboxes" space is active but not saturated.
|
||||||
occupies a distinct position: no surveyed project combines all five of its
|
bot-bottle occupies a distinct position: no surveyed project combines all five
|
||||||
defining features. Publishing is likely worthwhile, with the main risk being
|
of its defining features. Publishing is likely worthwhile, with the main risk
|
||||||
claudebox expanding to absorb the same niche.
|
being claudebox expanding to absorb the same niche.
|
||||||
|
|
||||||
|
**Updated 2026-07-09:** bot-bottle now supports three isolation backends
|
||||||
|
(Docker, Apple `container`, smolmachines/libkrun microVMs) and three built-in
|
||||||
|
agent providers (Claude Code, OpenAI Codex, Pi) with an open plugin system for
|
||||||
|
arbitrary providers. This meaningfully strengthens the differentiation against
|
||||||
|
all surveyed competitors.
|
||||||
|
|
||||||
## Closest competitor: claudebox
|
## Closest competitor: claudebox
|
||||||
|
|
||||||
@@ -43,28 +49,78 @@ manifest merge.
|
|||||||
Still marked early-development.
|
Still marked early-development.
|
||||||
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
|
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
|
||||||
runtimes; fundamentally different architecture.
|
runtimes; fundamentally different architecture.
|
||||||
|
- **superhq.ai / SuperHQ** (v0.4.4, April 2026) — macOS desktop app (Rust/GPUI)
|
||||||
|
that runs Claude Code, Codex, and Pi inside microVMs via Apple's
|
||||||
|
Virtualization.framework (their own shuru-sdk / libkrun). Auth gateway
|
||||||
|
injects API keys on the wire so the sandbox never sees them; tmpfs overlay
|
||||||
|
stages agent writes for diff-and-accept review; mobile remote access via
|
||||||
|
remote.superhq.ai. Early alpha, free on launch, Apple Silicon only.
|
||||||
|
|
||||||
|
Overlap: both projects cover agent isolation, credential proxying, and
|
||||||
|
multi-provider support (Claude Code / Codex / Pi). Differences: SuperHQ is a
|
||||||
|
GUI desktop app with no manifest layer; bot-bottle is a CLI fleet manager with
|
||||||
|
named agents, skills injection, per-agent system prompts, and cross-platform
|
||||||
|
backends (Docker, Apple `container`, smolmachines). SuperHQ's microVM
|
||||||
|
isolation story is now partially matched by bot-bottle's `macos_container` and
|
||||||
|
smolmachines backends. Worth watching — it targets the same security-minded
|
||||||
|
power-user audience and moves fast.
|
||||||
|
|
||||||
|
**Known gap in SuperHQ (user-requested, as of 2026-07-09):** A named user
|
||||||
|
(Brian Cheong, Founder, Dunialabs.io) explicitly called out the absence of
|
||||||
|
per-run audit logging: tool calls and network egress. Bot-bottle covers both:
|
||||||
|
network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state
|
||||||
|
is persisted to SQLite.
|
||||||
|
|
||||||
## What no found project does
|
## What no found project does
|
||||||
|
|
||||||
None combine:
|
None combine:
|
||||||
1. Named-agent JSON manifest with per-agent env resolution (prompt / host-forward / literal)
|
1. Named-agent manifest with per-agent env resolution (prompt / host-forward / literal), supporting multiple providers (Claude Code, Codex, Pi, arbitrary plugins)
|
||||||
2. Claude Code skills directory injection
|
2. Skills directory injection
|
||||||
3. Per-agent system prompts
|
3. Per-agent system prompts
|
||||||
4. SSH-agent key forwarding without copying private keys into the container
|
4. SSH-agent key forwarding without copying private keys into the container
|
||||||
5. Home + project manifest merge
|
5. Home + project manifest merge
|
||||||
|
6. Pluggable isolation backends: Docker (Linux/macOS), Apple `container` (macOS microVMs), smolmachines/libkrun microVMs
|
||||||
|
7. Per-run audit log: network egress via pipelock/mitmproxy + op-log persisted to SQLite
|
||||||
|
|
||||||
|
**In-flight directions (not yet shipped):**
|
||||||
|
|
||||||
|
- **Forge-native dispatch (issue #317):** Gitea webhook → orchestrator spins up a bottle
|
||||||
|
with the issue body as prompt → agent works → bottle freezes awaiting review comment →
|
||||||
|
rehydrates on comment → tears down on PR close. The issue-to-PR lifecycle concept is not
|
||||||
|
novel (Devin, Copilot Workspace, SWE-agent all do this as cloud services); what's
|
||||||
|
distinct is doing it self-hosted, manifest-driven, inside bot-bottle's isolation
|
||||||
|
primitives.
|
||||||
|
- **Paid web control plane (issue #327):** Browser-based multi-host agent launch and
|
||||||
|
monitoring; account-scoped bottle and agent definitions; secret custody (encrypted at
|
||||||
|
rest, injected into the sidecar at launch, never exposed to the agent or returned by any
|
||||||
|
read API). Monetization model: OSS runtime free, control plane paid — a standard split
|
||||||
|
(HashiCorp, Grafana) applied to a self-hosted agent sandbox. The principled secret
|
||||||
|
custody model (agent never sees real credentials, even via printenv) is more rigorous
|
||||||
|
than most surveyed tools but not unprecedented.
|
||||||
|
|
||||||
## Publishing verdict
|
## Publishing verdict
|
||||||
|
|
||||||
Worth publishing. Differentiators that matter to the target audience (power
|
Worth publishing. Differentiators that matter to the target audience (power
|
||||||
users running parallel Claude Code sessions with distinct personas/tooling):
|
users running parallel AI coding agent sessions with distinct personas/tooling):
|
||||||
|
|
||||||
- The Python-stdlib-first, low-dependency design — competitors are npm-based or
|
- The Python-stdlib-first, low-dependency design — competitors are npm-based,
|
||||||
Kubernetes-native.
|
Rust/GUI, or Kubernetes-native.
|
||||||
- Named agents with distinct skills and system prompts, not just language profiles.
|
- Named agents with distinct skills and system prompts, not just language profiles.
|
||||||
|
- Multi-backend isolation: Docker, Apple `container` microVMs, and
|
||||||
|
smolmachines/libkrun — single manifest works across all three.
|
||||||
|
- Multi-provider: Claude Code, Codex, Pi, plus an open plugin system for
|
||||||
|
arbitrary providers.
|
||||||
- SSH forwarding without key copying.
|
- SSH forwarding without key copying.
|
||||||
|
- Per-run audit log (tool calls + network egress) — an explicitly requested gap
|
||||||
|
in SuperHQ as of 2026-07-09.
|
||||||
|
- Forge-native dispatch and a paid control plane (in flight) bring bot-bottle
|
||||||
|
into the same product category as cloud services like Devin and Copilot
|
||||||
|
Workspace — but self-hosted, with stronger isolation guarantees and a
|
||||||
|
manifest-driven fleet model those services don't have.
|
||||||
|
|
||||||
Main risk: claudebox adds manifest/agent config. The space is moving fast
|
Main risk: claudebox adds manifest/agent config; SuperHQ is moving fast on the
|
||||||
enough that publishing sooner is better if establishing prior art matters.
|
GUI / microVM side. The space is moving fast enough that publishing sooner is
|
||||||
|
better if establishing prior art matters.
|
||||||
|
|
||||||
Discovery will be slow without active promotion; an Anthropic Discord post or
|
Discovery will be slow without active promotion; an Anthropic Discord post or
|
||||||
HN "Show HN" would do most of the work.
|
HN "Show HN" would do most of the work.
|
||||||
@@ -73,4 +129,4 @@ HN "Show HN" would do most of the work.
|
|||||||
|
|
||||||
- GitHub search cannot surface private or very new repos comprehensively.
|
- GitHub search cannot surface private or very new repos comprehensively.
|
||||||
- Counts (stars, forks) were not confirmed for every project.
|
- Counts (stars, forks) were not confirmed for every project.
|
||||||
- Research conducted 2026-05-07; the space moves fast.
|
- Initial research conducted 2026-05-07; SuperHQ entry added 2026-07-09; the space moves fast.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Unit: _launch_bottle StaleImageError handling.
|
"""Unit: _launch_bottle StaleImageError handling.
|
||||||
|
|
||||||
Exercises the while-True / try-except loop around backend.launch:
|
Exercises prelaunch_checks / backend.launch flow:
|
||||||
- headless mode → die on stale
|
- headless mode → die on stale
|
||||||
- interactive mode, user declines → stop without launching
|
- interactive mode, user declines → stop without launching
|
||||||
- interactive mode, user confirms → retry with skip_stale=True
|
- interactive mode, user confirms → skip stale check and launch once
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,14 +28,6 @@ def _fake_plan() -> Any:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def _stale_cm() -> MagicMock:
|
|
||||||
"""Return a context-manager mock whose __enter__ raises StaleImageError."""
|
|
||||||
cm = MagicMock()
|
|
||||||
cm.__enter__ = MagicMock(side_effect=StaleImageError("image is 5 day(s) old"))
|
|
||||||
cm.__exit__ = MagicMock(return_value=False)
|
|
||||||
return cm
|
|
||||||
|
|
||||||
|
|
||||||
def _ok_cm(bottle: Any) -> MagicMock:
|
def _ok_cm(bottle: Any) -> MagicMock:
|
||||||
"""Return a context-manager mock that yields `bottle`."""
|
"""Return a context-manager mock that yields `bottle`."""
|
||||||
cm = MagicMock()
|
cm = MagicMock()
|
||||||
@@ -78,23 +70,25 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_headless_stale_calls_die(self) -> None:
|
def test_headless_stale_calls_die(self) -> None:
|
||||||
"""In headless mode (assume_yes=True), a StaleImageError must call die()."""
|
"""In headless mode (assume_yes=True), a StaleImageError from prelaunch_checks must call die()."""
|
||||||
import bot_bottle.cli.start as start_mod
|
import bot_bottle.cli.start as start_mod
|
||||||
|
|
||||||
backend_mock = MagicMock()
|
backend_mock = MagicMock()
|
||||||
backend_mock.launch.return_value = _stale_cm()
|
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||||
|
|
||||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||||
patch.object(start_mod, "die", side_effect=Die()):
|
patch.object(start_mod, "die", side_effect=Die()):
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
self._run_launch(assume_yes=True)
|
self._run_launch(assume_yes=True)
|
||||||
|
|
||||||
def test_interactive_user_declines_stops_loop(self) -> None:
|
backend_mock.launch.assert_not_called()
|
||||||
"""Interactive user answering 'n' → loop exits without a second launch."""
|
|
||||||
|
def test_interactive_user_declines_stops_before_launch(self) -> None:
|
||||||
|
"""Interactive user answering 'n' → launch is never called."""
|
||||||
import bot_bottle.cli.start as start_mod
|
import bot_bottle.cli.start as start_mod
|
||||||
|
|
||||||
backend_mock = MagicMock()
|
backend_mock = MagicMock()
|
||||||
backend_mock.launch.return_value = _stale_cm()
|
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||||
|
|
||||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||||
patch.object(start_mod, "read_tty_line", return_value="n"), \
|
patch.object(start_mod, "read_tty_line", return_value="n"), \
|
||||||
@@ -102,23 +96,18 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
|||||||
rc = self._run_launch(assume_yes=False)
|
rc = self._run_launch(assume_yes=False)
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
# launch was called once (stale), then the user declined → no second call.
|
backend_mock.launch.assert_not_called()
|
||||||
backend_mock.launch.assert_called_once()
|
|
||||||
|
|
||||||
def test_interactive_user_confirms_retries_with_skip_stale(self) -> None:
|
def test_interactive_user_confirms_launches_once(self) -> None:
|
||||||
"""Interactive user answering 'y' → second launch with skip_stale=True."""
|
"""Interactive user answering 'y' → prelaunch stale error is bypassed; launch called once."""
|
||||||
import bot_bottle.cli.start as start_mod
|
import bot_bottle.cli.start as start_mod
|
||||||
|
|
||||||
bottle_mock = MagicMock()
|
bottle_mock = MagicMock()
|
||||||
bottle_mock.name = "dev-abc"
|
bottle_mock.name = "dev-abc"
|
||||||
|
|
||||||
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
|
||||||
if not skip_stale:
|
|
||||||
return _stale_cm()
|
|
||||||
return _ok_cm(bottle_mock)
|
|
||||||
|
|
||||||
backend_mock = MagicMock()
|
backend_mock = MagicMock()
|
||||||
backend_mock.launch.side_effect = launch_side_effect
|
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||||
|
backend_mock.launch.return_value = _ok_cm(bottle_mock)
|
||||||
|
|
||||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||||
patch.object(start_mod, "read_tty_line", return_value="y"), \
|
patch.object(start_mod, "read_tty_line", return_value="y"), \
|
||||||
@@ -128,11 +117,8 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
|||||||
rc = self._run_launch(assume_yes=False)
|
rc = self._run_launch(assume_yes=False)
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
# First call: skip_stale=False → stale. Second call: skip_stale=True → ok.
|
backend_mock.prelaunch_checks.assert_called_once()
|
||||||
self.assertEqual(2, backend_mock.launch.call_count)
|
backend_mock.launch.assert_called_once()
|
||||||
calls = backend_mock.launch.call_args_list
|
|
||||||
self.assertFalse(calls[0].kwargs.get("skip_stale", False))
|
|
||||||
self.assertTrue(calls[1].kwargs.get("skip_stale", False))
|
|
||||||
|
|
||||||
def test_interactive_yes_uppercase_also_accepted(self) -> None:
|
def test_interactive_yes_uppercase_also_accepted(self) -> None:
|
||||||
"""'Y' or 'YES' should also be accepted as confirmation."""
|
"""'Y' or 'YES' should also be accepted as confirmation."""
|
||||||
@@ -141,13 +127,9 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
|||||||
bottle_mock = MagicMock()
|
bottle_mock = MagicMock()
|
||||||
bottle_mock.name = "dev-abc"
|
bottle_mock.name = "dev-abc"
|
||||||
|
|
||||||
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
|
||||||
if not skip_stale:
|
|
||||||
return _stale_cm()
|
|
||||||
return _ok_cm(bottle_mock)
|
|
||||||
|
|
||||||
backend_mock = MagicMock()
|
backend_mock = MagicMock()
|
||||||
backend_mock.launch.side_effect = launch_side_effect
|
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
|
||||||
|
backend_mock.launch.return_value = _ok_cm(bottle_mock)
|
||||||
|
|
||||||
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
|
||||||
patch.object(start_mod, "read_tty_line", return_value="YES"), \
|
patch.object(start_mod, "read_tty_line", return_value="YES"), \
|
||||||
@@ -157,7 +139,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
|
|||||||
rc = self._run_launch(assume_yes=False)
|
rc = self._run_launch(assume_yes=False)
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
self.assertEqual(2, backend_mock.launch.call_count)
|
backend_mock.launch.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
|||||||
committed_tag: str | None = None,
|
committed_tag: str | None = None,
|
||||||
image_present: bool = True,
|
image_present: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Drive launch() through its full sequence with the committed-image
|
"""Drive build_or_load_images() + launch() with the committed-image
|
||||||
behaviour controlled by the arguments. Returns the images that were
|
behaviour controlled by the arguments. Returns the images that were
|
||||||
passed to `build_image` (empty list if it was never called)."""
|
passed to `build_image` (empty list if it was never called)."""
|
||||||
built: list[str] = []
|
built: list[str] = []
|
||||||
@@ -124,8 +124,9 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
|||||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
||||||
mock.patch.object(launch_mod, "compose_down"), \
|
mock.patch.object(launch_mod, "compose_down"), \
|
||||||
contextlib.redirect_stderr(io.StringIO()):
|
contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
images = launch_mod.build_or_load_images(plan)
|
||||||
provision = mock.Mock(return_value=None)
|
provision = mock.Mock(return_value=None)
|
||||||
with launch_mod.launch(plan, provision=provision):
|
with launch_mod.launch(plan, images, provision=provision):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return built
|
return built
|
||||||
@@ -170,8 +171,9 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
|||||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
||||||
mock.patch.object(launch_mod, "compose_down"), \
|
mock.patch.object(launch_mod, "compose_down"), \
|
||||||
contextlib.redirect_stderr(io.StringIO()):
|
contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
images = launch_mod.build_or_load_images(plan)
|
||||||
provision = mock.Mock(return_value=None)
|
provision = mock.Mock(return_value=None)
|
||||||
with launch_mod.launch(plan, provision=provision):
|
with launch_mod.launch(plan, images, provision=provision):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.assertEqual(1, len(captured_plans))
|
self.assertEqual(1, len(captured_plans))
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import BottleImages, BottleSpec
|
||||||
from bot_bottle.backend.docker import launch as launch_mod
|
from bot_bottle.backend.docker import launch as launch_mod
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
@@ -86,6 +86,8 @@ class TestTeardownWarning(unittest.TestCase):
|
|||||||
plan = _plan(self._tmp)
|
plan = _plan(self._tmp)
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
|
|
||||||
|
images = BottleImages(agent="bot-bottle-claude:latest", sidecar="bot-bottle-sidecars:latest")
|
||||||
|
|
||||||
with mock.patch.object(launch_mod.docker_mod, "build_image"), \
|
with mock.patch.object(launch_mod.docker_mod, "build_image"), \
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
launch_mod, "egress_tls_init",
|
launch_mod, "egress_tls_init",
|
||||||
@@ -115,7 +117,7 @@ class TestTeardownWarning(unittest.TestCase):
|
|||||||
), \
|
), \
|
||||||
contextlib.redirect_stderr(buf):
|
contextlib.redirect_stderr(buf):
|
||||||
provision = mock.Mock(return_value=None)
|
provision = mock.Mock(return_value=None)
|
||||||
with launch_mod.launch(plan, provision=provision):
|
with launch_mod.launch(plan, images, provision=provision):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
output = buf.getvalue()
|
output = buf.getvalue()
|
||||||
|
|||||||
@@ -339,9 +339,9 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
|||||||
), patch.object(
|
), patch.object(
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
launch.container_mod, "build_image", side_effect=fake_build,
|
||||||
), patch.object(launch, "info"):
|
), patch.object(launch, "info"):
|
||||||
updated = launch._build_images(plan)
|
images = launch.build_or_load_images(plan)
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image)
|
self.assertEqual("bot-bottle-committed-dev-abc:latest", images.agent)
|
||||||
self.assertEqual(1, len(calls))
|
self.assertEqual(1, len(calls))
|
||||||
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
|
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
|
||||||
|
|
||||||
@@ -360,9 +360,9 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
|||||||
), patch.object(
|
), patch.object(
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
launch.container_mod, "build_image", side_effect=fake_build,
|
||||||
):
|
):
|
||||||
updated = launch._build_images(plan)
|
images = launch.build_or_load_images(plan)
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
self.assertEqual("bot-bottle-agent:latest", images.agent)
|
||||||
self.assertEqual(2, len(calls))
|
self.assertEqual(2, len(calls))
|
||||||
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
|
|||||||
launch, "die", side_effect=Die(),
|
launch, "die", side_effect=Die(),
|
||||||
):
|
):
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
launch._build_images(plan)
|
launch.build_or_load_images(plan)
|
||||||
|
|
||||||
def test_cached_mode_dies_when_sidecar_image_missing(self) -> None:
|
def test_cached_mode_dies_when_sidecar_image_missing(self) -> None:
|
||||||
plan = self._cached_plan()
|
plan = self._cached_plan()
|
||||||
@@ -411,7 +411,7 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
|
|||||||
launch, "die", side_effect=Die(),
|
launch, "die", side_effect=Die(),
|
||||||
):
|
):
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
launch._build_images(plan)
|
launch.build_or_load_images(plan)
|
||||||
|
|
||||||
def test_cached_mode_both_present_returns_unchanged_plan(self) -> None:
|
def test_cached_mode_both_present_returns_unchanged_plan(self) -> None:
|
||||||
plan = self._cached_plan()
|
plan = self._cached_plan()
|
||||||
@@ -422,10 +422,10 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
|
|||||||
), patch.object(
|
), patch.object(
|
||||||
launch.container_mod, "build_image",
|
launch.container_mod, "build_image",
|
||||||
) as build, patch.object(launch, "info"):
|
) as build, patch.object(launch, "info"):
|
||||||
result = launch._build_images(plan)
|
images = launch.build_or_load_images(plan)
|
||||||
|
|
||||||
build.assert_not_called()
|
build.assert_not_called()
|
||||||
self.assertEqual(plan.image, result.image)
|
self.assertEqual(plan.image, images.agent)
|
||||||
|
|
||||||
def test_committed_image_plus_cached_skips_sidecar_build(self) -> None:
|
def test_committed_image_plus_cached_skips_sidecar_build(self) -> None:
|
||||||
plan = self._cached_plan()
|
plan = self._cached_plan()
|
||||||
@@ -437,11 +437,11 @@ class TestMacosContainerLaunchCachedImages(unittest.TestCase):
|
|||||||
), patch.object(
|
), patch.object(
|
||||||
launch.container_mod, "build_image",
|
launch.container_mod, "build_image",
|
||||||
) as build, patch.object(launch, "info"):
|
) as build, patch.object(launch, "info"):
|
||||||
result = launch._build_images(plan)
|
images = launch.build_or_load_images(plan)
|
||||||
|
|
||||||
# In cached mode with a committed image, no builds should fire.
|
# In cached mode with a committed image, no builds should fire.
|
||||||
build.assert_not_called()
|
build.assert_not_called()
|
||||||
self.assertEqual("bot-bottle-committed-dev-abc:latest", result.image)
|
self.assertEqual("bot-bottle-committed-dev-abc:latest", images.agent)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.egress_apply import EgressApplyError
|
|
||||||
from bot_bottle.backend.smolmachines import egress_apply
|
|
||||||
from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchCurrentRoutes(unittest.TestCase):
|
|
||||||
def test_reads_routes_from_sidecar_vm(self):
|
|
||||||
with patch.object(
|
|
||||||
egress_apply._smolvm,
|
|
||||||
"machine_exec",
|
|
||||||
return_value=SmolvmRunResult(0, "routes", ""),
|
|
||||||
) as exec_:
|
|
||||||
self.assertEqual("routes", egress_apply.fetch_current_routes("dev-abc"))
|
|
||||||
exec_.assert_called_once_with(
|
|
||||||
"bot-bottle-sidecars-dev-abc",
|
|
||||||
["cat", "/etc/egress/routes.yaml"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_read_failure_raises_apply_error(self):
|
|
||||||
with patch.object(
|
|
||||||
egress_apply._smolvm,
|
|
||||||
"machine_exec",
|
|
||||||
return_value=SmolvmRunResult(1, "", "nope"),
|
|
||||||
):
|
|
||||||
with self.assertRaises(EgressApplyError):
|
|
||||||
egress_apply.fetch_current_routes("dev-abc")
|
|
||||||
|
|
||||||
|
|
||||||
class TestReload(unittest.TestCase):
|
|
||||||
def test_sends_hup_to_sidecar_init(self):
|
|
||||||
with patch.object(
|
|
||||||
egress_apply._smolvm,
|
|
||||||
"machine_exec",
|
|
||||||
return_value=SmolvmRunResult(0, "", ""),
|
|
||||||
) as exec_:
|
|
||||||
egress_apply.SmolmachinesEgressApplicator()._signal_bundle_reload("dev-abc")
|
|
||||||
exec_.assert_called_once_with(
|
|
||||||
"bot-bottle-sidecars-dev-abc",
|
|
||||||
["sh", "-c", "kill -HUP 1"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_reload_failure_raises_apply_error(self):
|
|
||||||
with patch.object(
|
|
||||||
egress_apply._smolvm,
|
|
||||||
"machine_exec",
|
|
||||||
return_value=SmolvmRunResult(1, "", "nope"),
|
|
||||||
):
|
|
||||||
with self.assertRaises(EgressApplyError):
|
|
||||||
egress_apply.SmolmachinesEgressApplicator()._signal_bundle_reload("dev-abc")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Unit: smolmachines active-agent enumeration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.smolmachines import enumerate as _enumerate
|
|
||||||
|
|
||||||
|
|
||||||
def _completed(
|
|
||||||
stdout: str,
|
|
||||||
returncode: int = 0,
|
|
||||||
) -> subprocess.CompletedProcess: # type: ignore[type-arg]
|
|
||||||
return subprocess.CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=returncode,
|
|
||||||
stdout=stdout,
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnumerateActive(unittest.TestCase):
|
|
||||||
def test_skips_sidecar_vms(self):
|
|
||||||
machines = [
|
|
||||||
{"name": "bot-bottle-demo-abc12", "state": "running"},
|
|
||||||
{"name": "bot-bottle-sidecars-demo-abc12", "state": "running"},
|
|
||||||
{"name": "bot-bottle-stopped-xyz", "state": "stopped"},
|
|
||||||
{"name": "other", "state": "running"},
|
|
||||||
]
|
|
||||||
|
|
||||||
class _Metadata:
|
|
||||||
agent_name = "demo"
|
|
||||||
started_at = "2026-07-09T00:00:00Z"
|
|
||||||
label = "Demo"
|
|
||||||
color = "green"
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.enumerate.subprocess.run",
|
|
||||||
return_value=_completed(json.dumps(machines)),
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.enumerate._query_bundle_services",
|
|
||||||
return_value={"demo-abc12": ("egress",)},
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.enumerate.read_metadata",
|
|
||||||
return_value=_Metadata(),
|
|
||||||
) as read_metadata:
|
|
||||||
active = _enumerate.enumerate_active()
|
|
||||||
|
|
||||||
self.assertEqual(1, len(active))
|
|
||||||
self.assertEqual("demo-abc12", active[0].slug)
|
|
||||||
self.assertEqual(("egress",), active[0].services)
|
|
||||||
read_metadata.assert_called_once_with("demo-abc12")
|
|
||||||
|
|
||||||
def test_invalid_smolvm_output_returns_empty(self):
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.enumerate.subprocess.run",
|
|
||||||
return_value=_completed("not json"),
|
|
||||||
):
|
|
||||||
self.assertEqual([], _enumerate.enumerate_active())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -187,54 +187,6 @@ class TestAgentFromPath(unittest.TestCase):
|
|||||||
dockerfile="/repo/Dockerfile",
|
dockerfile="/repo/Dockerfile",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_cached_policy_uses_existing_artifact_without_build(self):
|
|
||||||
with tempfile.TemporaryDirectory(prefix="cached-smolmachine.") as tmp:
|
|
||||||
digest = "abcdef0123456789"
|
|
||||||
artifact = Path(tmp) / f"{digest}.smolmachine.smolmachine"
|
|
||||||
artifact.write_text("")
|
|
||||||
plan = SimpleNamespace(
|
|
||||||
slug="dev-abc12",
|
|
||||||
agent_image="bot-bottle-claude:latest",
|
|
||||||
spec=SimpleNamespace(image_policy="cached"),
|
|
||||||
)
|
|
||||||
with patch.object(
|
|
||||||
_launch_mod, "_SMOLMACHINE_CACHE_DIR", Path(tmp),
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod, "read_committed_image", return_value="",
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod.docker_mod, "image_exists", return_value=True,
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod.docker_mod, "image_id",
|
|
||||||
return_value=f"sha256:{digest}fffffffffffffffff",
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod, "_ensure_smolmachine",
|
|
||||||
) as ensure:
|
|
||||||
result = _launch_mod._agent_from_path(cast(Any, plan))
|
|
||||||
|
|
||||||
self.assertEqual(artifact, result)
|
|
||||||
ensure.assert_not_called()
|
|
||||||
|
|
||||||
def test_cached_policy_dies_when_artifact_missing(self):
|
|
||||||
with tempfile.TemporaryDirectory(prefix="cached-smolmachine.") as tmp:
|
|
||||||
plan = SimpleNamespace(
|
|
||||||
slug="dev-abc12",
|
|
||||||
agent_image="bot-bottle-claude:latest",
|
|
||||||
spec=SimpleNamespace(image_policy="cached"),
|
|
||||||
)
|
|
||||||
with patch.object(
|
|
||||||
_launch_mod, "_SMOLMACHINE_CACHE_DIR", Path(tmp),
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod, "read_committed_image", return_value="",
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod.docker_mod, "image_exists", return_value=True,
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod.docker_mod, "image_id",
|
|
||||||
return_value="sha256:abcdef0123456789fffffffffffffffff",
|
|
||||||
):
|
|
||||||
from bot_bottle.log import Die
|
|
||||||
with self.assertRaises(Die):
|
|
||||||
_launch_mod._agent_from_path(cast(Any, plan))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,325 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.smolmachines.port_forward import (
|
|
||||||
ForwardSpec,
|
|
||||||
ForwarderHandle,
|
|
||||||
_CompletedProcess,
|
|
||||||
_Forwarder,
|
|
||||||
_pipe,
|
|
||||||
main,
|
|
||||||
start_forwarder,
|
|
||||||
stop_forwarder,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _echo_server() -> tuple[socket.socket, int]:
|
|
||||||
listener = socket.create_server(("127.0.0.1", 0))
|
|
||||||
port = int(listener.getsockname()[1])
|
|
||||||
|
|
||||||
def run() -> None:
|
|
||||||
with listener:
|
|
||||||
conn, _ = listener.accept()
|
|
||||||
with conn:
|
|
||||||
while True:
|
|
||||||
data = conn.recv(65536)
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
conn.sendall(data)
|
|
||||||
|
|
||||||
threading.Thread(target=run, daemon=True).start()
|
|
||||||
return listener, port
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwarderValidation(unittest.TestCase):
|
|
||||||
def test_empty_specs_returns_noop_handle(self):
|
|
||||||
handle = start_forwarder(())
|
|
||||||
|
|
||||||
self.assertEqual((), handle.forwards)
|
|
||||||
self.assertEqual(0, handle.process.poll())
|
|
||||||
stop_forwarder(handle)
|
|
||||||
|
|
||||||
def test_rejects_generic_localhost_listener(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec(
|
|
||||||
label="egress",
|
|
||||||
listen_host="127.0.0.1",
|
|
||||||
listen_port=0,
|
|
||||||
target_host="127.0.0.1",
|
|
||||||
target_port=9099,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_rejects_wildcard_listener(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec(
|
|
||||||
label="egress",
|
|
||||||
listen_host="0.0.0.0",
|
|
||||||
listen_port=0,
|
|
||||||
target_host="127.0.0.1",
|
|
||||||
target_port=9099,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_rejects_non_loopback_target(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec(
|
|
||||||
label="egress",
|
|
||||||
listen_host="127.0.0.16",
|
|
||||||
listen_port=0,
|
|
||||||
target_host="192.0.2.10",
|
|
||||||
target_port=9099,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_rejects_invalid_ports(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec("bad-listen", "127.0.0.2", 70000, "127.0.0.1", 9099),
|
|
||||||
))
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec("bad-target", "127.0.0.2", 0, "127.0.0.1", 0),
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_completed_process_methods_are_noops(self):
|
|
||||||
proc = _CompletedProcess()
|
|
||||||
|
|
||||||
proc.terminate()
|
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
self.assertEqual(0, proc.poll())
|
|
||||||
self.assertEqual(0, proc.wait(timeout=1))
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeStdout:
|
|
||||||
def __init__(self, line: str):
|
|
||||||
self.line = line
|
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
def readline(self) -> str:
|
|
||||||
return self.line
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwarderProcess(unittest.TestCase):
|
|
||||||
def test_start_parses_ready_payload(self):
|
|
||||||
payload = {
|
|
||||||
"forwards": [{
|
|
||||||
"label": "egress",
|
|
||||||
"listen_host": "127.0.0.2",
|
|
||||||
"listen_port": 61000,
|
|
||||||
"target_host": "127.0.0.1",
|
|
||||||
"target_port": 55000,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.stdout = _FakeStdout(json.dumps(payload) + "\n")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.port_forward.subprocess.Popen",
|
|
||||||
return_value=proc,
|
|
||||||
) as popen:
|
|
||||||
handle = start_forwarder((
|
|
||||||
ForwardSpec("egress", "127.0.0.2", 0, "127.0.0.1", 55000),
|
|
||||||
))
|
|
||||||
|
|
||||||
self.assertIs(proc, handle.process)
|
|
||||||
self.assertEqual(61000, handle.forwards[0].listen_port)
|
|
||||||
self.assertTrue(proc.stdout.closed)
|
|
||||||
argv = popen.call_args.args[0]
|
|
||||||
self.assertIn("--spec-json", argv)
|
|
||||||
|
|
||||||
def test_start_dies_when_helper_exits_before_ready(self):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.stdout = _FakeStdout("")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.port_forward.subprocess.Popen",
|
|
||||||
return_value=proc,
|
|
||||||
):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec("egress", "127.0.0.2", 0, "127.0.0.1", 55000),
|
|
||||||
))
|
|
||||||
|
|
||||||
proc.terminate.assert_called_once()
|
|
||||||
proc.wait.assert_called_once_with(timeout=5)
|
|
||||||
|
|
||||||
def test_start_dies_on_invalid_ready_payload(self):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.stdout = _FakeStdout('{"wrong": []}\n')
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.port_forward.subprocess.Popen",
|
|
||||||
return_value=proc,
|
|
||||||
):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
start_forwarder((
|
|
||||||
ForwardSpec("egress", "127.0.0.2", 0, "127.0.0.1", 55000),
|
|
||||||
))
|
|
||||||
|
|
||||||
proc.terminate.assert_called_once()
|
|
||||||
proc.wait.assert_called_once_with(timeout=5)
|
|
||||||
|
|
||||||
def test_stop_kills_process_after_timeout(self):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 5), 0]
|
|
||||||
|
|
||||||
stop_forwarder(ForwarderHandle(process=proc, forwards=()))
|
|
||||||
|
|
||||||
proc.terminate.assert_called_once()
|
|
||||||
proc.kill.assert_called_once()
|
|
||||||
self.assertEqual(2, proc.wait.call_count)
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwarding(unittest.TestCase):
|
|
||||||
def test_forwards_bytes_both_directions(self):
|
|
||||||
try:
|
|
||||||
probe = socket.create_server(("127.0.0.2", 0))
|
|
||||||
except OSError:
|
|
||||||
self.skipTest("127.0.0.2 is not bindable on this host")
|
|
||||||
else:
|
|
||||||
probe.close()
|
|
||||||
_listener, target_port = _echo_server()
|
|
||||||
handle = start_forwarder((
|
|
||||||
ForwardSpec(
|
|
||||||
label="egress",
|
|
||||||
listen_host="127.0.0.2",
|
|
||||||
listen_port=0,
|
|
||||||
target_host="127.0.0.1",
|
|
||||||
target_port=target_port,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
try:
|
|
||||||
bound = handle.forwards[0]
|
|
||||||
with socket.create_connection((bound.listen_host, bound.listen_port)) as sock:
|
|
||||||
sock.sendall(b"hello")
|
|
||||||
self.assertEqual(b"hello", sock.recv(5))
|
|
||||||
finally:
|
|
||||||
stop_forwarder(handle)
|
|
||||||
|
|
||||||
|
|
||||||
class TestForwarderInProcess(unittest.TestCase):
|
|
||||||
def test_forwarder_serves_and_stops_in_process(self):
|
|
||||||
_listener, target_port = _echo_server()
|
|
||||||
forwarder = _Forwarder((
|
|
||||||
ForwardSpec("egress", "127.0.0.2", 0, "127.0.0.1", target_port),
|
|
||||||
))
|
|
||||||
forwarder.start()
|
|
||||||
thread = threading.Thread(target=forwarder.serve, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
bound = forwarder.bound[0]
|
|
||||||
with socket.create_connection((bound.listen_host, bound.listen_port)) as sock:
|
|
||||||
sock.sendall(b"ping")
|
|
||||||
self.assertEqual(b"ping", sock.recv(4))
|
|
||||||
finally:
|
|
||||||
forwarder.stop()
|
|
||||||
thread.join(timeout=2)
|
|
||||||
|
|
||||||
self.assertFalse(thread.is_alive())
|
|
||||||
|
|
||||||
def test_forwarder_logs_target_connect_failure(self):
|
|
||||||
forwarder = _Forwarder((
|
|
||||||
ForwardSpec("egress", "127.0.0.2", 0, "127.0.0.1", 1),
|
|
||||||
))
|
|
||||||
forwarder.start()
|
|
||||||
thread = threading.Thread(target=forwarder.serve, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
bound = forwarder.bound[0]
|
|
||||||
with patch("bot_bottle.backend.smolmachines.port_forward.warn") as warn:
|
|
||||||
with socket.create_connection((bound.listen_host, bound.listen_port)):
|
|
||||||
pass
|
|
||||||
for _ in range(100):
|
|
||||||
if warn.called:
|
|
||||||
break
|
|
||||||
threading.Event().wait(0.01)
|
|
||||||
self.assertTrue(warn.called)
|
|
||||||
finally:
|
|
||||||
forwarder.stop()
|
|
||||||
thread.join(timeout=2)
|
|
||||||
|
|
||||||
def test_pipe_stops_when_recv_fails(self):
|
|
||||||
src, dst = socket.socketpair()
|
|
||||||
src.close()
|
|
||||||
try:
|
|
||||||
_pipe(src, dst)
|
|
||||||
finally:
|
|
||||||
dst.close()
|
|
||||||
|
|
||||||
def test_pipe_stops_when_send_fails(self):
|
|
||||||
src, src_peer = socket.socketpair()
|
|
||||||
_dst_peer, dst = socket.socketpair()
|
|
||||||
dst.close()
|
|
||||||
try:
|
|
||||||
src_peer.sendall(b"hello")
|
|
||||||
src_peer.shutdown(socket.SHUT_WR)
|
|
||||||
_pipe(src, dst)
|
|
||||||
finally:
|
|
||||||
src.close()
|
|
||||||
src_peer.close()
|
|
||||||
_dst_peer.close()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain(unittest.TestCase):
|
|
||||||
def test_main_starts_serves_and_stops_forwarder(self):
|
|
||||||
calls: list[str] = []
|
|
||||||
handlers = []
|
|
||||||
|
|
||||||
class FakeForwarder:
|
|
||||||
def __init__(self, specs): # type: ignore[no-untyped-def]
|
|
||||||
self.specs = specs
|
|
||||||
self.bound = specs
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
calls.append("start")
|
|
||||||
|
|
||||||
def serve(self) -> None:
|
|
||||||
calls.append("serve")
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
calls.append("stop")
|
|
||||||
|
|
||||||
def record_signal(_signum, handler): # type: ignore[no-untyped-def]
|
|
||||||
handlers.append(handler)
|
|
||||||
|
|
||||||
spec = ForwardSpec("egress", "127.0.0.2", 0, "127.0.0.1", 9099)
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.port_forward._Forwarder",
|
|
||||||
FakeForwarder,
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.port_forward.signal.signal",
|
|
||||||
side_effect=record_signal,
|
|
||||||
), patch("builtins.print") as print_mock:
|
|
||||||
self.assertEqual(
|
|
||||||
0,
|
|
||||||
main(("--spec-json", json.dumps([{
|
|
||||||
"label": spec.label,
|
|
||||||
"listen_host": spec.listen_host,
|
|
||||||
"listen_port": spec.listen_port,
|
|
||||||
"target_host": spec.target_host,
|
|
||||||
"target_port": spec.target_port,
|
|
||||||
}]))),
|
|
||||||
)
|
|
||||||
handlers[0](15, None)
|
|
||||||
|
|
||||||
self.assertEqual(["start", "serve", "stop", "stop"], calls)
|
|
||||||
self.assertIn("forwards", print_mock.call_args.args[0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import ExitStack
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -28,7 +27,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import (
|
|||||||
SmolmachinesBottlePlan,
|
SmolmachinesBottlePlan,
|
||||||
)
|
)
|
||||||
from bot_bottle.backend.smolmachines import launch as _launch
|
from bot_bottle.backend.smolmachines import launch as _launch
|
||||||
from bot_bottle.backend.smolmachines import port_forward as _forward
|
|
||||||
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
||||||
from bot_bottle.backend.util import AGENT_CA_PATH
|
from bot_bottle.backend.util import AGENT_CA_PATH
|
||||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||||
@@ -434,7 +432,12 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
|
|
||||||
def test_canary_env_visible_to_smolvm_guest(self):
|
def test_canary_env_visible_to_smolvm_guest(self):
|
||||||
plan = _plan(canary=True)
|
plan = _plan(canary=True)
|
||||||
stamped = _launch._discover_urls(plan, "127.0.0.16", {9099: 65000})
|
with patch.object(
|
||||||
|
_launch._bundle,
|
||||||
|
"bundle_host_port",
|
||||||
|
return_value="65000",
|
||||||
|
):
|
||||||
|
stamped = _launch._discover_urls(plan, "127.0.0.16")
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"fake-canary-value",
|
"fake-canary-value",
|
||||||
@@ -503,111 +506,23 @@ class TestProvisionGitUser(unittest.TestCase):
|
|||||||
self.assertIn("bot@example.com", calls[0][0])
|
self.assertIn("bot@example.com", calls[0][0])
|
||||||
|
|
||||||
|
|
||||||
class TestLaunchResourceWiring(unittest.TestCase):
|
class TestProxyHost(unittest.TestCase):
|
||||||
def test_allocate_resources_uses_loopback_alias_and_bundle_name(self):
|
"""_proxy_host returns the bridge gateway on Linux and the loopback
|
||||||
|
alias on other platforms."""
|
||||||
|
|
||||||
|
def test_linux_returns_bundle_gateway(self):
|
||||||
plan = _plan()
|
plan = _plan()
|
||||||
with patch(
|
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
|
||||||
"bot_bottle.backend.smolmachines.launch._loopback.ensure_pool",
|
return_value="Linux"):
|
||||||
) as ensure_pool, patch(
|
result = _launch._proxy_host(plan, "127.0.0.16")
|
||||||
"bot_bottle.backend.smolmachines.launch._loopback.allocate",
|
self.assertEqual(plan.bundle_gateway, result)
|
||||||
return_value="127.0.0.16",
|
|
||||||
) as allocate:
|
|
||||||
loopback_ip, network = _launch._allocate_resources(plan, ExitStack())
|
|
||||||
|
|
||||||
ensure_pool.assert_called_once_with()
|
def test_non_linux_returns_loopback(self):
|
||||||
allocate.assert_called_once_with("demo-abc12")
|
|
||||||
self.assertEqual("127.0.0.16", loopback_ip)
|
|
||||||
self.assertEqual("bot-bottle-bundle-demo-abc12", network)
|
|
||||||
|
|
||||||
def test_start_bundle_wraps_raw_vm_ports_with_forwarder_ports(self):
|
|
||||||
plan = _plan(supervise=True)
|
|
||||||
plan = replace(
|
|
||||||
plan,
|
|
||||||
git_gate_plan=replace(
|
|
||||||
plan.git_gate_plan,
|
|
||||||
upstreams=(GitGateUpstream(
|
|
||||||
name="bot-bottle",
|
|
||||||
upstream_url="ssh://git@host/repo.git",
|
|
||||||
upstream_host="host",
|
|
||||||
upstream_port="22",
|
|
||||||
identity_file="/tmp/key",
|
|
||||||
known_host_key="",
|
|
||||||
),),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
stack = MagicMock(spec=ExitStack)
|
|
||||||
raw_launch = MagicMock(raw_ports={9099: 55001, 9420: 55002, 9100: 55003})
|
|
||||||
handle = _forward.ForwarderHandle(
|
|
||||||
process=MagicMock(),
|
|
||||||
forwards=(
|
|
||||||
_forward.ForwardSpec("egress", "127.0.0.16", 65001, "127.0.0.1", 55001),
|
|
||||||
_forward.ForwardSpec("git-http", "127.0.0.16", 65002, "127.0.0.1", 55002),
|
|
||||||
_forward.ForwardSpec("supervise", "127.0.0.16", 65003, "127.0.0.1", 55003),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.launch._provision_git_gate_keys",
|
|
||||||
return_value=plan,
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.launch._ensure_smolmachine",
|
|
||||||
return_value=Path("/cache/sidecar.smolmachine"),
|
|
||||||
) as ensure, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.launch._bundle.start_bundle_vm",
|
|
||||||
return_value=raw_launch,
|
|
||||||
) as start_vm, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.launch._forward.start_forwarder",
|
|
||||||
return_value=handle,
|
|
||||||
) as start_forwarder:
|
|
||||||
stamped = _launch._start_bundle(plan, "net", "127.0.0.16", stack)
|
|
||||||
|
|
||||||
ensure.assert_called_once()
|
|
||||||
start_vm.assert_called_once()
|
|
||||||
self.assertEqual(Path("/cache/sidecar.smolmachine"), start_vm.call_args.kwargs["from_path"])
|
|
||||||
specs = start_forwarder.call_args.args[0]
|
|
||||||
self.assertEqual(
|
|
||||||
(
|
|
||||||
_forward.ForwardSpec("egress", "127.0.0.16", 0, "127.0.0.1", 55001),
|
|
||||||
_forward.ForwardSpec("git-http", "127.0.0.16", 0, "127.0.0.1", 55002),
|
|
||||||
_forward.ForwardSpec("supervise", "127.0.0.16", 0, "127.0.0.1", 55003),
|
|
||||||
),
|
|
||||||
specs,
|
|
||||||
)
|
|
||||||
self.assertEqual("http://127.0.0.16:65001", stamped.agent_proxy_url)
|
|
||||||
self.assertEqual("127.0.0.16:65002", stamped.agent_git_gate_host)
|
|
||||||
self.assertEqual("http://127.0.0.16:65003/", stamped.agent_supervise_url)
|
|
||||||
self.assertEqual(2, stack.callback.call_count)
|
|
||||||
|
|
||||||
def test_init_vm_repairs_ownership_then_waits_for_exec(self):
|
|
||||||
plan = _plan()
|
plan = _plan()
|
||||||
with patch(
|
with patch("bot_bottle.backend.smolmachines.launch.platform.system",
|
||||||
"bot_bottle.backend.smolmachines.launch._smolvm.machine_exec",
|
return_value="Darwin"):
|
||||||
) as machine_exec, patch(
|
result = _launch._proxy_host(plan, "127.0.0.16")
|
||||||
"bot_bottle.backend.smolmachines.launch._smolvm.wait_exec_ready",
|
self.assertEqual("127.0.0.16", result)
|
||||||
) as wait_exec_ready:
|
|
||||||
_launch._init_vm(plan)
|
|
||||||
|
|
||||||
machine_exec.assert_called_once()
|
|
||||||
argv = machine_exec.call_args.args[1]
|
|
||||||
self.assertEqual(["sh", "-c"], argv[:2])
|
|
||||||
self.assertIn("chown -R node:node /home/node", argv[2])
|
|
||||||
wait_exec_ready.assert_called_once_with(plan.machine_name)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPortLabels(unittest.TestCase):
|
|
||||||
def test_known_and_dynamic_port_labels_round_trip(self):
|
|
||||||
for port, label in (
|
|
||||||
(9099, "egress"),
|
|
||||||
(9420, "git-http"),
|
|
||||||
(9100, "supervise"),
|
|
||||||
(12345, "port-12345"),
|
|
||||||
):
|
|
||||||
self.assertEqual(label, _launch._label_for_port(port))
|
|
||||||
self.assertEqual(port, _launch._port_for_label(label))
|
|
||||||
|
|
||||||
def test_unknown_label_is_rejected(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
_launch._port_for_label("sidecar")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscoverUrls(unittest.TestCase):
|
class TestDiscoverUrls(unittest.TestCase):
|
||||||
@@ -629,20 +544,14 @@ class TestDiscoverUrls(unittest.TestCase):
|
|||||||
),),
|
),),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
stamped = _launch._discover_urls(
|
with patch.object(_launch._bundle, "bundle_host_port", return_value="9420"):
|
||||||
plan,
|
stamped = _launch._discover_urls(plan, "127.0.0.16")
|
||||||
"127.0.0.16",
|
self.assertEqual("127.0.0.16:9420", stamped.agent_git_gate_host)
|
||||||
{9099: 65000, 9420: 65001},
|
|
||||||
)
|
|
||||||
self.assertEqual("127.0.0.16:65001", stamped.agent_git_gate_host)
|
|
||||||
|
|
||||||
def test_supervise_url_set_when_supervise_present(self):
|
def test_supervise_url_set_when_supervise_present(self):
|
||||||
plan = _plan(supervise=True)
|
plan = _plan(supervise=True)
|
||||||
stamped = _launch._discover_urls(
|
with patch.object(_launch._bundle, "bundle_host_port", return_value="55556"):
|
||||||
plan,
|
stamped = _launch._discover_urls(plan, "127.0.0.16")
|
||||||
"127.0.0.16",
|
|
||||||
{9099: 65000, 9100: 55556},
|
|
||||||
)
|
|
||||||
self.assertEqual("http://127.0.0.16:55556/", stamped.agent_supervise_url)
|
self.assertEqual("http://127.0.0.16:55556/", stamped.agent_supervise_url)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from bot_bottle.backend.smolmachines.sidecar_bundle import (
|
from bot_bottle.backend.smolmachines.sidecar_bundle import (
|
||||||
BundleLaunchSpec,
|
BundleLaunchSpec,
|
||||||
allocate_raw_host_ports,
|
|
||||||
bundle_container_name,
|
bundle_container_name,
|
||||||
bundle_network_name,
|
bundle_network_name,
|
||||||
create_bundle_network,
|
create_bundle_network,
|
||||||
ensure_bundle_image,
|
ensure_bundle_image,
|
||||||
start_bundle_vm,
|
|
||||||
remove_bundle_network,
|
remove_bundle_network,
|
||||||
start_bundle,
|
start_bundle,
|
||||||
stop_bundle,
|
stop_bundle,
|
||||||
stop_bundle_vm,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -203,66 +200,6 @@ class TestEnsureBundleImage(unittest.TestCase):
|
|||||||
self.assertEqual("Dockerfile.sidecars", kwargs["dockerfile"])
|
self.assertEqual("Dockerfile.sidecars", kwargs["dockerfile"])
|
||||||
|
|
||||||
|
|
||||||
class TestStartBundleVm(unittest.TestCase):
|
|
||||||
def test_allocate_raw_host_ports_returns_bindable_ports(self):
|
|
||||||
raw_ports = allocate_raw_host_ports((9099, 9420))
|
|
||||||
|
|
||||||
self.assertEqual({9099, 9420}, set(raw_ports))
|
|
||||||
for host_port in raw_ports.values():
|
|
||||||
self.assertIsInstance(host_port, int)
|
|
||||||
self.assertGreater(host_port, 0)
|
|
||||||
self.assertNotEqual(raw_ports[9099], raw_ports[9420])
|
|
||||||
|
|
||||||
def test_creates_sidecar_vm_with_ports_volumes_and_env(self):
|
|
||||||
calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
|
|
||||||
|
|
||||||
def record(name): # type: ignore
|
|
||||||
def inner(*args, **kwargs): # type: ignore
|
|
||||||
calls.append((name, args, kwargs))
|
|
||||||
return inner
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle.allocate_raw_host_ports",
|
|
||||||
return_value={9099: 55001, 9420: 55002},
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_create",
|
|
||||||
side_effect=record("create"),
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_start",
|
|
||||||
side_effect=record("start"),
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.wait_exec_ready",
|
|
||||||
side_effect=record("wait"),
|
|
||||||
):
|
|
||||||
launch = start_bundle_vm(
|
|
||||||
_spec(
|
|
||||||
daemons_csv="egress,git-http",
|
|
||||||
environment=("FOO=bar", "TOKEN"),
|
|
||||||
volumes=(("/host/routes", "/etc/egress", True),),
|
|
||||||
ports_to_publish=(9099, 9420),
|
|
||||||
),
|
|
||||||
from_path=Path("/cache/sidecar.smolmachine"),
|
|
||||||
host_env={"TOKEN": "secret"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual({9099: 55001, 9420: 55002}, launch.raw_ports)
|
|
||||||
create = calls[0]
|
|
||||||
self.assertEqual("create", create[0])
|
|
||||||
self.assertEqual(("bot-bottle-sidecars-demo-abc12",), create[1])
|
|
||||||
self.assertEqual(Path("/cache/sidecar.smolmachine"), create[2]["from_path"])
|
|
||||||
self.assertTrue(create[2]["net"])
|
|
||||||
self.assertEqual(((55001, 9099), (55002, 9420)), create[2]["ports"])
|
|
||||||
self.assertEqual((("/host/routes", "/etc/egress", True),), create[2]["volumes"])
|
|
||||||
self.assertEqual(
|
|
||||||
{
|
|
||||||
"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-http",
|
|
||||||
"FOO": "bar",
|
|
||||||
"TOKEN": "secret",
|
|
||||||
},
|
|
||||||
create[2]["env"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStopBundle(unittest.TestCase):
|
class TestStopBundle(unittest.TestCase):
|
||||||
def _patch_run(self, **kwargs): # type: ignore
|
def _patch_run(self, **kwargs): # type: ignore
|
||||||
return patch(
|
return patch(
|
||||||
@@ -285,38 +222,5 @@ class TestStopBundle(unittest.TestCase):
|
|||||||
stop_bundle("demo-abc12") # no raise
|
stop_bundle("demo-abc12") # no raise
|
||||||
|
|
||||||
|
|
||||||
class TestStopBundleVm(unittest.TestCase):
|
|
||||||
def test_stops_then_deletes_sidecar_vm(self):
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_stop",
|
|
||||||
) as stop, patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_delete",
|
|
||||||
) as delete:
|
|
||||||
stop_bundle_vm("demo-abc12")
|
|
||||||
stop.assert_called_once_with("bot-bottle-sidecars-demo-abc12")
|
|
||||||
delete.assert_called_once_with("bot-bottle-sidecars-demo-abc12")
|
|
||||||
|
|
||||||
def test_stop_and_delete_errors_are_warnings(self):
|
|
||||||
from bot_bottle.backend.smolmachines.smolvm import SmolvmError
|
|
||||||
result = subprocess.CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=1,
|
|
||||||
stdout="",
|
|
||||||
stderr="failed",
|
|
||||||
)
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_stop",
|
|
||||||
side_effect=SmolvmError(["smolvm", "machine", "stop"], result),
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle._smolvm.machine_delete",
|
|
||||||
side_effect=SmolvmError(["smolvm", "machine", "delete"], result),
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.backend.smolmachines.sidecar_bundle.warn",
|
|
||||||
) as warn:
|
|
||||||
stop_bundle_vm("demo-abc12")
|
|
||||||
|
|
||||||
self.assertEqual(2, warn.call_count)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -106,22 +106,6 @@ class TestArgvShapes(unittest.TestCase):
|
|||||||
self.assertIn("--name", argv)
|
self.assertIn("--name", argv)
|
||||||
self.assertIn("agent-xyz", argv)
|
self.assertIn("agent-xyz", argv)
|
||||||
|
|
||||||
def test_machine_create_with_net_ports_and_volumes(self):
|
|
||||||
with self._patch_run() as m:
|
|
||||||
machine_create(
|
|
||||||
"sidecar-xyz",
|
|
||||||
from_path=Path("/stage/sidecar.smolmachine"),
|
|
||||||
net=True,
|
|
||||||
ports=((55000, 9099),),
|
|
||||||
volumes=(("/host/routes", "/etc/egress", True),),
|
|
||||||
)
|
|
||||||
argv = m.call_args.args[0]
|
|
||||||
self.assertIn("--net", argv)
|
|
||||||
self.assertIn("-p", argv)
|
|
||||||
self.assertIn("55000:9099", argv)
|
|
||||||
self.assertIn("-v", argv)
|
|
||||||
self.assertIn("/host/routes:/etc/egress:ro", argv)
|
|
||||||
|
|
||||||
def test_machine_create_omits_net_when_no_allow_cidrs(self):
|
def test_machine_create_omits_net_when_no_allow_cidrs(self):
|
||||||
with self._patch_run() as m:
|
with self._patch_run() as m:
|
||||||
machine_create("agent-xyz", from_path=Path("/x.smolmachine"))
|
machine_create("agent-xyz", from_path=Path("/x.smolmachine"))
|
||||||
|
|||||||
+19
-115
@@ -1,12 +1,11 @@
|
|||||||
"""Unit: stale-image check functions across backends, and the
|
"""Unit: stale-image check functions across backends, and the
|
||||||
BottleBackend.launch template method (skip_stale flag).
|
BottleBackend.launch template method (prelaunch_checks + build_or_load_images).
|
||||||
|
|
||||||
No real images or containers are used — all Docker/container/smolmachine
|
No real images or containers are used — all Docker/container/smolmachine
|
||||||
calls are mocked at the module boundary."""
|
calls are mocked at the module boundary."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -23,51 +22,41 @@ def _bottle_cm(bottle: Any) -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# BottleBackend.launch template — _image_stale_checks + skip_stale
|
# BottleBackend.launch template — prelaunch_checks + _build_or_load_images
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestBottleBackendLaunchTemplate(unittest.TestCase):
|
class TestBottleBackendLaunchTemplate(unittest.TestCase):
|
||||||
"""Verify the concrete launch() method on BottleBackend calls
|
"""Verify the concrete launch() method on BottleBackend calls
|
||||||
_image_stale_checks unless skip_stale=True, then delegates to _launch_impl."""
|
_build_or_load_images and _launch_impl, and that prelaunch_checks is a no-op
|
||||||
|
on the base class."""
|
||||||
|
|
||||||
def _make_backend(self) -> Any:
|
def _make_backend(self) -> Any:
|
||||||
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
||||||
return DockerBottleBackend()
|
return DockerBottleBackend()
|
||||||
|
|
||||||
def test_stale_checks_called_by_default(self) -> None:
|
def test_launch_delegates_to_build_or_load_and_launch_impl(self) -> None:
|
||||||
|
from bot_bottle.backend import BottleImages
|
||||||
backend = self._make_backend()
|
backend = self._make_backend()
|
||||||
plan = cast(Any, SimpleNamespace())
|
plan = cast(Any, SimpleNamespace())
|
||||||
bottle = MagicMock()
|
bottle = MagicMock()
|
||||||
|
images = BottleImages(agent="agent:latest", sidecar="sidecar:latest")
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend, "_image_stale_checks",
|
backend, "_build_or_load_images", return_value=images,
|
||||||
) as stale_mock, patch.object(
|
) as build_mock, patch.object(
|
||||||
backend, "_launch_impl",
|
backend, "_launch_impl",
|
||||||
return_value=_bottle_cm(bottle),
|
return_value=_bottle_cm(bottle),
|
||||||
):
|
) as impl_mock:
|
||||||
with backend.launch(plan):
|
with backend.launch(plan):
|
||||||
pass
|
pass
|
||||||
stale_mock.assert_called_once_with(plan)
|
build_mock.assert_called_once_with(plan)
|
||||||
|
impl_mock.assert_called_once_with(plan, images)
|
||||||
|
|
||||||
def test_skip_stale_bypasses_stale_checks(self) -> None:
|
def test_noop_default_prelaunch_checks(self) -> None:
|
||||||
backend = self._make_backend()
|
|
||||||
plan = cast(Any, SimpleNamespace())
|
|
||||||
bottle = MagicMock()
|
|
||||||
with patch.object(
|
|
||||||
backend, "_image_stale_checks",
|
|
||||||
) as stale_mock, patch.object(
|
|
||||||
backend, "_launch_impl",
|
|
||||||
return_value=_bottle_cm(bottle),
|
|
||||||
):
|
|
||||||
with backend.launch(plan, skip_stale=True):
|
|
||||||
pass
|
|
||||||
stale_mock.assert_not_called()
|
|
||||||
|
|
||||||
def test_noop_default_image_stale_checks(self) -> None:
|
|
||||||
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
||||||
from bot_bottle.backend import BottleBackend
|
from bot_bottle.backend import BottleBackend
|
||||||
backend = DockerBottleBackend()
|
backend = DockerBottleBackend()
|
||||||
# Call the base-class _image_stale_checks directly to verify it's a no-op.
|
# Base-class prelaunch_checks is a no-op — must not raise.
|
||||||
BottleBackend._image_stale_checks(backend, cast(Any, SimpleNamespace())) # type: ignore[arg-type]
|
BottleBackend.prelaunch_checks(backend, cast(Any, SimpleNamespace())) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -142,98 +131,13 @@ class TestDockerStaleChecks(unittest.TestCase):
|
|||||||
self.assertEqual(1, cs.call_count)
|
self.assertEqual(1, cs.call_count)
|
||||||
self.assertIn(plan.image, cs.call_args.args[0])
|
self.assertIn(plan.image, cs.call_args.args[0])
|
||||||
|
|
||||||
def test_backend_image_stale_checks_delegates(self) -> None:
|
def test_backend_prelaunch_checks_delegates(self) -> None:
|
||||||
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
from bot_bottle.backend.docker.backend import DockerBottleBackend
|
||||||
from bot_bottle.backend.docker import launch as mod
|
from bot_bottle.backend.docker import launch as mod
|
||||||
backend = DockerBottleBackend()
|
backend = DockerBottleBackend()
|
||||||
plan = self._plan()
|
plan = self._plan()
|
||||||
with patch.object(mod, "stale_checks") as sc:
|
with patch.object(mod, "stale_checks") as sc:
|
||||||
backend._image_stale_checks(plan) # type: ignore[arg-type]
|
backend.prelaunch_checks(plan) # type: ignore[arg-type]
|
||||||
sc.assert_called_once_with(plan)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# smolmachines backend stale_checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSmolmachinesStaleChecks(unittest.TestCase):
|
|
||||||
def _plan(self, policy: str = "cached") -> Any:
|
|
||||||
return cast(Any, SimpleNamespace(
|
|
||||||
spec=SimpleNamespace(image_policy=policy),
|
|
||||||
slug="dev-abc",
|
|
||||||
agent_image="bot-bottle-claude:latest",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_fresh_policy_is_noop(self) -> None:
|
|
||||||
from bot_bottle.backend.smolmachines import launch as mod
|
|
||||||
with patch.object(mod, "read_committed_image") as rci, \
|
|
||||||
patch.object(mod, "check_stale_path") as csp:
|
|
||||||
mod.stale_checks(self._plan("fresh"))
|
|
||||||
rci.assert_not_called()
|
|
||||||
csp.assert_not_called()
|
|
||||||
|
|
||||||
def test_committed_artifact_found_checks_it(self) -> None:
|
|
||||||
from bot_bottle.backend.smolmachines import launch as mod
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".smolmachine") as f:
|
|
||||||
committed_path = f.name
|
|
||||||
with patch.object(mod, "read_committed_image", return_value=committed_path), \
|
|
||||||
patch.object(mod.docker_mod, "image_exists", return_value=False), \
|
|
||||||
patch.object(mod, "check_stale_path") as csp:
|
|
||||||
mod.stale_checks(self._plan())
|
|
||||||
csp.assert_called_once()
|
|
||||||
self.assertIn("agent smolmachine", csp.call_args.args[0])
|
|
||||||
|
|
||||||
def test_no_committed_agent_artifact_in_cache_is_checked(self) -> None:
|
|
||||||
from bot_bottle.backend.smolmachines import launch as mod
|
|
||||||
digest = "abcdef0123456789"
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
artifact = Path(tmp) / f"{digest}.smolmachine.smolmachine"
|
|
||||||
artifact.write_text("")
|
|
||||||
with patch.object(mod, "_SMOLMACHINE_CACHE_DIR", Path(tmp)), \
|
|
||||||
patch.object(mod, "read_committed_image", return_value=""), \
|
|
||||||
patch.object(mod.docker_mod, "image_exists", return_value=True), \
|
|
||||||
patch.object(
|
|
||||||
mod.docker_mod, "image_id",
|
|
||||||
return_value=f"sha256:{digest}ffffffffffffffff",
|
|
||||||
), \
|
|
||||||
patch.object(mod, "check_stale_path") as csp:
|
|
||||||
mod.stale_checks(self._plan())
|
|
||||||
# At least the agent artifact check ran.
|
|
||||||
self.assertGreaterEqual(csp.call_count, 1)
|
|
||||||
|
|
||||||
def test_sidecar_artifact_in_cache_is_checked(self) -> None:
|
|
||||||
from bot_bottle.backend.smolmachines import launch as mod
|
|
||||||
sidecar_digest = "fedcba9876543210"
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
sidecar_artifact = (
|
|
||||||
Path(tmp) / f"{sidecar_digest}.smolmachine.smolmachine"
|
|
||||||
)
|
|
||||||
sidecar_artifact.write_text("")
|
|
||||||
|
|
||||||
def image_exists(ref: str) -> bool: # pylint: disable=unused-argument
|
|
||||||
return True
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
|
||||||
if ref == mod._bundle.SIDECAR_BUNDLE_IMAGE: # type: ignore[attr-defined]
|
|
||||||
return f"sha256:{sidecar_digest}ffffffff"
|
|
||||||
return "sha256:0000000000000000ffffffff"
|
|
||||||
|
|
||||||
with patch.object(mod, "_SMOLMACHINE_CACHE_DIR", Path(tmp)), \
|
|
||||||
patch.object(mod, "read_committed_image", return_value=""), \
|
|
||||||
patch.object(mod.docker_mod, "image_exists", side_effect=image_exists), \
|
|
||||||
patch.object(mod.docker_mod, "image_id", side_effect=image_id), \
|
|
||||||
patch.object(mod, "check_stale_path") as csp:
|
|
||||||
mod.stale_checks(self._plan())
|
|
||||||
sidecar_calls = [c for c in csp.call_args_list if "sidecar" in c.args[0]]
|
|
||||||
self.assertGreaterEqual(len(sidecar_calls), 1)
|
|
||||||
|
|
||||||
def test_backend_image_stale_checks_delegates(self) -> None:
|
|
||||||
from bot_bottle.backend.smolmachines.backend import SmolmachinesBottleBackend
|
|
||||||
from bot_bottle.backend.smolmachines import launch as mod
|
|
||||||
backend = SmolmachinesBottleBackend()
|
|
||||||
plan = self._plan()
|
|
||||||
with patch.object(mod, "stale_checks") as sc:
|
|
||||||
backend._image_stale_checks(plan) # type: ignore[arg-type]
|
|
||||||
sc.assert_called_once_with(plan)
|
sc.assert_called_once_with(plan)
|
||||||
|
|
||||||
|
|
||||||
@@ -288,13 +192,13 @@ class TestMacosContainerStaleChecks(unittest.TestCase):
|
|||||||
mod.stale_checks(self._plan())
|
mod.stale_checks(self._plan())
|
||||||
cs.assert_not_called()
|
cs.assert_not_called()
|
||||||
|
|
||||||
def test_backend_image_stale_checks_delegates(self) -> None:
|
def test_backend_prelaunch_checks_delegates(self) -> None:
|
||||||
from bot_bottle.backend.macos_container.backend import MacosContainerBottleBackend
|
from bot_bottle.backend.macos_container.backend import MacosContainerBottleBackend
|
||||||
from bot_bottle.backend.macos_container import launch as mod
|
from bot_bottle.backend.macos_container import launch as mod
|
||||||
backend = MacosContainerBottleBackend()
|
backend = MacosContainerBottleBackend()
|
||||||
plan = self._plan()
|
plan = self._plan()
|
||||||
with patch.object(mod, "stale_checks") as sc:
|
with patch.object(mod, "stale_checks") as sc:
|
||||||
backend._image_stale_checks(plan) # type: ignore[arg-type]
|
backend.prelaunch_checks(plan) # type: ignore[arg-type]
|
||||||
sc.assert_called_once_with(plan)
|
sc.assert_called_once_with(plan)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user