feat: support smolmachines bottle commit
This commit is contained in:
@@ -40,8 +40,12 @@ from ..docker.git_gate import (
|
|||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
)
|
)
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
from ...log import warn
|
from ...log import info, warn
|
||||||
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
from ...bottle_state import (
|
||||||
|
egress_state_dir,
|
||||||
|
git_gate_state_dir,
|
||||||
|
read_committed_image,
|
||||||
|
)
|
||||||
from . import loopback_alias as _loopback
|
from . import loopback_alias as _loopback
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
@@ -85,14 +89,7 @@ def launch(
|
|||||||
plan = _start_bundle(plan, network, loopback_ip, stack)
|
plan = _start_bundle(plan, network, loopback_ip, stack)
|
||||||
plan = _discover_urls(plan, loopback_ip)
|
plan = _discover_urls(plan, loopback_ip)
|
||||||
|
|
||||||
# Build the agent image and pack it into a `.smolmachine`
|
agent_from_path = _agent_from_path(plan)
|
||||||
# artifact (or hit the per-Dockerfile-digest cache). Runs
|
|
||||||
# here, not in prepare, so the docker-build output doesn't
|
|
||||||
# garble the dashboard's preflight modal.
|
|
||||||
agent_from_path = _ensure_smolmachine(
|
|
||||||
plan.agent_image,
|
|
||||||
dockerfile=plan.agent_dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||||
_init_vm(plan)
|
_init_vm(plan)
|
||||||
@@ -386,6 +383,30 @@ def _resolve_token_env(
|
|||||||
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||||
|
"""Return the `.smolmachine` artifact used for `machine create --from`.
|
||||||
|
|
||||||
|
Prefer a committed VM artifact when one is recorded and still
|
||||||
|
present. If the file was removed, fall back to the normal image
|
||||||
|
build + pack cache path.
|
||||||
|
"""
|
||||||
|
committed = read_committed_image(plan.slug)
|
||||||
|
if committed:
|
||||||
|
committed_path = Path(committed)
|
||||||
|
if committed_path.is_file():
|
||||||
|
info(f"using committed smolmachine {str(committed_path)!r}")
|
||||||
|
return committed_path
|
||||||
|
|
||||||
|
# Build the agent image and pack it into a `.smolmachine`
|
||||||
|
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
||||||
|
# not in prepare, so the docker-build output doesn't garble the
|
||||||
|
# dashboard's preflight modal.
|
||||||
|
return _ensure_smolmachine(
|
||||||
|
plan.agent_image,
|
||||||
|
dockerfile=plan.agent_dockerfile_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||||
"""Build the agent docker image and convert it into a
|
"""Build the agent docker image and convert it into a
|
||||||
`.smolmachine` artifact, caching the result under
|
`.smolmachine` artifact, caching the result under
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ def pack_create(image: str, output: Path) -> None:
|
|||||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
||||||
|
|
||||||
|
|
||||||
|
def pack_create_from_vm(name: str, output: Path) -> None:
|
||||||
|
"""`smolvm pack create --from-vm <name> -o <output>`.
|
||||||
|
|
||||||
|
Snapshots an existing persistent VM into a pack artifact. As
|
||||||
|
with `pack_create`, smolvm writes a launcher at `output` and the
|
||||||
|
bootable sidecar at `output.smolmachine`.
|
||||||
|
"""
|
||||||
|
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
|
||||||
|
|
||||||
|
|
||||||
# --- Machine lifecycle ---------------------------------------------------
|
# --- Machine lifecycle ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+50
-20
@@ -1,20 +1,21 @@
|
|||||||
"""commit: freeze a running Docker bottle's container state to a local image.
|
"""commit: freeze a running bottle's state to a resumable artifact.
|
||||||
|
|
||||||
Runs `docker commit <container> <image-tag>` on the active agent
|
Docker bottles are committed to a local Docker image. Smolmachines
|
||||||
container and stores the image tag in per-bottle state so the next
|
bottles are packed from the running VM into a `.smolmachine` artifact.
|
||||||
`./cli.py resume <slug>` boots from that snapshot instead of
|
The resulting reference is stored in per-bottle state so the next
|
||||||
rebuilding from the Dockerfile.
|
`./cli.py resume <slug>` boots from the 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
from ..backend import enumerate_active_agents
|
||||||
from ..backend.docker.util import commit_container
|
from ..backend.docker.util import commit_container
|
||||||
|
from ..backend.smolmachines.smolvm import pack_create_from_vm
|
||||||
|
from ..bottle_state import bottle_state_dir
|
||||||
from ..bottle_state import mark_preserved, read_metadata, write_committed_image
|
from ..bottle_state import mark_preserved, read_metadata, write_committed_image
|
||||||
from ..log import die, info
|
from ..log import die, info
|
||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
@@ -23,6 +24,7 @@ from . import tui
|
|||||||
|
|
||||||
_COMMITTED_IMAGE_PREFIX = "bot-bottle-committed-"
|
_COMMITTED_IMAGE_PREFIX = "bot-bottle-committed-"
|
||||||
_DOCKER_BACKENDS = {"docker", ""}
|
_DOCKER_BACKENDS = {"docker", ""}
|
||||||
|
_SMOLMACHINES_BACKEND = "smolmachines"
|
||||||
|
|
||||||
|
|
||||||
def _committed_image_tag(slug: str) -> str:
|
def _committed_image_tag(slug: str) -> str:
|
||||||
@@ -33,6 +35,19 @@ def _agent_container_name(slug: str) -> str:
|
|||||||
return f"bot-bottle-{slug}"
|
return f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_machine_name(slug: str) -> str:
|
||||||
|
return f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _committed_smolmachine_output(slug: str) -> Path:
|
||||||
|
return bottle_state_dir(slug) / "committed-smolmachine"
|
||||||
|
|
||||||
|
|
||||||
|
def _committed_smolmachine_artifact(slug: str) -> Path:
|
||||||
|
output = _committed_smolmachine_output(slug)
|
||||||
|
return output.with_name(f"{output.name}.smolmachine")
|
||||||
|
|
||||||
|
|
||||||
def cmd_commit(argv: list[str]) -> int:
|
def cmd_commit(argv: list[str]) -> int:
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -58,18 +73,33 @@ def cmd_commit(argv: list[str]) -> int:
|
|||||||
|
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
backend = metadata.backend if metadata else ""
|
backend = metadata.backend if metadata else ""
|
||||||
if backend not in _DOCKER_BACKENDS:
|
if backend in _DOCKER_BACKENDS:
|
||||||
|
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
|
||||||
|
|
||||||
|
if backend == _SMOLMACHINES_BACKEND:
|
||||||
|
machine = _agent_machine_name(slug)
|
||||||
|
output = _committed_smolmachine_output(slug)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pack_create_from_vm(machine, output)
|
||||||
|
artifact = _committed_smolmachine_artifact(slug)
|
||||||
|
write_committed_image(slug, str(artifact))
|
||||||
|
mark_preserved(slug)
|
||||||
|
info(f"to resume from this snapshot: ./cli.py resume {slug}")
|
||||||
|
info(f"to export for migration: cp {artifact} {slug}.smolmachine")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if backend:
|
||||||
die(
|
die(
|
||||||
f"commit is only supported for the docker backend; "
|
f"commit is only supported for the docker and smolmachines backends; "
|
||||||
f"bottle {slug!r} uses {backend!r}"
|
f"bottle {slug!r} uses {backend!r}"
|
||||||
)
|
)
|
||||||
|
die(f"commit cannot determine the backend for bottle {slug!r}")
|
||||||
container = _agent_container_name(slug)
|
return 1
|
||||||
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
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Add a `commit` CLI command that freezes a running Docker bottle's
|
Add a `commit` CLI command that freezes a running bottle's state to a
|
||||||
container state to a named Docker image. Operators can then resume the
|
resumable local artifact. Docker bottles are stored as Docker images;
|
||||||
bottle from that exact filesystem snapshot, or export the image with
|
smolmachines bottles are stored as `.smolmachine` artifacts. Operators
|
||||||
`docker save` to migrate work to a different host.
|
can then resume the bottle from that exact filesystem snapshot, or
|
||||||
|
export the artifact to migrate work to a different host.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
@@ -29,30 +30,29 @@ snapshot before a planned host reboot or hardware migration.
|
|||||||
|
|
||||||
## Goals / Success Criteria
|
## Goals / Success Criteria
|
||||||
|
|
||||||
- `./cli.py commit [<slug>]` takes a snapshot of the running Docker
|
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and
|
||||||
agent container and stores it as a local Docker image.
|
stores it as a local artifact.
|
||||||
- Without a slug argument the command shows the same interactive picker
|
- Without a slug argument the command shows the same interactive picker
|
||||||
as `start` (the list of active slugs).
|
as `start` (the list of active slugs).
|
||||||
- The committed image tag is stored in per-bottle state so that the next
|
- The committed artifact reference is stored in per-bottle state so
|
||||||
`./cli.py resume <slug>` automatically uses the committed image instead
|
that the next `./cli.py resume <slug>` automatically uses the
|
||||||
of rebuilding from the Dockerfile.
|
snapshot instead of rebuilding from the Dockerfile.
|
||||||
- `mark_preserved` is called so the state dir survives the normal
|
- `mark_preserved` is called so the state dir survives the normal
|
||||||
session-end cleanup.
|
session-end cleanup.
|
||||||
- A `docker save` hint is printed so operators know how to export the
|
- A backend-specific export hint is printed so operators know how to
|
||||||
image for migration.
|
migrate the snapshot.
|
||||||
- The command errors clearly on non-Docker backends (smolmachines does
|
- The command errors clearly on unsupported backends.
|
||||||
not expose a container-level commit API in its current CLI surface).
|
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- Smolmachines or macOS-container backend support.
|
- macOS-container backend support.
|
||||||
- Automatic commit on agent exit.
|
- Automatic commit on agent exit.
|
||||||
- Image push to a remote registry.
|
- Image push to a remote registry.
|
||||||
- Storing the image tag in the manifest or sharing it between operators.
|
- Storing the image tag in the manifest or sharing it between operators.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
### Image tag
|
### Docker image tag
|
||||||
|
|
||||||
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
|
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
|
||||||
to match existing image naming conventions; `committed` distinguishes it
|
to match existing image naming conventions; `committed` distinguishes it
|
||||||
@@ -68,13 +68,15 @@ directory:
|
|||||||
~/.bot-bottle/state/<identity>/
|
~/.bot-bottle/state/<identity>/
|
||||||
metadata.json
|
metadata.json
|
||||||
Dockerfile (capability-block override; optional)
|
Dockerfile (capability-block override; optional)
|
||||||
committed-image (committed image tag; optional)
|
committed-image (committed artifact reference; optional)
|
||||||
transcript/
|
transcript/
|
||||||
```
|
```
|
||||||
|
|
||||||
`bottle_state.committed_image_path(identity)` returns the path.
|
`bottle_state.committed_image_path(identity)` returns the path.
|
||||||
`write_committed_image` / `read_committed_image` are the read/write
|
`write_committed_image` / `read_committed_image` are the read/write
|
||||||
helpers, matching the existing `per_bottle_dockerfile` pattern.
|
helpers, matching the existing `per_bottle_dockerfile` pattern. Docker
|
||||||
|
stores a Docker tag in this file; smolmachines stores the absolute path
|
||||||
|
to the committed `.smolmachine` artifact.
|
||||||
|
|
||||||
### `commit` command
|
### `commit` command
|
||||||
|
|
||||||
@@ -83,14 +85,15 @@ helpers, matching the existing `per_bottle_dockerfile` pattern.
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
|
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
|
2. Check metadata and branch by backend.
|
||||||
clear "not supported" error.
|
3. For Docker, derive container name `bot-bottle-<slug>` and run
|
||||||
3. Derive container name: `bot-bottle-<slug>` (matches the agent
|
`docker commit <container> bot-bottle-committed-<slug>:latest`.
|
||||||
provision plan's `instance_name` convention).
|
4. For smolmachines, derive machine name `bot-bottle-<slug>` and run
|
||||||
4. Run `docker commit <container> bot-bottle-committed-<slug>:latest`.
|
`smolvm pack create --from-vm <machine> -o ~/.bot-bottle/state/<slug>/committed-smolmachine`.
|
||||||
5. Write the image tag to `~/.bot-bottle/state/<slug>/committed-image`.
|
5. Write the Docker image tag or smolmachine artifact path to
|
||||||
|
`~/.bot-bottle/state/<slug>/committed-image`.
|
||||||
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
|
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
|
||||||
7. Print the resume hint and a `docker save` export example.
|
7. Print the resume hint and a backend-specific export example.
|
||||||
|
|
||||||
### Resume from committed image
|
### Resume from committed image
|
||||||
|
|
||||||
@@ -120,6 +123,22 @@ 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
|
after `docker rmi` or a `docker system prune`), the launch falls back
|
||||||
to a normal Dockerfile build, matching the pre-commit behavior.
|
to a normal Dockerfile build, matching the pre-commit behavior.
|
||||||
|
|
||||||
|
### Resume from committed smolmachine
|
||||||
|
|
||||||
|
`bot_bottle/backend/smolmachines/launch.py` checks the committed
|
||||||
|
reference before the normal Docker build -> pack cache path:
|
||||||
|
|
||||||
|
```python
|
||||||
|
committed = read_committed_image(plan.slug)
|
||||||
|
if committed and Path(committed).is_file():
|
||||||
|
return Path(committed)
|
||||||
|
return _ensure_smolmachine(plan.agent_image, dockerfile=plan.agent_dockerfile_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
The returned path is passed to `smolvm machine create --from`, so the
|
||||||
|
resumed VM boots from the committed snapshot. If the artifact has been
|
||||||
|
deleted, launch falls back to the normal build and pack flow.
|
||||||
|
|
||||||
## Testing strategy
|
## Testing strategy
|
||||||
|
|
||||||
- Unit tests for `write_committed_image` / `read_committed_image` in
|
- Unit tests for `write_committed_image` / `read_committed_image` in
|
||||||
@@ -127,10 +146,14 @@ to a normal Dockerfile build, matching the pre-commit behavior.
|
|||||||
pattern.
|
pattern.
|
||||||
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
|
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
|
||||||
mocking `subprocess.run` and asserting on the `docker commit` argv.
|
mocking `subprocess.run` and asserting on the `docker commit` argv.
|
||||||
- Unit tests for `cmd_commit` argument parsing and the "unsupported
|
- Unit tests for `cmd_commit` argument parsing, Docker commit,
|
||||||
backend" error path, mocking `enumerate_active_agents` and
|
smolmachines pack, and the unsupported backend error path, mocking
|
||||||
`commit_container`.
|
`enumerate_active_agents`, `commit_container`, and
|
||||||
|
`pack_create_from_vm`.
|
||||||
- Unit tests for the launch-step committed-image branch: patch
|
- Unit tests for the launch-step committed-image branch: patch
|
||||||
`read_committed_image` to return a tag, patch `image_exists` to return
|
`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
|
True, and assert that `build_image` is not called and `plan.image` is
|
||||||
overridden.
|
overridden.
|
||||||
|
- Unit tests for the smolmachines launch-step committed-artifact branch:
|
||||||
|
patch `read_committed_image` to return an existing path and assert the
|
||||||
|
normal `_ensure_smolmachine` path is skipped.
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ from __future__ import annotations
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, call, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bot_bottle.cli.commit import cmd_commit, _committed_image_tag, _agent_container_name
|
from bot_bottle.cli.commit import (
|
||||||
|
cmd_commit,
|
||||||
|
_agent_container_name,
|
||||||
|
_committed_image_tag,
|
||||||
|
_committed_smolmachine_artifact,
|
||||||
|
_committed_smolmachine_output,
|
||||||
|
)
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle import bottle_state
|
from bot_bottle import bottle_state
|
||||||
|
|
||||||
@@ -41,6 +47,16 @@ class TestCommitHelpers(unittest.TestCase):
|
|||||||
_agent_container_name("dev-abc12"),
|
_agent_container_name("dev-abc12"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_committed_smolmachine_paths(self):
|
||||||
|
output = _committed_smolmachine_output("dev-abc12")
|
||||||
|
artifact = _committed_smolmachine_artifact("dev-abc12")
|
||||||
|
self.assertTrue(str(output).endswith(
|
||||||
|
"/.bot-bottle/state/dev-abc12/committed-smolmachine"
|
||||||
|
))
|
||||||
|
self.assertTrue(str(artifact).endswith(
|
||||||
|
"/.bot-bottle/state/dev-abc12/committed-smolmachine.smolmachine"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""cmd_commit with an explicit slug bypasses the TUI picker."""
|
"""cmd_commit with an explicit slug bypasses the TUI picker."""
|
||||||
@@ -117,14 +133,14 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
|||||||
mock_commit.assert_called_once()
|
mock_commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestCmdCommitNonDockerBackend(_FakeHomeMixin, unittest.TestCase):
|
class TestCmdCommitSmolmachinesBackend(_FakeHomeMixin, unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
self._setup_fake_home()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self._teardown_fake_home()
|
self._teardown_fake_home()
|
||||||
|
|
||||||
def test_dies_for_smolmachines_backend(self):
|
def test_packs_smolmachines_bottle(self):
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
@@ -132,13 +148,30 @@ class TestCmdCommitNonDockerBackend(_FakeHomeMixin, unittest.TestCase):
|
|||||||
))
|
))
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
"bot_bottle.cli.commit.pack_create_from_vm",
|
||||||
) as mock_die:
|
) as mock_pack, patch(
|
||||||
with self.assertRaises(SystemExit):
|
"bot_bottle.cli.commit.info",
|
||||||
cmd_commit([slug])
|
):
|
||||||
|
rc = cmd_commit([slug])
|
||||||
|
|
||||||
mock_die.assert_called_once()
|
self.assertEqual(0, rc)
|
||||||
self.assertIn("smolmachines", mock_die.call_args.args[0])
|
mock_pack.assert_called_once_with(
|
||||||
|
f"bot-bottle-{slug}",
|
||||||
|
_committed_smolmachine_output(slug),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
str(_committed_smolmachine_artifact(slug)),
|
||||||
|
bottle_state.read_committed_image(slug),
|
||||||
|
)
|
||||||
|
self.assertTrue(bottle_state.is_preserved(slug))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdCommitUnsupportedBackend(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
def test_dies_for_macos_container_backend(self):
|
def test_dies_for_macos_container_backend(self):
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import io
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
@@ -73,36 +74,28 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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):
|
class TestLaunchCommittedImage(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self) -> None:
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||||
|
|
||||||
def _run_launch(self, plan, *, committed_tag=None, image_present=True):
|
def _run_launch(
|
||||||
|
self,
|
||||||
|
plan: DockerBottlePlan,
|
||||||
|
*,
|
||||||
|
committed_tag: str | None = None,
|
||||||
|
image_present: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
"""Drive launch() through its full sequence with the committed-image
|
"""Drive launch() through its full sequence with the committed-image
|
||||||
behaviour controlled by the arguments. Returns the images that were
|
behaviour controlled by the arguments. Returns the images that were
|
||||||
passed to `build_image` (empty list if it was never called)."""
|
passed to `build_image` (empty list if it was never called)."""
|
||||||
built = []
|
built: list[str] = []
|
||||||
|
|
||||||
def fake_build(image, ctx, *, dockerfile=""):
|
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
|
||||||
|
del ctx, dockerfile
|
||||||
built.append(image)
|
built.append(image)
|
||||||
|
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
@@ -136,19 +129,19 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
|||||||
|
|
||||||
return built
|
return built
|
||||||
|
|
||||||
def test_skips_build_when_committed_image_present(self):
|
def test_skips_build_when_committed_image_present(self) -> None:
|
||||||
plan = _plan(self._tmp)
|
plan = _plan(self._tmp)
|
||||||
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
|
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")
|
self.assertEqual([], built, "build_image should not be called when committed image exists")
|
||||||
|
|
||||||
def test_uses_committed_image_in_compose_spec(self):
|
def test_uses_committed_image_in_compose_spec(self) -> None:
|
||||||
"""The compose spec renderer receives the committed image tag via
|
"""The compose spec renderer receives the committed image tag via
|
||||||
plan.image — captured here by checking what bottle_plan_to_compose
|
plan.image — captured here by checking what bottle_plan_to_compose
|
||||||
was called with."""
|
was called with."""
|
||||||
plan = _plan(self._tmp)
|
plan = _plan(self._tmp)
|
||||||
captured_plans = []
|
captured_plans: list[DockerBottlePlan] = []
|
||||||
|
|
||||||
def fake_compose(p):
|
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
|
||||||
captured_plans.append(p)
|
captured_plans.append(p)
|
||||||
return {"services": {"agent": {}}}
|
return {"services": {"agent": {}}}
|
||||||
|
|
||||||
@@ -183,12 +176,12 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
|||||||
self.assertEqual(1, len(captured_plans))
|
self.assertEqual(1, len(captured_plans))
|
||||||
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
|
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
|
||||||
|
|
||||||
def test_falls_back_to_build_when_no_committed_image(self):
|
def test_falls_back_to_build_when_no_committed_image(self) -> None:
|
||||||
plan = _plan(self._tmp)
|
plan = _plan(self._tmp)
|
||||||
built = self._run_launch(plan, committed_tag=None)
|
built = self._run_launch(plan, committed_tag=None)
|
||||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
self.assertEqual([_DEFAULT_IMAGE], built)
|
||||||
|
|
||||||
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self):
|
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
|
||||||
plan = _plan(self._tmp)
|
plan = _plan(self._tmp)
|
||||||
built = self._run_launch(
|
built = self._run_launch(
|
||||||
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.backend.smolmachines import launch as _launch_mod
|
from bot_bottle.backend.smolmachines import launch as _launch_mod
|
||||||
@@ -141,5 +143,46 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentFromPath(unittest.TestCase):
|
||||||
|
def _plan(self) -> Any:
|
||||||
|
return cast(Any, SimpleNamespace(
|
||||||
|
slug="dev-abc12",
|
||||||
|
agent_image="bot-bottle-claude:latest",
|
||||||
|
agent_dockerfile_path="/repo/Dockerfile",
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_uses_committed_artifact_when_present(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="committed-smolmachine.") as tmp:
|
||||||
|
artifact = Path(tmp) / "committed-smolmachine.smolmachine"
|
||||||
|
artifact.write_text("")
|
||||||
|
with patch.object(
|
||||||
|
_launch_mod, "read_committed_image", return_value=str(artifact),
|
||||||
|
), patch.object(
|
||||||
|
_launch_mod, "_ensure_smolmachine",
|
||||||
|
) as ensure, patch.object(
|
||||||
|
_launch_mod, "info",
|
||||||
|
):
|
||||||
|
result = _launch_mod._agent_from_path(self._plan())
|
||||||
|
|
||||||
|
self.assertEqual(artifact, result)
|
||||||
|
ensure.assert_not_called()
|
||||||
|
|
||||||
|
def test_falls_back_when_committed_artifact_missing(self):
|
||||||
|
packed = Path("/cache/agent.smolmachine")
|
||||||
|
with patch.object(
|
||||||
|
_launch_mod, "read_committed_image",
|
||||||
|
return_value="/missing/committed.smolmachine",
|
||||||
|
), patch.object(
|
||||||
|
_launch_mod, "_ensure_smolmachine", return_value=packed,
|
||||||
|
) as ensure:
|
||||||
|
result = _launch_mod._agent_from_path(self._plan())
|
||||||
|
|
||||||
|
self.assertEqual(packed, result)
|
||||||
|
ensure.assert_called_once_with(
|
||||||
|
"bot-bottle-claude:latest",
|
||||||
|
dockerfile="/repo/Dockerfile",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from bot_bottle.backend.smolmachines.smolvm import (
|
|||||||
machine_start,
|
machine_start,
|
||||||
machine_stop,
|
machine_stop,
|
||||||
pack_create,
|
pack_create,
|
||||||
|
pack_create_from_vm,
|
||||||
wait_exec_ready,
|
wait_exec_ready,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,6 +64,17 @@ class TestArgvShapes(unittest.TestCase):
|
|||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_pack_create_from_vm_argv(self):
|
||||||
|
with self._patch_run() as m:
|
||||||
|
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/committed"))
|
||||||
|
argv = m.call_args.args[0]
|
||||||
|
self.assertEqual(
|
||||||
|
["smolvm", "pack", "create",
|
||||||
|
"--from-vm", "bot-bottle-dev-abc12",
|
||||||
|
"-o", "/tmp/committed"],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
def test_machine_create_minimal(self):
|
def test_machine_create_minimal(self):
|
||||||
with self._patch_run() as m:
|
with self._patch_run() as m:
|
||||||
machine_create("agent-xyz")
|
machine_create("agent-xyz")
|
||||||
@@ -193,6 +205,14 @@ class TestErrorPath(unittest.TestCase):
|
|||||||
with self.assertRaises(SmolvmError):
|
with self.assertRaises(SmolvmError):
|
||||||
pack_create("missing:tag", Path("/tmp/out"))
|
pack_create("missing:tag", Path("/tmp/out"))
|
||||||
|
|
||||||
|
def test_pack_create_from_vm_failure_raises(self):
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
||||||
|
return_value=_fail("pack failed"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(SmolvmError):
|
||||||
|
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/out"))
|
||||||
|
|
||||||
def test_exec_failure_returns_result(self):
|
def test_exec_failure_returns_result(self):
|
||||||
# The in-VM command's exit code is what Bottle.exec sees;
|
# The in-VM command's exit code is what Bottle.exec sees;
|
||||||
# `false` exiting non-zero is not a smolvm failure.
|
# `false` exiting non-zero is not a smolvm failure.
|
||||||
|
|||||||
Reference in New Issue
Block a user