feat(cli): add commit command to snapshot running bottle state
Adds `./cli.py commit [<slug>]` which runs `docker commit` on the active agent container and stores the resulting image tag in per-bottle state. The next `./cli.py resume <slug>` automatically boots from the committed snapshot instead of rebuilding from the Dockerfile, preserving all in-container state across restarts and migrations. - bottle_state: add write_committed_image / read_committed_image helpers - docker/util: add commit_container wrapper around `docker commit` - docker/launch: check for a committed image before the Dockerfile build step; fall back to normal build if the image is absent from the daemon - cli/commit: new command with interactive slug picker; errors clearly on non-Docker backends - 50 new unit tests covering all paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ from ...bottle_state import (
|
||||
bottle_state_dir,
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from .compose import (
|
||||
bottle_plan_to_compose,
|
||||
@@ -91,12 +92,22 @@ def launch(
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: agent image build. Sidecar images get built lazily by
|
||||
# `docker compose up` via the renderer's `build:` directives.
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
# Step 1: agent image. Use a committed snapshot when one exists
|
||||
# and is present in the local daemon; otherwise build from the
|
||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||
# up` via the renderer's `build:` directives.
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
else:
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
|
||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||
|
||||
@@ -152,6 +152,21 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
# )
|
||||
|
||||
|
||||
def commit_container(container_name: str, image_tag: str) -> None:
|
||||
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
||||
running container's filesystem state as a local Docker image."""
|
||||
result = subprocess.run(
|
||||
["docker", "commit", container_name, image_tag],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
info(f"committed {container_name!r} → {image_tag!r}")
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
"""Return the content-addressed image ID (e.g.
|
||||
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
|
||||
|
||||
@@ -43,6 +43,7 @@ from . import supervise as _supervise
|
||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||
_STATE_SUBDIR = "state"
|
||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||
_TRANSCRIPT_SUBDIR = "transcript"
|
||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||
# live here so chunk 3's `docker compose up` can find them at stable
|
||||
@@ -179,6 +180,32 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def committed_image_path(identity: str) -> Path:
|
||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
||||
|
||||
|
||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
||||
"""Persist the committed image tag for `identity`. The next
|
||||
`cli.py resume <identity>` will boot from this image instead of
|
||||
rebuilding from the Dockerfile."""
|
||||
path = committed_image_path(identity)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(image_tag.strip() + "\n")
|
||||
path.chmod(0o644)
|
||||
return path
|
||||
|
||||
|
||||
def read_committed_image(identity: str) -> str | None:
|
||||
"""Return the committed image tag for `identity`, or None if no
|
||||
commit has been recorded. Used by the Docker launch step to skip
|
||||
the Dockerfile build when a committed snapshot exists."""
|
||||
path = committed_image_path(identity)
|
||||
if not path.is_file():
|
||||
return None
|
||||
tag = path.read_text().strip()
|
||||
return tag or None
|
||||
|
||||
|
||||
def per_bottle_image_tag(identity: str) -> str:
|
||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
||||
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
||||
@@ -314,6 +341,7 @@ __all__ = [
|
||||
"bottle_state_dir",
|
||||
"cleanup_state",
|
||||
"clear_preserve_marker",
|
||||
"committed_image_path",
|
||||
"egress_state_dir",
|
||||
"git_gate_state_dir",
|
||||
"is_preserved",
|
||||
@@ -323,9 +351,11 @@ __all__ = [
|
||||
"per_bottle_dockerfile_path",
|
||||
"per_bottle_image_tag",
|
||||
"preserve_marker_path",
|
||||
"read_committed_image",
|
||||
"read_metadata",
|
||||
"supervise_state_dir",
|
||||
"transcript_snapshot_dir",
|
||||
"write_committed_image",
|
||||
"write_metadata",
|
||||
"write_per_bottle_dockerfile",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Main CLI dispatcher.
|
||||
|
||||
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
||||
Commands: cleanup, commit, edit, info, init, list, resume, start, supervise
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +12,7 @@ from ..manifest import ManifestError
|
||||
from ._common import PROG
|
||||
from . import list as _list_mod
|
||||
from .cleanup import cmd_cleanup
|
||||
from .commit import cmd_commit
|
||||
from .edit import cmd_edit
|
||||
from .info import cmd_info
|
||||
from .init import cmd_init
|
||||
@@ -23,6 +24,7 @@ cmd_list = _list_mod.cmd_list
|
||||
|
||||
COMMANDS = {
|
||||
"cleanup": cmd_cleanup,
|
||||
"commit": cmd_commit,
|
||||
"edit": cmd_edit,
|
||||
"info": cmd_info,
|
||||
"init": cmd_init,
|
||||
@@ -37,6 +39,7 @@ def usage() -> None:
|
||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||
sys.stderr.write("Commands:\n")
|
||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""commit: freeze a running Docker bottle's container state to a local image.
|
||||
|
||||
Runs `docker commit <container> <image-tag>` on the active agent
|
||||
container and stores the image tag in per-bottle state so the next
|
||||
`./cli.py resume <slug>` boots from that snapshot instead of
|
||||
rebuilding from the Dockerfile.
|
||||
|
||||
Only the Docker backend is supported. Smolmachines VMs have no
|
||||
container-level commit API in the current smolvm CLI surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from ..backend import enumerate_active_agents
|
||||
from ..backend.docker.util import commit_container
|
||||
from ..bottle_state import mark_preserved, read_metadata, write_committed_image
|
||||
from ..log import die, info
|
||||
from ._common import PROG
|
||||
from . import tui
|
||||
|
||||
|
||||
_COMMITTED_IMAGE_PREFIX = "bot-bottle-committed-"
|
||||
_DOCKER_BACKENDS = {"docker", ""}
|
||||
|
||||
|
||||
def _committed_image_tag(slug: str) -> str:
|
||||
return f"{_COMMITTED_IMAGE_PREFIX}{slug}:latest"
|
||||
|
||||
|
||||
def _agent_container_name(slug: str) -> str:
|
||||
return f"bot-bottle-{slug}"
|
||||
|
||||
|
||||
def cmd_commit(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
||||
parser.add_argument(
|
||||
"slug",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=(
|
||||
"bottle slug from `cli.py list active` "
|
||||
"(omit to pick interactively)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
slug = args.slug
|
||||
if slug is None:
|
||||
active = enumerate_active_agents()
|
||||
if not active:
|
||||
die("no active bottles; start one with `./cli.py start`")
|
||||
choices = [a.slug for a in active]
|
||||
slug = tui.filter_select(choices, title="Select bottle to commit")
|
||||
if slug is None:
|
||||
return 0
|
||||
|
||||
metadata = read_metadata(slug)
|
||||
backend = metadata.backend if metadata else ""
|
||||
if backend not in _DOCKER_BACKENDS:
|
||||
die(
|
||||
f"commit is only supported for the docker backend; "
|
||||
f"bottle {slug!r} uses {backend!r}"
|
||||
)
|
||||
|
||||
container = _agent_container_name(slug)
|
||||
image_tag = _committed_image_tag(slug)
|
||||
|
||||
commit_container(container, image_tag)
|
||||
write_committed_image(slug, image_tag)
|
||||
mark_preserved(slug)
|
||||
info(f"to resume from this snapshot: ./cli.py resume {slug}")
|
||||
info(f"to export for migration: docker save {image_tag} -o {slug}.tar")
|
||||
return 0
|
||||
@@ -0,0 +1,136 @@
|
||||
# PRD prd-new: Commit bottle state to an image
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-06-20
|
||||
- **Issue:** #194
|
||||
|
||||
## Summary
|
||||
|
||||
Add a `commit` CLI command that freezes a running Docker bottle's
|
||||
container state to a named Docker image. Operators can then resume the
|
||||
bottle from that exact filesystem snapshot, or export the image with
|
||||
`docker save` to migrate work to a different host.
|
||||
|
||||
## Problem
|
||||
|
||||
When a long-running agent session is interrupted — by a host reboot, a
|
||||
network failure, or a planned infrastructure migration — the in-progress
|
||||
container state is lost. `cli.py resume` rebuilds the agent image from
|
||||
the Dockerfile and reprovi-sions the bottle, but that returns the guest
|
||||
to its initial state, not to wherever the agent was mid-task.
|
||||
|
||||
There is no mechanism today to capture "what's installed / configured
|
||||
inside the running container right now" and make it reproducible. The
|
||||
`capability-block` flow writes a new Dockerfile and marks the bottle for
|
||||
resume, but that only applies when the agent itself has requested a
|
||||
capability change; it doesn't help the operator who wants to take a
|
||||
snapshot before a planned host reboot or hardware migration.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `./cli.py commit [<slug>]` takes a snapshot of the running Docker
|
||||
agent container and stores it as a local Docker image.
|
||||
- Without a slug argument the command shows the same interactive picker
|
||||
as `start` (the list of active slugs).
|
||||
- The committed image tag is stored in per-bottle state so that the next
|
||||
`./cli.py resume <slug>` automatically uses the committed image instead
|
||||
of rebuilding from the Dockerfile.
|
||||
- `mark_preserved` is called so the state dir survives the normal
|
||||
session-end cleanup.
|
||||
- A `docker save` hint is printed so operators know how to export the
|
||||
image for migration.
|
||||
- The command errors clearly on non-Docker backends (smolmachines does
|
||||
not expose a container-level commit API in its current CLI surface).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Smolmachines or macOS-container backend support.
|
||||
- Automatic commit on agent exit.
|
||||
- Image push to a remote registry.
|
||||
- Storing the image tag in the manifest or sharing it between operators.
|
||||
|
||||
## Design
|
||||
|
||||
### Image tag
|
||||
|
||||
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
|
||||
to match existing image naming conventions; `committed` distinguishes it
|
||||
from the build-time image (`bot-bottle-claude:latest`) and the
|
||||
capability-block rebuild image (`bot-bottle-rebuilt-<identity>:latest`).
|
||||
|
||||
### State storage
|
||||
|
||||
A new plain-text file `committed-image` is added to the per-bottle state
|
||||
directory:
|
||||
|
||||
```
|
||||
~/.bot-bottle/state/<identity>/
|
||||
metadata.json
|
||||
Dockerfile (capability-block override; optional)
|
||||
committed-image (committed image tag; optional)
|
||||
transcript/
|
||||
```
|
||||
|
||||
`bottle_state.committed_image_path(identity)` returns the path.
|
||||
`write_committed_image` / `read_committed_image` are the read/write
|
||||
helpers, matching the existing `per_bottle_dockerfile` pattern.
|
||||
|
||||
### `commit` command
|
||||
|
||||
```
|
||||
./cli.py commit [<slug>]
|
||||
```
|
||||
|
||||
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
|
||||
2. Check metadata: if `backend` is set and is not `docker`, die with a
|
||||
clear "not supported" error.
|
||||
3. Derive container name: `bot-bottle-<slug>` (matches the agent
|
||||
provision plan's `instance_name` convention).
|
||||
4. Run `docker commit <container> bot-bottle-committed-<slug>:latest`.
|
||||
5. Write the image tag to `~/.bot-bottle/state/<slug>/committed-image`.
|
||||
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
|
||||
7. Print the resume hint and a `docker save` export example.
|
||||
|
||||
### Resume from committed image
|
||||
|
||||
`bot_bottle/backend/docker/launch.py` already rebuilds the agent image
|
||||
at the top of the `launch` context manager. The change is a check
|
||||
immediately before that step:
|
||||
|
||||
```python
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
else:
|
||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
```
|
||||
|
||||
Replacing `agent_provision.image` propagates to `plan.image` (a
|
||||
property) and from there to the Compose spec renderer's `_agent_service`
|
||||
→ `image:` field, so the container boots from the committed snapshot.
|
||||
The build step is skipped entirely when a committed image is found and
|
||||
exists locally.
|
||||
|
||||
If the committed image has been deleted from the local daemon (e.g.
|
||||
after `docker rmi` or a `docker system prune`), the launch falls back
|
||||
to a normal Dockerfile build, matching the pre-commit behavior.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- Unit tests for `write_committed_image` / `read_committed_image` in
|
||||
`tests/unit/test_bottle_state.py`, using the existing `_FakeHomeMixin`
|
||||
pattern.
|
||||
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
|
||||
mocking `subprocess.run` and asserting on the `docker commit` argv.
|
||||
- Unit tests for `cmd_commit` argument parsing and the "unsupported
|
||||
backend" error path, mocking `enumerate_active_agents` and
|
||||
`commit_container`.
|
||||
- Unit tests for the launch-step committed-image branch: patch
|
||||
`read_committed_image` to return a tag, patch `image_exists` to return
|
||||
True, and assert that `build_image` is not called and `plan.image` is
|
||||
overridden.
|
||||
@@ -277,5 +277,56 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
self.assertEqual("", loaded.backend)
|
||||
|
||||
|
||||
class TestCommittedImage(_FakeHomeMixin, unittest.TestCase):
|
||||
"""write_committed_image / read_committed_image round-trip."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_returns_none_when_absent(self):
|
||||
self.assertIsNone(bottle_state.read_committed_image("dev"))
|
||||
|
||||
def test_write_then_read_roundtrip(self):
|
||||
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev:latest",
|
||||
bottle_state.read_committed_image("dev"),
|
||||
)
|
||||
|
||||
def test_strips_trailing_newline_on_read(self):
|
||||
path = bottle_state.committed_image_path("dev")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("bot-bottle-committed-dev:latest\n\n")
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev:latest",
|
||||
bottle_state.read_committed_image("dev"),
|
||||
)
|
||||
|
||||
def test_isolated_per_slug(self):
|
||||
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
|
||||
bottle_state.write_committed_image("api", "bot-bottle-committed-api:latest")
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev:latest",
|
||||
bottle_state.read_committed_image("dev"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-api:latest",
|
||||
bottle_state.read_committed_image("api"),
|
||||
)
|
||||
|
||||
def test_path_under_state_dir(self):
|
||||
path = bottle_state.committed_image_path("dev")
|
||||
self.assertTrue(str(path).endswith("/.bot-bottle/state/dev/committed-image"))
|
||||
|
||||
def test_empty_content_returns_none(self):
|
||||
path = bottle_state.committed_image_path("dev")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(" \n")
|
||||
self.assertIsNone(bottle_state.read_committed_image("dev"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Unit: cli.py commit command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from bot_bottle.cli.commit import cmd_commit, _committed_image_tag, _agent_container_name
|
||||
from bot_bottle import supervise
|
||||
from bot_bottle import bottle_state
|
||||
|
||||
|
||||
class _FakeHomeMixin:
|
||||
def _setup_fake_home(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.")
|
||||
original = supervise.bot_bottle_root
|
||||
|
||||
def fake_root() -> Path:
|
||||
return Path(self._tmp.name) / ".bot-bottle"
|
||||
|
||||
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
||||
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
|
||||
|
||||
def _teardown_fake_home(self):
|
||||
self._restore()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class TestCommitHelpers(unittest.TestCase):
|
||||
def test_committed_image_tag(self):
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev-abc12:latest",
|
||||
_committed_image_tag("dev-abc12"),
|
||||
)
|
||||
|
||||
def test_agent_container_name(self):
|
||||
self.assertEqual(
|
||||
"bot-bottle-dev-abc12",
|
||||
_agent_container_name("dev-abc12"),
|
||||
)
|
||||
|
||||
|
||||
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||
"""cmd_commit with an explicit slug bypasses the TUI picker."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_commits_docker_bottle(self):
|
||||
slug = "dev-abc12"
|
||||
# Write metadata saying this is a docker bottle.
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="docker",
|
||||
))
|
||||
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.commit_container",
|
||||
) as mock_commit, patch(
|
||||
"bot_bottle.cli.commit.info",
|
||||
):
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_commit.assert_called_once_with(
|
||||
f"bot-bottle-{slug}",
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
)
|
||||
|
||||
def test_writes_committed_image_to_state(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="docker",
|
||||
))
|
||||
|
||||
with patch("bot_bottle.cli.commit.commit_container"), \
|
||||
patch("bot_bottle.cli.commit.info"):
|
||||
cmd_commit([slug])
|
||||
|
||||
self.assertEqual(
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
bottle_state.read_committed_image(slug),
|
||||
)
|
||||
|
||||
def test_marks_bottle_preserved(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="docker",
|
||||
))
|
||||
|
||||
with patch("bot_bottle.cli.commit.commit_container"), \
|
||||
patch("bot_bottle.cli.commit.info"):
|
||||
cmd_commit([slug])
|
||||
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
def test_empty_backend_treated_as_docker(self):
|
||||
"""Old state dirs without a backend field should be treated as docker."""
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="",
|
||||
))
|
||||
|
||||
with patch("bot_bottle.cli.commit.commit_container") as mock_commit, \
|
||||
patch("bot_bottle.cli.commit.info"):
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_commit.assert_called_once()
|
||||
|
||||
|
||||
class TestCmdCommitNonDockerBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_dies_for_smolmachines_backend(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="smolmachines",
|
||||
))
|
||||
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
||||
) as mock_die:
|
||||
with self.assertRaises(SystemExit):
|
||||
cmd_commit([slug])
|
||||
|
||||
mock_die.assert_called_once()
|
||||
self.assertIn("smolmachines", mock_die.call_args.args[0])
|
||||
|
||||
def test_dies_for_macos_container_backend(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="macos-container",
|
||||
))
|
||||
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
||||
) as mock_die:
|
||||
with self.assertRaises(SystemExit):
|
||||
cmd_commit([slug])
|
||||
|
||||
mock_die.assert_called_once()
|
||||
self.assertIn("macos-container", mock_die.call_args.args[0])
|
||||
|
||||
|
||||
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_dies_when_no_active_bottles_and_no_slug(self):
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[],
|
||||
), patch(
|
||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
||||
) as mock_die:
|
||||
with self.assertRaises(SystemExit):
|
||||
cmd_commit([])
|
||||
|
||||
mock_die.assert_called_once()
|
||||
|
||||
def test_returns_zero_when_picker_cancelled(self):
|
||||
active = MagicMock()
|
||||
active.slug = "dev-abc12"
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[active],
|
||||
), patch(
|
||||
"bot_bottle.cli.commit.tui.filter_select", return_value=None,
|
||||
):
|
||||
rc = cmd_commit([])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Unit: Docker launch step uses committed image when available."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker import launch as launch_mod
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import Manifest
|
||||
|
||||
|
||||
_SLUG = "dev-abc12"
|
||||
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
|
||||
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
|
||||
|
||||
|
||||
def _manifest() -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
|
||||
|
||||
def _plan(tmp: str) -> DockerBottlePlan:
|
||||
stage = Path(tmp)
|
||||
spec = BottleSpec(
|
||||
manifest=_manifest(),
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd=tmp,
|
||||
identity=_SLUG,
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage,
|
||||
git_gate_plan=GitGatePlan(
|
||||
slug=_SLUG,
|
||||
entrypoint_script=stage / "entrypoint.sh",
|
||||
hook_script=stage / "hook.sh",
|
||||
access_hook_script=stage / "access-hook.sh",
|
||||
upstreams=(),
|
||||
),
|
||||
egress_plan=EgressPlan(
|
||||
slug=_SLUG,
|
||||
routes_path=stage / "egress.yaml",
|
||||
routes=(),
|
||||
token_env_map={},
|
||||
),
|
||||
supervise_plan=None,
|
||||
agent_provision=AgentProvisionPlan(
|
||||
template="claude",
|
||||
command="claude",
|
||||
prompt_mode="append_file",
|
||||
image=_DEFAULT_IMAGE,
|
||||
dockerfile="",
|
||||
guest_home="/home/node",
|
||||
instance_name=f"bot-bottle-{_SLUG}",
|
||||
prompt_file=stage / "prompt.txt",
|
||||
guest_env={},
|
||||
),
|
||||
slug=_SLUG,
|
||||
forwarded_env={},
|
||||
use_runsc=False,
|
||||
)
|
||||
|
||||
|
||||
def _std_mocks(test, plan):
|
||||
"""Context manager providing the standard launch-step mocks needed to
|
||||
get through the non-image parts of `launch()` without real Docker."""
|
||||
return mock.patch.multiple(
|
||||
launch_mod,
|
||||
egress_tls_init=mock.DEFAULT,
|
||||
network_mod=mock.DEFAULT,
|
||||
bottle_plan_to_compose=mock.DEFAULT,
|
||||
write_compose_file=mock.DEFAULT,
|
||||
compose_up=mock.DEFAULT,
|
||||
compose_dump_logs=mock.DEFAULT,
|
||||
compose_down=mock.DEFAULT,
|
||||
)
|
||||
|
||||
|
||||
class TestLaunchCommittedImage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
def _run_launch(self, plan, *, committed_tag=None, image_present=True):
|
||||
"""Drive launch() through its full sequence with the committed-image
|
||||
behaviour controlled by the arguments. Returns the images that were
|
||||
passed to `build_image` (empty list if it was never called)."""
|
||||
built = []
|
||||
|
||||
def fake_build(image, ctx, *, dockerfile=""):
|
||||
built.append(image)
|
||||
|
||||
with mock.patch.object(
|
||||
launch_mod, "read_committed_image", return_value=committed_tag,
|
||||
), mock.patch.object(
|
||||
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
||||
), mock.patch.object(
|
||||
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
||||
), mock.patch.object(
|
||||
launch_mod, "egress_tls_init",
|
||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||
), mock.patch.object(
|
||||
launch_mod.network_mod, "network_name_for_slug",
|
||||
return_value="bb-internal",
|
||||
), mock.patch.object(
|
||||
launch_mod.network_mod, "network_egress_name_for_slug",
|
||||
return_value="bb-egress",
|
||||
), mock.patch.object(
|
||||
launch_mod, "bottle_plan_to_compose",
|
||||
return_value={"services": {"agent": {}}},
|
||||
), mock.patch.object(
|
||||
launch_mod, "write_compose_file",
|
||||
return_value=Path("/tmp/compose.yml"),
|
||||
), mock.patch.object(launch_mod, "compose_up"), \
|
||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
||||
mock.patch.object(launch_mod, "compose_down"), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
provision = mock.Mock(return_value=None)
|
||||
with launch_mod.launch(plan, provision=provision):
|
||||
pass
|
||||
|
||||
return built
|
||||
|
||||
def test_skips_build_when_committed_image_present(self):
|
||||
plan = _plan(self._tmp)
|
||||
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
|
||||
self.assertEqual([], built, "build_image should not be called when committed image exists")
|
||||
|
||||
def test_uses_committed_image_in_compose_spec(self):
|
||||
"""The compose spec renderer receives the committed image tag via
|
||||
plan.image — captured here by checking what bottle_plan_to_compose
|
||||
was called with."""
|
||||
plan = _plan(self._tmp)
|
||||
captured_plans = []
|
||||
|
||||
def fake_compose(p):
|
||||
captured_plans.append(p)
|
||||
return {"services": {"agent": {}}}
|
||||
|
||||
with mock.patch.object(
|
||||
launch_mod, "read_committed_image", return_value=_COMMITTED_TAG,
|
||||
), mock.patch.object(
|
||||
launch_mod.docker_mod, "image_exists", return_value=True,
|
||||
), mock.patch.object(
|
||||
launch_mod.docker_mod, "build_image",
|
||||
), mock.patch.object(
|
||||
launch_mod, "egress_tls_init",
|
||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||
), mock.patch.object(
|
||||
launch_mod.network_mod, "network_name_for_slug",
|
||||
return_value="bb-internal",
|
||||
), mock.patch.object(
|
||||
launch_mod.network_mod, "network_egress_name_for_slug",
|
||||
return_value="bb-egress",
|
||||
), mock.patch.object(
|
||||
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
|
||||
), mock.patch.object(
|
||||
launch_mod, "write_compose_file",
|
||||
return_value=Path("/tmp/compose.yml"),
|
||||
), mock.patch.object(launch_mod, "compose_up"), \
|
||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
||||
mock.patch.object(launch_mod, "compose_down"), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
provision = mock.Mock(return_value=None)
|
||||
with launch_mod.launch(plan, provision=provision):
|
||||
pass
|
||||
|
||||
self.assertEqual(1, len(captured_plans))
|
||||
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
|
||||
|
||||
def test_falls_back_to_build_when_no_committed_image(self):
|
||||
plan = _plan(self._tmp)
|
||||
built = self._run_launch(plan, committed_tag=None)
|
||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
||||
|
||||
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self):
|
||||
plan = _plan(self._tmp)
|
||||
built = self._run_launch(
|
||||
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
||||
)
|
||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -67,5 +67,46 @@ class TestSave(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCommitContainer(unittest.TestCase):
|
||||
def test_runs_docker_commit(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_ok(),
|
||||
) as run, patch.object(docker_mod, "info"):
|
||||
docker_mod.commit_container(
|
||||
"bot-bottle-dev-abc12",
|
||||
"bot-bottle-committed-dev-abc12:latest",
|
||||
)
|
||||
argv = run.call_args.args[0]
|
||||
self.assertEqual(
|
||||
[
|
||||
"docker", "commit",
|
||||
"bot-bottle-dev-abc12",
|
||||
"bot-bottle-committed-dev-abc12:latest",
|
||||
],
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_dies_on_docker_commit_failure(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_fail("No such container"),
|
||||
), patch.object(
|
||||
docker_mod, "die", side_effect=SystemExit("die"),
|
||||
) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
docker_mod.commit_container("missing-container", "some:tag")
|
||||
die.assert_called_once()
|
||||
self.assertIn("missing-container", die.call_args.args[0])
|
||||
|
||||
def test_die_message_includes_image_tag(self):
|
||||
with patch.object(
|
||||
docker_mod.subprocess, "run", return_value=_fail("boom"),
|
||||
), patch.object(
|
||||
docker_mod, "die", side_effect=SystemExit("die"),
|
||||
) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
docker_mod.commit_container("ctr", "my-tag:v1")
|
||||
self.assertIn("my-tag:v1", die.call_args.args[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user