From 921aceb515c6a683c197dfa1de32aa9d3a61f8fa Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 08:57:06 +0000 Subject: [PATCH] =?UTF-8?q?fix(smolmachines):=20commit=20via=20exec-tar=20?= =?UTF-8?q?instead=20of=20stop=E2=86=92pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smolvm pack create --from-vm requires the VM to be stopped, and stopping a smolmachines VM terminates any running interactive session. Instead, mirror the macos-container approach: exec into the running VM as root and stream the root filesystem via tar (smolvm machine exec -- tar), build a Docker image from the archive, push to an ephemeral local registry, and run smolvm pack create --image to produce the .smolmachine artifact. The VM stays running throughout the commit. Remove the stop-confirm prompt and machine_is_running check that were added in the previous commit — neither is needed when we no longer stop. --- bot_bottle/backend/smolmachines/freezer.py | 117 +++++++++++++++------ tests/unit/test_backend_freezer.py | 55 ++-------- 2 files changed, 94 insertions(+), 78 deletions(-) diff --git a/bot_bottle/backend/smolmachines/freezer.py b/bot_bottle/backend/smolmachines/freezer.py index 003228f..561a4d4 100644 --- a/bot_bottle/backend/smolmachines/freezer.py +++ b/bot_bottle/backend/smolmachines/freezer.py @@ -1,52 +1,101 @@ -"""SmolmachinesFreezer — snapshot a smolmachines bottle via smolvm pack.""" +"""SmolmachinesFreezer — snapshot a smolmachines bottle. + +`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm +removes VMs when stopped (same issue as Apple Container). Instead, exec +into the running VM as root and stream the root filesystem via tar, build +a Docker image from the archive, convert it to a smolmachine artifact via +the existing registry pipeline, and record the sidecar path. The VM stays +running throughout.""" from __future__ import annotations -import sys +import subprocess +import tempfile +from pathlib import Path from .. import ActiveAgent -from ..freeze import CommitCancelled, Freezer -from .smolvm import machine_is_running, machine_stop, pack_create_from_vm +from ..freeze import Freezer +from ..docker import util as docker_mod +from .local_registry import crane_push_tarball, ephemeral_registry +from .smolvm import pack_create from ...bottle_state import bottle_state_dir -from ...log import info - - -def _read_tty_line() -> str: - """Read one line from /dev/tty, falling back to stdin.""" - try: - with open("/dev/tty", "r", encoding="utf-8") as tty: - return tty.readline().rstrip("\n") - except OSError: - return sys.stdin.readline().rstrip("\n") +from ...log import die, info class SmolmachinesFreezer(Freezer): - """Freezes a smolmachines bottle via `smolvm pack create --from-vm`. + """Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack. - `smolvm pack create --from-vm` requires the VM to be stopped first. - If the VM is running the freezer prompts the user to confirm the stop - before proceeding. The VM remains stopped after commit; use - `./cli.py resume` to restart.""" + The VM is NOT stopped. smolvm machine exec streams the root filesystem + via tar; we build a Docker image from it and run the same image→registry→ + pack_create pipeline that _ensure_smolmachine uses for fresh builds.""" backend_name = "smolmachines" def _freeze(self, agent: ActiveAgent) -> str: machine = f"bot-bottle-{agent.slug}" - output = bottle_state_dir(agent.slug) / "committed-smolmachine" - output.parent.mkdir(parents=True, exist_ok=True) - if machine_is_running(machine): - sys.stderr.write( - f"bot-bottle: bottle {agent.slug!r} is running; " - "commit will stop it. Continue? [y/N] " - ) - sys.stderr.flush() - reply = _read_tty_line().strip().lower() - if reply not in ("y", "yes"): - raise CommitCancelled - machine_stop(machine) - pack_create_from_vm(machine, output) - artifact = output.with_name(f"{output.name}.smolmachine") - return str(artifact) + image_ref = f"bot-bottle-committed-{agent.slug}:latest" + output_dir = bottle_state_dir(agent.slug) + output_dir.mkdir(parents=True, exist_ok=True) + binary = output_dir / "committed-smolmachine" + sidecar = output_dir / "committed-smolmachine.smolmachine" + _snapshot_running_vm(machine, image_ref, binary) + return str(sidecar) def _export_hint(self, slug: str, image_ref: str) -> None: info(f"to export for migration: cp {image_ref} {slug}.smolmachine") + + +def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None: + """Exec-tar the running VM, build a Docker image, and pack to a smolmachine. + + binary: destination for the launcher (sibling .smolmachine is the artifact + that machine_create --from consumes, same convention as pack_create). + """ + with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp: + tmp_path = Path(tmp) + rootfs_tar = tmp_path / "rootfs.tar" + dockerfile = tmp_path / "Dockerfile" + + with open(rootfs_tar, "wb") as tar_out: + result = subprocess.run( + [ + "smolvm", "machine", "exec", + "--name", machine, "--", + "tar", "--create", + "--exclude=./proc", + "--exclude=./sys", + "--exclude=./dev", + "--exclude=./run", + "--file=-", + "--directory=/", + ".", + ], + stdout=tar_out, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + die( + f"smolvm exec tar {machine!r} failed: " + f"{(result.stderr or b'').decode().strip() or ''}" + ) + + dockerfile.write_text( + "FROM scratch\n" + "ADD rootfs.tar /\n" + "USER node\n" + "WORKDIR /home/node\n" + ) + docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile)) + + image_tarball = binary.parent / "committed.image.tar" + docker_mod.save(image_ref, str(image_tarball)) + try: + with ephemeral_registry() as handle: + digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16] + push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}" + pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}" + crane_push_tarball(handle, str(image_tarball), push_ref) + pack_create(pack_ref, binary) + finally: + image_tarball.unlink(missing_ok=True) diff --git a/tests/unit/test_backend_freezer.py b/tests/unit/test_backend_freezer.py index ca8c3db..9ed1cec 100644 --- a/tests/unit/test_backend_freezer.py +++ b/tests/unit/test_backend_freezer.py @@ -189,61 +189,28 @@ class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase): started_at="t", backend="smolmachines", )) - def test_packs_stopped_vm_directly(self): + def test_snapshots_running_vm_without_stopping(self): + """Commit should exec-tar the running VM, not stop it.""" slug = "dev-abc12" self._write_meta(slug) freezer = SmolmachinesFreezer() agent = _make_agent(slug, "smolmachines") - with patch("bot_bottle.backend.smolmachines.freezer.machine_is_running", - return_value=False), \ - patch("bot_bottle.backend.smolmachines.freezer.pack_create_from_vm") as mock_pack, \ + with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \ patch("bot_bottle.backend.freeze.info"), \ patch("bot_bottle.backend.smolmachines.freezer.info"): freezer.commit(agent) - expected_output = bottle_state.bottle_state_dir(slug) / "committed-smolmachine" - mock_pack.assert_called_once_with(f"bot-bottle-{slug}", expected_output) - expected_artifact = str(expected_output.with_name("committed-smolmachine.smolmachine")) - self.assertEqual(expected_artifact, bottle_state.read_committed_image(slug)) + expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine" + mock_snap.assert_called_once_with( + f"bot-bottle-{slug}", + f"bot-bottle-committed-{slug}:latest", + expected_binary, + ) + expected_sidecar = str(expected_binary.with_suffix(".smolmachine")) + self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug)) self.assertTrue(bottle_state.is_preserved(slug)) - def test_stops_running_vm_on_yes(self): - slug = "dev-abc12" - self._write_meta(slug) - freezer = SmolmachinesFreezer() - agent = _make_agent(slug, "smolmachines") - - with patch("bot_bottle.backend.smolmachines.freezer.machine_is_running", - return_value=True), \ - patch("bot_bottle.backend.smolmachines.freezer._read_tty_line", return_value="y"), \ - patch("bot_bottle.backend.smolmachines.freezer.machine_stop") as mock_stop, \ - patch("bot_bottle.backend.smolmachines.freezer.pack_create_from_vm") as mock_pack, \ - patch("bot_bottle.backend.freeze.info"), \ - patch("bot_bottle.backend.smolmachines.freezer.info"): - freezer.commit(agent) - - mock_stop.assert_called_once_with(f"bot-bottle-{slug}") - mock_pack.assert_called_once() - - def test_raises_commit_cancelled_on_no(self): - slug = "dev-abc12" - self._write_meta(slug) - freezer = SmolmachinesFreezer() - agent = _make_agent(slug, "smolmachines") - - with patch("bot_bottle.backend.smolmachines.freezer.machine_is_running", - return_value=True), \ - patch("bot_bottle.backend.smolmachines.freezer._read_tty_line", return_value="n"), \ - patch("bot_bottle.backend.smolmachines.freezer.machine_stop") as mock_stop, \ - patch("bot_bottle.backend.smolmachines.freezer.pack_create_from_vm") as mock_pack: - from bot_bottle.backend.freeze import CommitCancelled - with self.assertRaises(CommitCancelled): - freezer.commit(agent) - - mock_stop.assert_not_called() - mock_pack.assert_not_called() - if __name__ == "__main__": unittest.main()