Compare commits

...

2 Commits

Author SHA1 Message Date
didericis-claude 346367ca32 feat(cleanup): walk every backend, reap smolmachines orphans too
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 41s
`./cli.py cleanup` previously called only the env-var-selected
backend's `prepare_cleanup` / `cleanup` — so a leftover smolvm
machine + bundle container + bundle network from a crashed
smolmachines bottle would survive a default `docker`-mode cleanup
indefinitely.

Smolmachines now has a real `cleanup` module (alongside
`enumerate.py` from issue #77) that walks:

  - smolvm machines named `claude-bottle-*` (via
    `smolvm machine ls --json`)
  - bundle containers `claude-bottle-sidecars-*`
  - bundle networks `claude-bottle-bundle-*`

Cleanup runs stop+delete on the machines, force-rm on the
containers, network rm on the networks. Each step is best-effort
so a failed rm doesn't block the rest.

`cli.py cleanup` walks every backend in `known_backend_names()`
and runs each backend's `cleanup` after a single y/N prompt that
shows a combined plan.

State dirs (`~/.claude-bottle/state/<slug>/`) are shared layout
with the docker backend, which still owns the orphan-state-dir
bucket. It now consults `enumerate_active_bottles()` for the
cross-backend live identity set so a running smolmachines
bottle's state dir isn't reaped during a cleanup.

Tests: smolmachines cleanup (prepare + cleanup ordering + failure
handling); cross-backend orphan protection on the docker
state-dir check; CLI cmd_cleanup walks both backends, short-
circuits on all-empty, aborts on N. 617 unit tests pass.

End-to-end verified on this host:
  $ smolvm machine ls --json | jq '.[].name'
  "claude-bottle-researcher-m3hxd"
  $ ./cli.py cleanup
  --- smolmachines backend ---
  smolvm machine:  claude-bottle-researcher-m3hxd
  remove all of the above? [y/N]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:45:38 -04:00
didericis-claude 5e0130b56f fix(smolmachines): build agent image in launch, not prepare
test / unit (push) Successful in 26s
test / integration (push) Successful in 43s
When starting a smolmachines agent from the dashboard the
docker-build output rendered on top of the curses preflight
modal — the build was kicked off before the operator had
confirmed launch. The docker backend's `prepare` is pure
resolution (no docker calls); smolmachines was inconsistent
because `prepare` called `_ensure_smolmachine` which ran
`docker build` → `docker save` → `crane push` → `smolvm pack
create`, several seconds of stderr noise rendered before the
y/N prompt.

Move the pipeline:

- `_ensure_smolmachine` (+ `_SMOLMACHINE_CACHE_DIR` + `_REPO_DIR`
  + the local-registry / smolvm imports) moves from
  `backend/smolmachines/prepare.py` to
  `backend/smolmachines/launch.py`. Called right before
  `_smolvm.machine_create` so the resulting `.smolmachine`
  sidecar path lands as a local in `launch`, not on the plan.

- `SmolmachinesBottlePlan.agent_from_path: Path` becomes
  `agent_image_ref: str`. `prepare` stashes only the docker tag
  (`$CLAUDE_BOTTLE_IMAGE` || `claude-bottle:latest`); `launch`
  resolves it into the artifact at bringup.

This puts smolmachines on the same prepare-vs-launch boundary
the docker backend uses: the preflight summary in the dashboard
prints, the operator confirms, then `launch` runs — and its
stderr is routed via `_route_op_to_right_pane` (in tmux) or via
`curses.endwin` (foreground handoff) so the build output lands
cleanly.

Tests:
- `tests/unit/test_smolmachines_prepare_image.py` →
  `tests/unit/test_smolmachines_launch_image.py`, updated to
  import `_ensure_smolmachine` from `launch` rather than
  `prepare`.
- `test_smolmachines_provision.py`: plan fixture switches
  `agent_from_path` → `agent_image_ref`.

593 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:44:53 -04:00
13 changed files with 655 additions and 141 deletions
+23 -4
View File
@@ -83,11 +83,18 @@ def _list_prefixed_networks() -> list[str]:
return sorted(set(out)) return sorted(set(out))
def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]: def _list_orphan_state_dirs(
live_projects: set[str], protected_identities: set[str],
) -> list[str]:
"""State identities whose compose project isn't running and """State identities whose compose project isn't running and
that don't have a `.preserve` marker. `.preserve` means the that don't have a `.preserve` marker. `.preserve` means the
user (or an auto-preserve-on-crash) wants the state kept for user (or an auto-preserve-on-crash) wants the state kept for
`resume`.""" `resume`.
`protected_identities` is the set of slugs that are live in
ANY backend — used so this docker-side check doesn't reap a
running smolmachines bottle's state dir (the layout is shared
across both backends)."""
state_root = _supervise.claude_bottle_root() / "state" state_root = _supervise.claude_bottle_root() / "state"
if not state_root.is_dir(): if not state_root.is_dir():
return [] return []
@@ -99,6 +106,8 @@ def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]:
project = f"{COMPOSE_PROJECT_PREFIX}{identity}" project = f"{COMPOSE_PROJECT_PREFIX}{identity}"
if project in live_projects: if project in live_projects:
continue continue
if identity in protected_identities:
continue
if is_preserved(identity): if is_preserved(identity):
continue continue
orphans.append(identity) orphans.append(identity)
@@ -106,15 +115,25 @@ def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]:
def prepare_cleanup() -> DockerBottleCleanupPlan: def prepare_cleanup() -> DockerBottleCleanupPlan:
"""Enumerate everything cleanup will touch. No removals.""" """Enumerate everything cleanup will touch. No removals.
Pulls the union of live identities across backends via
`enumerate_active_agents()` so the orphan-state-dir bucket
doesn't include slugs whose smolmachines VM is still up."""
docker_mod.require_docker() docker_mod.require_docker()
projects = list_compose_projects() projects = list_compose_projects()
project_set = set(projects) project_set = set(projects)
# Late import to avoid a circular at module-load time —
# the backend package's __init__ imports this module.
from .. import enumerate_active_agents
protected = {a.slug for a in enumerate_active_agents()}
return DockerBottleCleanupPlan( return DockerBottleCleanupPlan(
projects=tuple(projects), projects=tuple(projects),
stray_containers=tuple(_list_prefixed_containers()), stray_containers=tuple(_list_prefixed_containers()),
stray_networks=tuple(_list_prefixed_networks()), stray_networks=tuple(_list_prefixed_networks()),
orphan_state_dirs=tuple(_list_orphan_state_dirs(project_set)), orphan_state_dirs=tuple(
_list_orphan_state_dirs(project_set, protected),
),
) )
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Generator, Sequence from typing import Generator, Sequence
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleSpec
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
from . import prepare as _prepare from . import prepare as _prepare
@@ -76,12 +77,10 @@ class SmolmachinesBottleBackend(
_supervise.provision_supervise(plan, target) _supervise.provision_supervise(plan, target)
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan: def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
return SmolmachinesBottleCleanupPlan() return _cleanup.prepare_cleanup()
def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None: def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None:
del plan _cleanup.cleanup(plan)
# Nothing to clean in chunks 1-3 — see
# SmolmachinesBottleCleanupPlan docstring.
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
@@ -1,13 +1,29 @@
"""SmolmachinesBottleCleanupPlan — concrete BottleCleanupPlan stub """SmolmachinesBottleCleanupPlan — concrete BottleCleanupPlan (issue #77).
(PRD 0023 chunk 1).
Chunk 1 always reports nothing-to-clean. Real enumeration — Tracks the resources `SmolmachinesBottleBackend.cleanup` will
orphaned smolvm machines, stranded gvproxy sockets, leftover remove:
sidecar bundle containers — lands in chunk 4 alongside the
integration-test sweep that exercises teardown.""" - machines: smolvm machines whose name starts with
`claude-bottle-` (running or stopped). Stopped +
deleted via `smolvm machine stop` + `machine delete -f`.
- bundles: docker containers `claude-bottle-sidecars-<slug>`
left over from a smolmachines bottle (the bundle's
port-forwards stay published on lo0 aliases until
the container is gone). Removed via `docker rm -f`.
- networks: docker networks `claude-bottle-bundle-<slug>`
attached to the bundles. Removed via
`docker network rm`.
Smolmachines state dirs live under the same `~/.claude-bottle/state/`
path the docker backend uses; the docker backend's
`prepare_cleanup` already enumerates orphan state dirs and is the
single source of truth for that bucket (consults
`enumerate_active_bottles()` so it doesn't reap a live
smolmachines bottle's dir)."""
from __future__ import annotations from __future__ import annotations
import sys
from dataclasses import dataclass from dataclasses import dataclass
from ...log import info from ...log import info
@@ -16,10 +32,24 @@ from .. import BottleCleanupPlan
@dataclass(frozen=True) @dataclass(frozen=True)
class SmolmachinesBottleCleanupPlan(BottleCleanupPlan): class SmolmachinesBottleCleanupPlan(BottleCleanupPlan):
def print(self) -> None: """Resources SmolmachinesBottleBackend.cleanup will remove.
info("smolmachines cleanup: nothing to remove (chunk 4 will " Produced by `prepare_cleanup`; sorted so the y/N output is
"enumerate orphan machines + gvproxy sockets)") stable."""
machines: tuple[str, ...] = ()
bundles: tuple[str, ...] = ()
networks: tuple[str, ...] = ()
@property @property
def empty(self) -> bool: def empty(self) -> bool:
return True return not self.machines and not self.bundles and not self.networks
def print(self) -> None:
print(file=sys.stderr)
for name in self.machines:
info(f"smolvm machine: {name}")
for name in self.bundles:
info(f"bundle container:{name}")
for name in self.networks:
info(f"bundle network: {name}")
print(file=sys.stderr)
@@ -48,7 +48,13 @@ class SmolmachinesBottlePlan(BottlePlan):
# (push to a registry first, or smolvm grows a docker-daemon # (push to a registry first, or smolvm grows a docker-daemon
# transport). # transport).
machine_name: str machine_name: str
agent_from_path: Path # Agent image ref (docker tag). `launch` runs the
# build → save → registry push → smolvm pack pipeline against
# this and feeds the resulting `.smolmachine` artifact to
# `machine_create --from`. The pipeline runs at launch time
# (not prepare time) so the docker build output doesn't garble
# the dashboard's preflight modal.
agent_image_ref: str
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since # In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist. # the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags. # Passed to `smolvm machine create` as `-e K=V` flags.
@@ -0,0 +1,159 @@
"""Cleanup + active-listing for the smolmachines backend (issue #77).
`prepare_cleanup` enumerates leftover smolmachines resources:
- smolvm machines (`smolvm machine ls --json`) whose name starts
with `claude-bottle-`.
- bundle docker containers (`claude-bottle-sidecars-<slug>`).
- bundle docker networks (`claude-bottle-bundle-<slug>`).
State dirs live under `~/.claude-bottle/state/<identity>/` —
shared layout with the docker backend, which has the single
orphan-state-dir enumerator (it already consults
`enumerate_active_agents()` so a live smolmachines bottle's dir
is preserved).
`cleanup` removes everything in the plan: stop + delete each VM,
force-rm each container, rm each network. Each step is
best-effort — a failure on one resource doesn't block the others."""
from __future__ import annotations
import json
import subprocess
from ...log import info, warn
from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
# Both names start with the same prefix the launcher uses.
_VM_PREFIX = "claude-bottle-"
_BUNDLE_PREFIX = _bundle.bundle_container_name("") # `claude-bottle-sidecars-`
_NETWORK_PREFIX = _bundle.bundle_network_name("") # `claude-bottle-bundle-`
def prepare_cleanup() -> SmolmachinesBottleCleanupPlan:
"""Enumerate every smolmachines-owned resource on the host.
No side effects. Returns an empty plan when smolvm isn't on
PATH (no machines to reap) — `cleanup` is a no-op in that
case too."""
machines = _list_claude_bottle_machines()
bundles = _list_bundle_containers()
networks = _list_bundle_networks()
return SmolmachinesBottleCleanupPlan(
machines=tuple(sorted(machines)),
bundles=tuple(sorted(bundles)),
networks=tuple(sorted(networks)),
)
def cleanup(plan: SmolmachinesBottleCleanupPlan) -> None:
"""Remove everything in the plan. Order matters: stop VMs
first (they hold ports on lo0 aliases via libkrun), then the
bundle containers (which hold the host port-forwards), then
the networks (which docker won't reap until the containers
are gone)."""
for name in plan.machines:
info(f"stopping smolvm machine {name}")
subprocess.run(
["smolvm", "machine", "stop", "--name", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
info(f"deleting smolvm machine {name}")
r = subprocess.run(
["smolvm", "machine", "delete", "-f", name],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
warn(
f"smolvm machine delete -f {name} failed: "
f"{(r.stderr or '').strip()}"
)
for name in plan.bundles:
info(f"removing bundle container {name}")
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
for name in plan.networks:
info(f"removing bundle network {name}")
r = subprocess.run(
["docker", "network", "rm", name],
capture_output=True, text=True, check=False,
)
if r.returncode != 0 and "no such network" not in (r.stderr or "").lower():
warn(
f"docker network rm {name} failed: "
f"{(r.stderr or '').strip()}"
)
def _list_claude_bottle_machines() -> list[str]:
"""All smolvm machines named `claude-bottle-*`, regardless of
state (running / stopped / created). Empty when smolvm isn't
installed."""
if not _smolvm.is_available():
return []
r = subprocess.run(
["smolvm", "machine", "ls", "--json"],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
return []
try:
machines = json.loads(r.stdout or "[]")
except json.JSONDecodeError:
return []
return [
m["name"] for m in machines
if isinstance(m, dict)
and m.get("name", "").startswith(_VM_PREFIX)
]
def _list_bundle_containers() -> list[str]:
"""All docker containers named `claude-bottle-sidecars-*`,
running or stopped. Empty when docker isn't installed."""
# Late import: `backend/__init__` imports this module
# transitively via the smolmachines backend.
from .. import has_backend
if not has_backend("docker"):
return []
r = subprocess.run(
["docker", "ps", "-a",
"--filter", f"name=^{_BUNDLE_PREFIX}",
"--format", "{{.Names}}"],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
return []
return [
line for line in (r.stdout or "").splitlines()
if line and line.startswith(_BUNDLE_PREFIX)
]
def _list_bundle_networks() -> list[str]:
"""All docker networks named `claude-bottle-bundle-*`. Empty
when docker isn't installed."""
from .. import has_backend
if not has_backend("docker"):
return []
r = subprocess.run(
["docker", "network", "ls",
"--filter", f"name={_NETWORK_PREFIX}",
"--format", "{{.Name}}"],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
return []
return [
line for line in (r.stdout or "").splitlines()
if line and line.startswith(_NETWORK_PREFIX)
]
+78 -6
View File
@@ -23,6 +23,7 @@ import dataclasses
import os import os
import time import time
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
@@ -32,6 +33,7 @@ from ...pipelock import (
) )
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker import util as docker_mod
from ..docker.egress import ( from ..docker.egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER, EGRESS_PIPELOCK_CA_IN_CONTAINER,
@@ -55,6 +57,18 @@ from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle from .bottle import SmolmachinesBottle
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .local_registry import crane_push_tarball, ephemeral_registry
# Repo root, used as the `docker build` context for the agent image.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
# Per-host cache for `smolvm pack create` outputs. Keyed by the
# docker image ID so a Dockerfile change automatically invalidates
# the cache. `pack create` is idempotent on the smolvm side but
# takes several seconds even on a no-op rebuild.
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "claude-bottle" / "smolmachines"
# Container-internal listening ports for each bundle daemon. The # Container-internal listening ports for each bundle daemon. The
@@ -199,17 +213,25 @@ def launch(
agent_supervise_url=agent_supervise_url, agent_supervise_url=agent_supervise_url,
) )
# 5. smolvm VM. --from carries the pre-packed .smolmachine # 5. Build the agent image and pack it into a
# artifact (built by prepare); --allow-cidr + -e carry the # `.smolmachine` artifact (or hit the per-Dockerfile-digest
# per-bottle TSI allowlist + env. The allowlist is the # cache). Runs here, not in prepare, so the docker-build
# per-bottle loopback alias — narrowing it to one /32 keeps # output doesn't garble the dashboard's preflight modal:
# the agent from reaching other host loopback services or # both the curses-endwin path and the tmux pane-routing
# path redirect stderr around `launch` already.
agent_from_path = _ensure_smolmachine(plan.agent_image_ref)
# smolvm VM. --from carries the pre-packed .smolmachine
# artifact; --allow-cidr + -e carry the per-bottle TSI
# allowlist + env. The allowlist is the per-bottle
# loopback alias — narrowing it to one /32 keeps the
# agent from reaching other host loopback services or
# other bottles' published ports. Smolfile isn't usable # other bottles' published ports. Smolfile isn't usable
# here — smolvm 0.8.0 makes `--from` and `--smolfile` # here — smolvm 0.8.0 makes `--from` and `--smolfile`
# mutually exclusive. # mutually exclusive.
_smolvm.machine_create( _smolvm.machine_create(
plan.machine_name, plan.machine_name,
from_path=plan.agent_from_path, from_path=agent_from_path,
allow_cidrs=[f"{loopback_ip}/32"], allow_cidrs=[f"{loopback_ip}/32"],
env=plan.guest_env, env=plan.guest_env,
) )
@@ -389,3 +411,53 @@ def _resolve_token_env(
if not ep.routes: if not ep.routes:
return {} return {}
return egress_resolve_token_values(ep.token_env_map, dict(host_env)) return egress_resolve_token_values(ep.token_env_map, dict(host_env))
def _ensure_smolmachine(image_ref: str) -> Path:
"""Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
ID (so a Dockerfile change automatically invalidates the cache).
Returns the `.smolmachine.smolmachine` sidecar path that's
the file `machine create --from` consumes (pack create produces
a launcher binary at `.smolmachine` plus the sidecar alongside
it; the sidecar is the actual artifact).
Conversion path: `docker build` (the existing layer cache
makes no-change rebuilds cheap) `docker save` to a tarball
spin up an ephemeral registry on a private docker network
`crane push --insecure` from a one-shot container on the same
network `smolvm pack create --image localhost:<host port>/...`
tear down the registry + network. The crane push detour
sidesteps the Docker-Desktop daemon's HTTPS preference for
non-loopback registries see the `local_registry` module
docstring for the gory details.
Each pack-create costs several seconds even on a hot cache,
so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
# keep filenames manageable, long enough to make collisions
# astronomically unlikely.
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
binary = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine"
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if sidecar.is_file():
return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
docker_mod.save(image_ref, str(tarball))
try:
with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}"
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary)
finally:
# Tarball is ~500MB-1GB for the agent image; reclaim once
# the smolmachine artifact exists. The artifact itself is
# the long-lived cache entry.
tarball.unlink(missing_ok=True)
return sidecar
+9 -79
View File
@@ -1,12 +1,10 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c). """smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
Resolves the per-bottle docker subnet + bundle IP, builds the Resolves the per-bottle docker subnet + bundle IP and assembles
agent's docker image from the repo Dockerfile, converts it into a the guest env. The agent's docker image build → smolmachine
`.smolmachine` artifact via an ephemeral local registry (smolvm's pack pipeline runs in `launch.launch`, not here, so the
crane backend only reads registry refs), and assembles the guest dashboard's preflight modal isn't garbled by docker-build output
env. The `.smolmachine` is cached under before the operator has confirmed.
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
ID so Dockerfile changes invalidate the cache automatically.
No VM bringup that's `launch.launch`'s job.""" No VM bringup that's `launch.launch`'s job."""
@@ -17,7 +15,6 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...backend import BottleSpec from ...backend import BottleSpec
from ...backend.docker import util as docker_mod
from ...backend.docker.bottle_state import ( from ...backend.docker.bottle_state import (
BottleMetadata, BottleMetadata,
agent_state_dir, agent_state_dir,
@@ -32,23 +29,10 @@ from ...egress import Egress
from ...git_gate import GitGate from ...git_gate import GitGate
from ...pipelock import PipelockProxy from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from . import smolvm as _smolvm
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .local_registry import crane_push_tarball, ephemeral_registry
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Repo root, used as the `docker build` context for the agent image.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
# Per-host cache for `smolvm pack create` outputs. Keyed by the
# image ref so re-prepares for the same image hit the cache
# (pack create is idempotent on the smolvm side but takes several
# seconds even when no layer is fetched).
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "claude-bottle" / "smolmachines"
# Gateway ports the bundle exposes inside its container — pipelock # Gateway ports the bundle exposes inside its container — pipelock
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent # HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent
# inside the smolvm guest dials these on the bundle's pinned IP. # inside the smolvm guest dials these on the bundle's pinned IP.
@@ -158,16 +142,12 @@ def resolve_plan(
prompt_file.chmod(0o600) prompt_file.chmod(0o600)
machine_name = f"claude-bottle-{slug}" machine_name = f"claude-bottle-{slug}"
# Build the agent image from the repo Dockerfile (shared with # Stash the agent image ref — `launch.launch` runs the
# the docker backend, layer-cached) and convert it into a # build → pack pipeline at bringup. Honors CLAUDE_BOTTLE_IMAGE
# `.smolmachine` artifact via an ephemeral local registry. The # to match the docker backend's `resolve_plan` default.
# CLAUDE_BOTTLE_IMAGE env var match the docker backend's
# resolve_plan default so both backends use the same image when
# one is built.
agent_image_ref = os.environ.get( agent_image_ref = os.environ.get(
"CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest" "CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest"
) )
agent_from_path = _ensure_smolmachine(agent_image_ref)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
@@ -177,7 +157,7 @@ def resolve_plan(
bundle_gateway=gateway, bundle_gateway=gateway,
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
machine_name=machine_name, machine_name=machine_name,
agent_from_path=agent_from_path, agent_image_ref=agent_image_ref,
guest_env=guest_env, guest_env=guest_env,
prompt_file=prompt_file, prompt_file=prompt_file,
proxy_plan=proxy_plan, proxy_plan=proxy_plan,
@@ -185,53 +165,3 @@ def resolve_plan(
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
) )
def _ensure_smolmachine(image_ref: str) -> Path:
"""Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker image
ID (so a Dockerfile change automatically invalidates the cache).
Returns the `.smolmachine.smolmachine` sidecar path that's
the file `machine create --from` consumes (pack create produces
a launcher binary at `.smolmachine` plus the sidecar alongside
it; the sidecar is the actual artifact).
Conversion path: `docker build` (the existing layer cache
makes no-change rebuilds cheap) `docker save` to a tarball
spin up an ephemeral registry on a private docker network
`crane push --insecure` from a one-shot container on the same
network `smolvm pack create --image localhost:<host port>/...`
tear down the registry + network. The crane push detour
sidesteps the Docker-Desktop daemon's HTTPS preference for
non-loopback registries see the `local_registry` module
docstring for the gory details.
Each pack-create costs several seconds even on a hot cache,
so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
# keep filenames manageable, long enough to make collisions
# astronomically unlikely.
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
binary = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine"
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if sidecar.is_file():
return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar"
docker_mod.save(image_ref, str(tarball))
try:
with ephemeral_registry() as handle:
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}"
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary)
finally:
# Tarball is ~500MB-1GB for the agent image; reclaim once
# the smolmachine artifact exists. The artifact itself is
# the long-lived cache entry.
tarball.unlink(missing_ok=True)
return sidecar
+27 -10
View File
@@ -1,11 +1,16 @@
"""cleanup: stop and remove all orphaned claude-bottle resources. """cleanup: stop and remove all orphaned claude-bottle resources.
PRD 0018 chunk 4: backend's prepare_cleanup carries everything in Walks every registered backend (docker + smolmachines) so a single
one plan live compose projects (whose `compose down` removes `./cli.py cleanup` reaps both backends' leftovers — orphaned
containers + networks atomically), legacy stray containers/networks smolvm machines won't survive a docker-only cleanup pass (issue
that aren't in any project, and orphan state dirs (per-bottle addressed alongside #77).
state with no live project AND no `.preserve` marker). One prompt,
one cleanup call. Each backend's `prepare_cleanup` enumerates its own resources;
docker's `_list_orphan_state_dirs` consults
`enumerate_active_agents()` for the union of live identities so
state dirs of running smolmachines bottles aren't reaped. State
dirs are shared layout, so docker is the single owner of that
bucket.
State dirs with `.preserve` are intentionally never touched they State dirs with `.preserve` are intentionally never touched they
hold capability-block rebuilds or crash snapshots the operator may hold capability-block rebuilds or crash snapshots the operator may
@@ -17,24 +22,36 @@ from __future__ import annotations
import sys import sys
from ..backend import get_bottle_backend from ..backend import get_bottle_backend, known_backend_names
from ..log import info from ..log import info
from ._common import read_tty_line from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int: def cmd_cleanup(_argv: list[str]) -> int:
backend = get_bottle_backend() # Order: stable backend iteration so the y/N output is
plan = backend.prepare_cleanup() # deterministic across runs.
plans = [
(name, get_bottle_backend(name)) for name in known_backend_names()
]
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
if plan.empty: if all(p.empty for _, _, p in prepared):
info("no claude-bottle resources to clean up") info("no claude-bottle resources to clean up")
return 0 return 0
for name, _, plan in prepared:
if plan.empty:
continue
info(f"--- {name} backend ---")
plan.print() plan.print()
if not _prompt_yes("remove all of the above?"): if not _prompt_yes("remove all of the above?"):
info("cleanup: skipped") info("cleanup: skipped")
return 0 return 0
for name, backend, plan in prepared:
if plan.empty:
continue
backend.cleanup(plan) backend.cleanup(plan)
info("cleanup: done") info("cleanup: done")
return 0 return 0
@@ -0,0 +1,105 @@
"""Unit: `cli.py cleanup` walks every backend (issue follow-up).
Asserts cmd_cleanup queries each backend's `prepare_cleanup`,
combines the y/N output, and runs each backend's `cleanup` when
the operator confirms. Mocks the backends and stdin."""
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch, MagicMock
from claude_bottle.cli import cleanup as cmd
def _make_backend(empty: bool = True):
backend = MagicMock()
plan = MagicMock(empty=empty)
backend.prepare_cleanup.return_value = plan
backend.cleanup = MagicMock()
return backend, plan
class TestCmdCleanup(unittest.TestCase):
def test_iterates_every_backend(self):
docker, docker_plan = _make_backend(empty=False)
smol, smol_plan = _make_backend(empty=False)
backends_by_name = {"docker": docker, "smolmachines": smol}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
self.assertEqual(0, cmd.cmd_cleanup([]))
docker.prepare_cleanup.assert_called_once()
smol.prepare_cleanup.assert_called_once()
docker.cleanup.assert_called_once_with(docker_plan)
smol.cleanup.assert_called_once_with(smol_plan)
def test_short_circuits_when_all_empty(self):
docker, _ = _make_backend(empty=True)
smol, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "smolmachines": smol}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes",
) as prompt:
self.assertEqual(0, cmd.cmd_cleanup([]))
prompt.assert_not_called()
docker.cleanup.assert_not_called()
smol.cleanup.assert_not_called()
def test_abort_at_prompt_runs_nothing(self):
docker, _ = _make_backend(empty=False)
smol, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "smolmachines": smol}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes", return_value=False,
):
self.assertEqual(0, cmd.cmd_cleanup([]))
docker.cleanup.assert_not_called()
smol.cleanup.assert_not_called()
def test_skips_empty_plans_when_others_have_work(self):
# docker has work, smolmachines doesn't — only docker.cleanup
# is called.
docker, docker_plan = _make_backend(empty=False)
smol, _ = _make_backend(empty=True)
backends_by_name = {"docker": docker, "smolmachines": smol}
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "smolmachines"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name],
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
cmd.cmd_cleanup([])
docker.cleanup.assert_called_once_with(docker_plan)
smol.cleanup.assert_not_called()
if __name__ == "__main__":
unittest.main()
+31 -7
View File
@@ -44,7 +44,7 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
self._teardown_fake_home() self._teardown_fake_home()
def test_no_state_root_returns_empty(self): def test_no_state_root_returns_empty(self):
self.assertEqual([], _list_orphan_state_dirs(set())) self.assertEqual([], _list_orphan_state_dirs(set(), set()))
def test_state_dir_with_no_live_no_preserve_is_orphan(self): def test_state_dir_with_no_live_no_preserve_is_orphan(self):
# Just touch the dir; no metadata, no preserve marker — the # Just touch the dir; no metadata, no preserve marker — the
@@ -52,7 +52,7 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
bottle_state.write_per_bottle_dockerfile("solo-aaa", "FROM x\n") bottle_state.write_per_bottle_dockerfile("solo-aaa", "FROM x\n")
self.assertEqual( self.assertEqual(
["solo-aaa"], ["solo-aaa"],
_list_orphan_state_dirs(set()), _list_orphan_state_dirs(set(), set()),
) )
def test_live_project_skips_dir(self): def test_live_project_skips_dir(self):
@@ -61,7 +61,7 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
bottle_state.write_per_bottle_dockerfile("live-bbb", "FROM x\n") bottle_state.write_per_bottle_dockerfile("live-bbb", "FROM x\n")
self.assertEqual( self.assertEqual(
[], [],
_list_orphan_state_dirs({"claude-bottle-live-bbb"}), _list_orphan_state_dirs({"claude-bottle-live-bbb"}, set()),
) )
def test_preserve_marker_skips_dir(self): def test_preserve_marker_skips_dir(self):
@@ -71,14 +71,14 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
bottle_state.mark_preserved("kept-ccc") bottle_state.mark_preserved("kept-ccc")
self.assertEqual( self.assertEqual(
[], [],
_list_orphan_state_dirs(set()), _list_orphan_state_dirs(set(), set()),
) )
def test_preserve_overrides_no_live_project(self): def test_preserve_overrides_no_live_project(self):
# Even without a live project, a preserve marker keeps it. # Even without a live project, a preserve marker keeps it.
bottle_state.write_per_bottle_dockerfile("kept-ddd", "FROM x\n") bottle_state.write_per_bottle_dockerfile("kept-ddd", "FROM x\n")
bottle_state.mark_preserved("kept-ddd") bottle_state.mark_preserved("kept-ddd")
self.assertEqual([], _list_orphan_state_dirs(set())) self.assertEqual([], _list_orphan_state_dirs(set(), set()))
def test_mixed_set_categorized_correctly(self): def test_mixed_set_categorized_correctly(self):
bottle_state.write_per_bottle_dockerfile("orphan-eee", "FROM x\n") bottle_state.write_per_bottle_dockerfile("orphan-eee", "FROM x\n")
@@ -86,7 +86,7 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
bottle_state.write_per_bottle_dockerfile("kept-ggg", "FROM z\n") bottle_state.write_per_bottle_dockerfile("kept-ggg", "FROM z\n")
bottle_state.mark_preserved("kept-ggg") bottle_state.mark_preserved("kept-ggg")
result = _list_orphan_state_dirs({"claude-bottle-live-fff"}) result = _list_orphan_state_dirs({"claude-bottle-live-fff"}, set())
self.assertEqual(["orphan-eee"], result) self.assertEqual(["orphan-eee"], result)
def test_sorted_output(self): def test_sorted_output(self):
@@ -94,7 +94,31 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
bottle_state.write_per_bottle_dockerfile(name, "FROM x\n") bottle_state.write_per_bottle_dockerfile(name, "FROM x\n")
self.assertEqual( self.assertEqual(
["aaa-1", "mmm-1", "zzz-1"], ["aaa-1", "mmm-1", "zzz-1"],
_list_orphan_state_dirs(set()), _list_orphan_state_dirs(set(), set()),
)
def test_protected_identity_skips_dir(self):
# `protected_identities` carries slugs that are live in
# any backend (smolmachines included). docker's orphan
# detection respects them so a running smolmachines
# bottle's state dir isn't reaped while the VM is up.
bottle_state.write_per_bottle_dockerfile("smol-hhh", "FROM x\n")
self.assertEqual(
[],
_list_orphan_state_dirs(set(), {"smol-hhh"}),
)
def test_protected_overrides_no_live_project(self):
# A smolmachines bottle has no docker compose project but
# IS in the protected set; the absence of a project
# shouldn't cause a reap.
bottle_state.write_per_bottle_dockerfile("smol-iii", "FROM x\n")
self.assertEqual(
[],
_list_orphan_state_dirs(
{"claude-bottle-something-else"}, # different project up
{"smol-iii"}, # but smol-iii is live
),
) )
+148
View File
@@ -0,0 +1,148 @@
"""Unit: smolmachines backend cleanup (`cleanup.py` +
`bottle_cleanup_plan.py`).
Tests mock `subprocess.run` + `has_backend` so they execute
without docker / smolvm on PATH. Each cleanup step verifies argv
shape; teardown verifies order (machines bundles networks)."""
from __future__ import annotations
import subprocess
import unittest
from unittest.mock import patch
from claude_bottle import backend as backend_mod
from claude_bottle.backend.smolmachines import cleanup
from claude_bottle.backend.smolmachines.bottle_cleanup_plan import (
SmolmachinesBottleCleanupPlan,
)
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr=stderr,
)
class TestPrepareCleanup(unittest.TestCase):
def test_empty_when_nothing_running(self):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run") as run, \
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = True
run.return_value = _ok(stdout="[]")
plan = cleanup.prepare_cleanup()
self.assertTrue(plan.empty)
def test_lists_machines_bundles_networks(self):
def fake_run(argv, *a, **kw):
if argv[:3] == ["smolvm", "machine", "ls"]:
return _ok(stdout=(
'[{"name":"claude-bottle-a-1","state":"running"},'
' {"name":"claude-bottle-b-2","state":"created"},'
' {"name":"unrelated","state":"running"}]'
))
if argv[:2] == ["docker", "ps"]:
return _ok(stdout=(
"claude-bottle-sidecars-a-1\n"
"claude-bottle-sidecars-b-2\n"
))
if argv[:3] == ["docker", "network", "ls"]:
return _ok(stdout=(
"claude-bottle-bundle-a-1\n"
"claude-bottle-bundle-b-2\n"
))
return _ok()
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = True
plan = cleanup.prepare_cleanup()
# `unrelated` filtered out (no claude-bottle- prefix).
self.assertEqual(
("claude-bottle-a-1", "claude-bottle-b-2"),
plan.machines,
)
self.assertEqual(
("claude-bottle-sidecars-a-1", "claude-bottle-sidecars-b-2"),
plan.bundles,
)
self.assertEqual(
("claude-bottle-bundle-a-1", "claude-bottle-bundle-b-2"),
plan.networks,
)
def test_no_smolvm_means_no_machines(self):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run", return_value=_ok()), \
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = False
plan = cleanup.prepare_cleanup()
self.assertEqual((), plan.machines)
class TestCleanup(unittest.TestCase):
def test_machines_stopped_then_deleted_then_bundles_then_networks(self):
plan = SmolmachinesBottleCleanupPlan(
machines=("claude-bottle-a-1",),
bundles=("claude-bottle-sidecars-a-1",),
networks=("claude-bottle-bundle-a-1",),
)
calls: list[list[str]] = []
def fake_run(argv, *a, **kw):
calls.append(list(argv[:4]))
return _ok()
with patch.object(cleanup.subprocess, "run", side_effect=fake_run):
cleanup.cleanup(plan)
# Stop precedes delete precedes bundle rm precedes network rm.
self.assertEqual(
["smolvm", "machine", "stop", "--name"], calls[0],
)
self.assertEqual(
["smolvm", "machine", "delete", "-f"], calls[1],
)
self.assertEqual(
["docker", "rm", "-f", "claude-bottle-sidecars-a-1"], calls[2],
)
self.assertEqual(
["docker", "network", "rm", "claude-bottle-bundle-a-1"], calls[3],
)
def test_failures_are_warnings_not_fatal(self):
# smolvm machine delete -f returning non-zero should warn
# but continue with bundles + networks. The cleanup is
# idempotent on success and tries to remove every resource.
plan = SmolmachinesBottleCleanupPlan(
machines=("claude-bottle-a-1",),
bundles=("claude-bottle-sidecars-a-1",),
networks=(),
)
results = iter([
_ok(), # stop succeeds
subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="boom"), # delete fails
_ok(), # bundle rm succeeds
])
def fake_run(argv, *a, **kw):
return next(results)
with patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
patch.object(cleanup, "warn") as warn:
cleanup.cleanup(plan)
# warn called once for the delete failure.
warn.assert_called_once()
def test_empty_plan_is_noop(self):
plan = SmolmachinesBottleCleanupPlan()
with patch.object(cleanup.subprocess, "run") as run:
cleanup.cleanup(plan)
run.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -4,7 +4,12 @@
Asserts that the cache-hit path returns without touching the Asserts that the cache-hit path returns without touching the
registry / pack pipeline, and that the cache-miss path runs registry / pack pipeline, and that the cache-miss path runs
build tag push pack in order against a registry port the build tag push pack in order against a registry port the
helper yields.""" helper yields.
The pipeline lives in `launch.py` (moved from `prepare.py` so the
docker build doesn't run before the dashboard's preflight modal;
the curses-endwin / tmux pane-routing handoff happens around
`launch`)."""
from __future__ import annotations from __future__ import annotations
@@ -13,14 +18,14 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from claude_bottle.backend.smolmachines import prepare as _prepare from claude_bottle.backend.smolmachines import launch as _launch_mod
class TestEnsureSmolmachine(unittest.TestCase): class TestEnsureSmolmachine(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-cache.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-cache.")
self._cache_patch = patch.object( self._cache_patch = patch.object(
_prepare, "_SMOLMACHINE_CACHE_DIR", Path(self._tmp.name), _launch_mod, "_SMOLMACHINE_CACHE_DIR", Path(self._tmp.name),
) )
self._cache_patch.start() self._cache_patch.start()
@@ -35,20 +40,20 @@ class TestEnsureSmolmachine(unittest.TestCase):
sidecar.write_text("") sidecar.write_text("")
with patch.object( with patch.object(
_prepare.docker_mod, "build_image", _launch_mod.docker_mod, "build_image",
) as build, patch.object( ) as build, patch.object(
_prepare.docker_mod, "image_id", _launch_mod.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff", return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object( ), patch.object(
_prepare.docker_mod, "save", _launch_mod.docker_mod, "save",
) as save, patch.object( ) as save, patch.object(
_prepare, "ephemeral_registry", _launch_mod, "ephemeral_registry",
) as registry, patch.object( ) as registry, patch.object(
_prepare, "crane_push_tarball", _launch_mod, "crane_push_tarball",
) as push, patch.object( ) as push, patch.object(
_prepare._smolvm, "pack_create", _launch_mod._smolvm, "pack_create",
) as pack: ) as pack:
result = _prepare._ensure_smolmachine("claude-bottle:latest") result = _launch_mod._ensure_smolmachine("claude-bottle:latest")
self.assertEqual(sidecar, result) self.assertEqual(sidecar, result)
# build still runs (Dockerfile edits land without manual rmi). # build still runs (Dockerfile edits land without manual rmi).
@@ -88,25 +93,25 @@ class TestEnsureSmolmachine(unittest.TestCase):
return _f return _f
with patch.object( with patch.object(
_prepare.docker_mod, "build_image", _launch_mod.docker_mod, "build_image",
side_effect=record("build"), side_effect=record("build"),
), patch.object( ), patch.object(
_prepare.docker_mod, "image_id", _launch_mod.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff", return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object( ), patch.object(
_prepare.docker_mod, "save", _launch_mod.docker_mod, "save",
side_effect=record("save"), side_effect=record("save"),
) as save, patch.object( ) as save, patch.object(
_prepare, "ephemeral_registry", _launch_mod, "ephemeral_registry",
return_value=_Reg(), return_value=_Reg(),
), patch.object( ), patch.object(
_prepare, "crane_push_tarball", _launch_mod, "crane_push_tarball",
side_effect=record("push"), side_effect=record("push"),
) as push, patch.object( ) as push, patch.object(
_prepare._smolvm, "pack_create", _launch_mod._smolvm, "pack_create",
side_effect=record("pack"), side_effect=record("pack"),
) as pack: ) as pack:
_prepare._ensure_smolmachine("claude-bottle:latest") _launch_mod._ensure_smolmachine("claude-bottle:latest")
# Build → save → push → pack in that order. No `docker # Build → save → push → pack in that order. No `docker
# push` (the daemon's HTTPS-by-default path is what we're # push` (the daemon's HTTPS-by-default path is what we're
+1 -1
View File
@@ -90,7 +90,7 @@ def _plan(
bundle_gateway="192.168.50.1", bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
machine_name="claude-bottle-demo-abc12", machine_name="claude-bottle-demo-abc12",
agent_from_path=Path("/tmp/agent.smolmachine"), agent_image_ref="claude-bottle:latest",
guest_env={}, guest_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
proxy_plan=PipelockProxyPlan( proxy_plan=PipelockProxyPlan(