fix(smolmachines): commit via exec-tar instead of stop→pack
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.
This commit is contained in:
@@ -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 '<no stderr>'}"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user