From ccb2956562226edd06189d9d1393428579d530eb Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 08:18:18 +0000 Subject: [PATCH] =?UTF-8?q?fix(macos-container):=20commit=20via=20exec-tar?= =?UTF-8?q?=20instead=20of=20stop=E2=86=92export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Container removes containers when they stop, making the stop-then-export flow impossible regardless of the --rm flag. Replace `container export` (requires stopped container) with `container exec --user root tar --create ... --file=- --directory=/ .` streamed to a temp file, then build the committed image from that archive as before. The bottle stays running after commit, which is better UX. Drop the stop-confirm prompt from MacosContainerFreezer since we no longer need to stop the container at all. --- bot_bottle/backend/macos_container/freezer.py | 34 +++-------- bot_bottle/backend/macos_container/util.py | 38 +++++++++---- tests/unit/test_backend_freezer.py | 57 ++++--------------- 3 files changed, 43 insertions(+), 86 deletions(-) diff --git a/bot_bottle/backend/macos_container/freezer.py b/bot_bottle/backend/macos_container/freezer.py index c4dff1e..2fae40e 100644 --- a/bot_bottle/backend/macos_container/freezer.py +++ b/bot_bottle/backend/macos_container/freezer.py @@ -1,37 +1,26 @@ """MacosContainerFreezer — snapshot a macOS container bottle. -Apple's `container export` requires the container to be stopped first. -When the container is running the freezer prompts the user to confirm -the stop before proceeding.""" +Apple Container removes containers when they stop, making stop-then-export +impossible. Instead, commit_container execs into the running container and +streams the root filesystem via tar. The bottle continues running after commit. +""" from __future__ import annotations -import sys - from .. import ActiveAgent -from ..freeze import CommitCancelled, Freezer -from .util import commit_container, container_is_running, stop_container +from ..freeze import Freezer +from .util import commit_container from ...log import info class MacosContainerFreezer(Freezer): - """Freezes a macOS-container bottle via `container export` + image rebuild.""" + """Freezes a macOS-container bottle via exec-tar + image rebuild.""" backend_name = "macos-container" def _freeze(self, agent: ActiveAgent) -> str: container = f"bot-bottle-{agent.slug}" image_tag = f"bot-bottle-committed-{agent.slug}:latest" - if container_is_running(container): - 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 - stop_container(container) commit_container(container, image_tag) return image_tag @@ -40,12 +29,3 @@ class MacosContainerFreezer(Freezer): f"to export for migration: " f"container image save {image_ref} -o {slug}.tar" ) - - -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") diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py index 706401a..52ea20d 100644 --- a/bot_bottle/backend/macos_container/util.py +++ b/bot_bottle/backend/macos_container/util.py @@ -76,24 +76,38 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: def commit_container(container_name: str, image_tag: str) -> None: """Snapshot a running Apple Container as a local image. - Apple Container exposes filesystem export rather than Docker's - `commit` verb. Bot-bottle supplies command and environment at - launch time, so preserving the root filesystem is sufficient for a - resumable committed bottle image. + `container export` requires a stopped container, but Apple Container + removes containers when they stop, making stop-then-export impossible. + Instead, exec into the running container as root and stream the root + filesystem out via tar, then build a new image from that archive. + The bottle continues running after commit. """ with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp: rootfs_tar = os.path.join(tmp, "rootfs.tar") dockerfile = os.path.join(tmp, "Dockerfile") - result = subprocess.run( - [_CONTAINER, "export", "-o", rootfs_tar, container_name], - capture_output=True, - text=True, - check=False, - ) + with open(rootfs_tar, "wb") as tar_out: + result = subprocess.run( + [ + _CONTAINER, "exec", + "--user", "root", + container_name, + "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"container export {container_name!r} failed: " - f"{(result.stderr or '').strip() or ''}" + f"container exec tar {container_name!r} failed: " + f"{(result.stderr or b'').decode().strip() or ''}" ) with open(dockerfile, "w", encoding="utf-8") as f: f.write( diff --git a/tests/unit/test_backend_freezer.py b/tests/unit/test_backend_freezer.py index 36865f6..700eb3b 100644 --- a/tests/unit/test_backend_freezer.py +++ b/tests/unit/test_backend_freezer.py @@ -5,15 +5,11 @@ from __future__ import annotations import tempfile import unittest from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch from bot_bottle import supervise, bottle_state from bot_bottle.backend import ActiveAgent -from bot_bottle.backend.freeze import ( - CommitCancelled, - Freezer, - get_freezer, -) +from bot_bottle.backend.freeze import get_freezer from bot_bottle.backend.docker.freezer import DockerFreezer from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer @@ -157,15 +153,14 @@ class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase): started_at="t", backend="macos-container", )) - def test_commits_stopped_container(self): + def test_commits_running_container_without_stopping(self): + """Commit should exec-tar the running container, not stop it.""" slug = "dev-abc12" self._write_meta(slug) freezer = MacosContainerFreezer() agent = _make_agent(slug, "macos-container") - with patch("bot_bottle.backend.macos_container.freezer.container_is_running", - return_value=False), \ - patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \ + with patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \ patch("bot_bottle.backend.freeze.info"), \ patch("bot_bottle.backend.macos_container.freezer.info"): freezer.commit(agent) @@ -174,43 +169,11 @@ class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase): f"bot-bottle-{slug}", f"bot-bottle-committed-{slug}:latest", ) - - def test_stops_running_container_on_yes(self): - slug = "dev-abc12" - self._write_meta(slug) - freezer = MacosContainerFreezer() - agent = _make_agent(slug, "macos-container") - - with patch("bot_bottle.backend.macos_container.freezer.container_is_running", - return_value=True), \ - patch("bot_bottle.backend.macos_container.freezer._read_tty_line", - return_value="y"), \ - patch("bot_bottle.backend.macos_container.freezer.stop_container") as mock_stop, \ - patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \ - patch("bot_bottle.backend.freeze.info"), \ - patch("bot_bottle.backend.macos_container.freezer.info"): - freezer.commit(agent) - - mock_stop.assert_called_once_with(f"bot-bottle-{slug}") - mock_commit.assert_called_once() - - def test_raises_commit_cancelled_on_no(self): - slug = "dev-abc12" - self._write_meta(slug) - freezer = MacosContainerFreezer() - agent = _make_agent(slug, "macos-container") - - with patch("bot_bottle.backend.macos_container.freezer.container_is_running", - return_value=True), \ - patch("bot_bottle.backend.macos_container.freezer._read_tty_line", - return_value="n"), \ - patch("bot_bottle.backend.macos_container.freezer.stop_container") as mock_stop, \ - patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit: - with self.assertRaises(CommitCancelled): - freezer.commit(agent) - - mock_stop.assert_not_called() - mock_commit.assert_not_called() + self.assertEqual( + f"bot-bottle-committed-{slug}:latest", + bottle_state.read_committed_image(slug), + ) + self.assertTrue(bottle_state.is_preserved(slug)) class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):