fix(macos-container): commit via exec-tar instead of stop→export
lint / lint (push) Failing after 1m41s
test / unit (pull_request) Failing after 34s
test / integration (pull_request) Successful in 18s

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 <name> 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.
This commit is contained in:
2026-06-23 08:18:18 +00:00
parent f2f82910c9
commit 7fc5a3af66
3 changed files with 43 additions and 86 deletions
+7 -27
View File
@@ -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")
+26 -12
View File
@@ -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 '<no stderr>'}"
f"container exec tar {container_name!r} failed: "
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
)
with open(dockerfile, "w", encoding="utf-8") as f:
f.write(
+10 -47
View File
@@ -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):