feat: add macos container backend scaffold

This commit is contained in:
2026-06-10 18:14:17 +00:00
committed by didericis
parent b00b0ba4aa
commit fe82dc7f2b
16 changed files with 919 additions and 3 deletions
+3 -1
View File
@@ -190,7 +190,7 @@ class ActiveAgent:
of sidecar daemons currently up for this bottle (`egress`,
`git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
`_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active-
list rendering to disambiguate and by the dashboard's
re-attach path."""
@@ -530,6 +530,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
# via `from . import ...` without hitting a partially-initialized module.
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
@@ -539,6 +540,7 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: dis
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
"docker": DockerBottleBackend(),
"macos-container": MacosContainerBottleBackend(),
"smolmachines": SmolmachinesBottleBackend(),
}
@@ -0,0 +1,10 @@
"""macOS Apple Container backend.
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
the Apple `container` CLI integration; launch remains gated until the
sidecar network enforcement shape is implemented.
"""
from .backend import MacosContainerBottleBackend
__all__ = ["MacosContainerBottleBackend"]
@@ -0,0 +1,81 @@
"""MacosContainerBottleBackend — Apple Container implementation."""
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
from . import resolve_plan as _resolve_plan
from . import util as _container
from .bottle import MacosContainerBottle
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
from .bottle_plan import MacosContainerBottlePlan
class MacosContainerBottleBackend(
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
):
"""Experimental Apple Container backend. Selected by
`BOT_BOTTLE_BACKEND=macos-container` or
`--backend=macos-container`."""
name = "macos-container"
@classmethod
def is_available(cls) -> bool:
return _container.is_available()
def _preflight(self) -> None:
_resolve_plan.preflight()
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return _resolve_plan.build_guest_env(resolved_env)
def _resolve_plan(
self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> MacosContainerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
@contextmanager
def launch(
self, plan: MacosContainerBottlePlan
) -> Generator[MacosContainerBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
return _cleanup.prepare_cleanup()
def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None:
_cleanup.cleanup(plan)
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
@@ -0,0 +1,91 @@
"""Bottle handle for Apple's `container` CLI."""
from __future__ import annotations
import subprocess
from typing import Callable, cast
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
from ..terminal import exec_shell_script
class MacosContainerBottle(Bottle):
def __init__(
self,
container: str,
teardown: Callable[[], None],
prompt_path_in_container: str | None,
*,
agent_command: str = "claude",
agent_prompt_mode: PromptMode = "append_file",
agent_provider_template: str = "claude",
terminal_title: str = "",
terminal_color: str = "",
agent_workdir: str = "/home/node",
):
self.name = container
self._teardown = teardown
self.prompt_path = prompt_path_in_container
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.terminal_title = terminal_title
self.terminal_color = terminal_color
self.agent_provider_template = agent_provider_template
self.agent_workdir = agent_workdir
self._closed = False
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
full_argv = list(argv)
full_argv.extend(
prompt_args(
cast(PromptMode, self._agent_prompt_mode),
self.prompt_path,
argv=full_argv,
)
)
cmd = ["container", "exec"]
if tty:
cmd.extend(["--interactive", "--tty"])
if self.agent_workdir and self.agent_workdir != "/home/node":
cmd.extend(["--workdir", self.agent_workdir])
cmd.extend([self.name, self.agent_command, *full_argv])
return cmd
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
agent_argv = self.agent_argv(argv, tty=tty)
script = (
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
if tty else None
)
if script is None:
return subprocess.run(agent_argv, check=False).returncode
return subprocess.run(["sh", "-lc", script], check=False).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult:
result = subprocess.run(
["container", "exec", "--user", user, "--interactive",
self.name, "sh", "-s"],
input=script,
capture_output=True,
text=True,
check=False,
)
return ExecResult(
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
)
def cp_in(self, host_path: str, container_path: str) -> None:
subprocess.run(
["container", "cp", host_path, f"{self.name}:{container_path}"],
stdout=subprocess.DEVNULL,
check=True,
)
def close(self) -> None:
if self._closed:
return
self._closed = True
self._teardown()
@@ -0,0 +1,27 @@
"""Cleanup plan for the macOS Apple Container backend."""
from __future__ import annotations
from dataclasses import dataclass
from ...log import info
from .. import BottleCleanupPlan
@dataclass(frozen=True)
class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
containers: tuple[str, ...] = ()
networks: tuple[str, ...] = ()
def print(self) -> None:
if not self.containers and not self.networks:
info("macos-container cleanup: nothing to remove")
return
for name in self.containers:
info(f"macos-container container: {name}")
for name in self.networks:
info(f"macos-container network: {name}")
@property
def empty(self) -> bool:
return not self.containers and not self.networks
@@ -0,0 +1,43 @@
"""Plan type for the macOS Apple Container backend."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from ...agent_provider import PromptMode
from .. import BottlePlan
@dataclass(frozen=True)
class MacosContainerBottlePlan(BottlePlan):
slug: str
forwarded_env: dict[str, str] = field(repr=False)
@property
def container_name(self) -> str:
return self.agent_provision.instance_name
@property
def image(self) -> str:
return self.agent_provision.image
@property
def dockerfile_path(self) -> str:
return self.agent_provision.dockerfile
@property
def prompt_file(self) -> Path:
return self.agent_provision.prompt_file
@property
def agent_command(self) -> str:
return self.agent_provision.command
@property
def agent_prompt_mode(self) -> PromptMode:
return self.agent_provision.prompt_mode
@property
def agent_provider_template(self) -> str:
return self.agent_provision.template
@@ -0,0 +1,70 @@
"""Cleanup for the macOS Apple Container backend."""
from __future__ import annotations
import subprocess
from ...log import info, warn
from . import util as container_mod
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
_PREFIX = "bot-bottle-"
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
def _list_prefixed_containers() -> list[str]:
result = subprocess.run(
["container", "list", "--all", "--quiet"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
warn(f"container list failed: {result.stderr.strip()}")
return []
return sorted(
name for name in (line.strip() for line in result.stdout.splitlines())
if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
)
def _list_prefixed_networks() -> list[str]:
result = subprocess.run(
["container", "network", "list", "--quiet"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return []
return sorted(
name for name in (line.strip() for line in result.stdout.splitlines())
if name.startswith(_PREFIX)
)
def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
container_mod.require_container()
return MacosContainerBottleCleanupPlan(
containers=tuple(_list_prefixed_containers()),
networks=tuple(_list_prefixed_networks()),
)
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
for name in plan.containers:
info(f"container delete --force {name}")
subprocess.run(
["container", "delete", "--force", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
for name in plan.networks:
info(f"container network delete {name}")
subprocess.run(
["container", "network", "delete", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
@@ -0,0 +1,37 @@
"""Active-agent enumeration for the macOS Apple Container backend."""
from __future__ import annotations
import subprocess
from ...bottle_state import read_metadata
from .. import ActiveAgent
_PREFIX = "bot-bottle-"
def enumerate_active() -> list[ActiveAgent]:
result = subprocess.run(
["container", "list", "--quiet"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return []
out: list[ActiveAgent] = []
for name in sorted(line.strip() for line in result.stdout.splitlines()):
if not name.startswith(_PREFIX):
continue
slug = name[len(_PREFIX):]
metadata = read_metadata(slug)
out.append(ActiveAgent(
backend_name="macos-container",
slug=slug,
agent_name=metadata.agent_name if metadata else "?",
started_at=metadata.started_at if metadata else "",
services=(),
label=metadata.label if metadata else "",
color=metadata.color if metadata else "",
))
return out
@@ -0,0 +1,34 @@
"""Launch flow for the macOS Apple Container backend.
The backend is registered and its host primitives are implemented, but
full launch is intentionally blocked until the sidecar network
enforcement design is finished. Apple Container can publish ports and
create networks, but bot-bottle's Docker topology relies on an agent
container attached only to an internal network while the sidecar bundle
also has egress. The first runnable version must preserve that
no-direct-egress property.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Callable, Generator
from ...log import die
from .bottle import MacosContainerBottle
from .bottle_plan import MacosContainerBottlePlan
@contextmanager
def launch(
plan: MacosContainerBottlePlan,
*,
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
) -> Generator[MacosContainerBottle, None, None]:
del provision
die(
"macos-container backend launch is not enabled yet: "
"the backend primitives are present, but sidecar network "
"enforcement still needs implementation."
)
yield # pragma: no cover
@@ -0,0 +1,44 @@
"""Prepare step for the macOS Apple Container backend."""
from __future__ import annotations
from pathlib import Path
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from .. import BottleSpec
from . import util as container_mod
from .bottle_plan import MacosContainerBottlePlan
def preflight() -> None:
container_mod.require_container()
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
return dict(resolved_env.literals)
def resolve_plan(
spec: BottleSpec,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
supervise_plan: SupervisePlan | None,
git_gate_plan: GitGatePlan,
stage_dir: Path,
) -> MacosContainerBottlePlan:
return MacosContainerBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
forwarded_env=dict(resolved_env.forwarded),
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
agent_provision=agent_provision_plan,
)
+117
View File
@@ -0,0 +1,117 @@
"""Host-side primitives for Apple's `container` CLI."""
from __future__ import annotations
import platform
import shutil
import subprocess
from typing import Iterable
from ...log import die, info
_CONTAINER = "container"
def is_macos() -> bool:
return platform.system() == "Darwin"
def is_available() -> bool:
return is_macos() and shutil.which(_CONTAINER) is not None
def require_container() -> None:
"""Fail with an install pointer if Apple Container is unavailable."""
if not is_macos():
info("BOT_BOTTLE_BACKEND=macos-container requires macOS.")
die("macos-container backend is only supported on macOS")
if shutil.which(_CONTAINER) is None:
info("Apple Container is required but was not found on PATH.")
info("Install: https://github.com/apple/container/releases")
die("container not found")
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
info(
f"building image {ref} from {context} with Apple Container "
"(layer cache keeps repeat builds fast)"
)
args = [_CONTAINER, "build", "-t", ref]
if dockerfile:
args.extend(["-f", dockerfile])
args.append(context)
subprocess.run(args, check=True)
def image_exists(ref: str) -> bool:
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
def container_exists(name: str) -> bool:
result = subprocess.run(
[_CONTAINER, "list", "--all", "--quiet"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return False
return name in {line.strip() for line in result.stdout.splitlines()}
def force_remove_container(name: str) -> None:
if container_exists(name):
subprocess.run(
[_CONTAINER, "delete", "--force", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
def image_id(ref: str) -> str:
"""Return the image digest/ID from `container image inspect`.
The command returns JSON on current Apple Container releases. Keep
parsing narrow and fatal so callers do not cache on an empty key.
"""
import json
result = subprocess.run(
[_CONTAINER, "image", "inspect", ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"container image inspect for {ref!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
try:
data = json.loads(result.stdout or "{}")
except json.JSONDecodeError as exc:
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
if isinstance(data, list) and data:
data = data[0]
if isinstance(data, dict):
value = data.get("id") or data.get("digest") or data.get("ID")
if value:
return str(value)
die(f"container image inspect for {ref!r} did not include an image id")
raise AssertionError("unreachable")
def save(ref: str, output: str) -> None:
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
def _silent_run(cmd: Iterable[str]) -> int:
return subprocess.run(
list(cmd),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode
@@ -0,0 +1,159 @@
# PRD prd-new: macOS Container backend
- **Status:** Draft
- **Author:** Codex
- **Created:** 2026-06-10
- **Issue:** #220
## Summary
Add an experimental `macos-container` backend that integrates Apple's
`container` CLI as a host runtime on macOS. The first shipped slice
registers the backend, implements the reusable host primitives
(`build`, `exec`, `cp`, image inspection, cleanup, active
enumeration), and blocks full launch behind an explicit network
enforcement guard. This creates a real integration point without
weakening bot-bottle's sidecar egress model.
## Problem
bot-bottle currently has two local execution paths:
- `docker`, which runs the whole bottle topology through Docker
Compose.
- `smolmachines`, which runs the agent in smolvm but still depends on
Docker for the sidecar bundle and image-building pipeline.
Issue #220 explored removing Docker as a host dependency. A follow-up
comment verified that smolvm can publish guest ports back to host
loopback and that another smolvm guest can reach that service through
the existing per-bottle loopback alias plus `--allow-cidr` path. That
keeps the VM-contained sidecar direction viable and rejects the
host-process sidecar fallback.
Apple's `container` CLI is another macOS-native way to run OCI images
as lightweight Linux VMs. Its current command surface includes
Docker-like `build`, `run`, `exec`, `cp`, port publishing, image
inspection, and user-defined networks. That makes it a plausible local
backend, but it does not remove the need to preserve bot-bottle's
sidecar enforcement property: the agent must not have a direct egress
path around the egress sidecar.
## Goals / Success Criteria
- `--backend=macos-container` and
`BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing
backend selector.
- Backend availability is true only on macOS hosts with `container` on
`PATH`.
- The backend has tested wrappers for Apple Container image build,
image inspection, container `exec`, container `cp`, cleanup, and
active-agent enumeration.
- Full launch fails loudly with an operator-facing message until the
sidecar network enforcement design is implemented.
- The PRD records the remaining launch work so the next PR can make the
backend runnable without revisiting registration or wrapper plumbing.
## Non-goals
- Do not remove or deprecate the Docker backend.
- Do not change the default backend from `smolmachines`.
- Do not run sidecar daemons as host processes.
- Do not launch a degraded backend where the agent can bypass the
egress sidecar through direct network access.
- Do not require Docker Desktop as part of the macOS Container backend.
## Design
### Backend name
The selectable backend name is `macos-container`. The Python package
uses `bot_bottle.backend.macos_container` because module names cannot
contain hyphens.
### Availability and preflight
`MacosContainerBottleBackend.is_available()` returns true only when:
- `platform.system() == "Darwin"`
- `container` is discoverable on `PATH`
`prepare()` calls `require_container()`, which produces a concrete
install pointer and rejects non-macOS hosts.
### Implemented primitives
The backend owns an Apple Container wrapper module instead of reusing
Docker wrappers. The wrapper maps bot-bottle's backend needs to
Apple's CLI:
| bot-bottle need | Apple Container command |
|---|---|
| Build provider image | `container build -t <ref> [-f Dockerfile] <context>` |
| Run agent commands | `container exec [--interactive --tty] <id> ...` |
| Copy files into guest | `container cp <host> <id>:<path>` |
| Inspect image identity | `container image inspect <ref>` |
| Cleanup stale containers | `container delete --force <id>` |
| Cleanup stale networks | `container network delete <name>` |
| Active enumeration | `container list --quiet` |
The bottle handle mirrors `DockerBottle`: it builds a host argv for
foreground agent execution, pipes shell snippets through stdin for
`Bottle.exec`, and exposes `cp_in` for provisioning.
### Launch guard
`launch()` is intentionally not enabled in the first slice. It exits
with a fatal message explaining that sidecar network enforcement still
needs implementation.
This is deliberate. A runnable backend that places the agent on a
normal outbound network while relying on environment variables for
proxying would violate bot-bottle's egress model. The runnable version
must prove one of these shapes:
- Apple Container supports the equivalent of Docker's two-network
sidecar topology: agent on an internal-only network, sidecar on both
internal and egress networks.
- The sidecar bundle runs as a separate VM/container with published
loopback ports, and the agent runtime can be constrained to only
reach that per-bottle loopback alias.
- Apple Container init/network hooks can enforce the egress sidecar as
the only outbound path before the agent process starts.
## Implementation chunks
1. Register `macos-container`, add availability/preflight, bottle
handle, utility wrappers, cleanup, active enumeration, unit tests,
and this PRD.
2. Spike Apple Container networking against real macOS 26 hosts:
repeated `--network`, internal network egress behavior, published
loopback reachability from another container, DNS behavior, and
labels/JSON output stability.
3. Implement launch once the enforcement shape is proven. Reuse the
existing sidecar bundle image and daemon subset env contract where
possible.
4. Add real-runtime integration tests guarded by `container` presence
and macOS version.
5. Consider moving smolmachines sidecar/image-building work to
VM-contained or Apple Container-backed execution only after the
`macos-container` launch path is trustworthy.
## Testing Strategy
- Unit tests cover backend registration through `known_backend_names`.
- Unit tests cover availability/preflight behavior without requiring
macOS.
- Unit tests cover `MacosContainerBottle` command construction and
stdin-based shell execution.
- Unit tests cover cleanup and active enumeration parsing.
- Future integration tests must run on a host with Apple Container
installed and should verify egress cannot bypass the sidecar.
## References
- Issue #220 comment: smolvm `--port/-p` can expose a guest service to
host loopback, and another smolvm guest can reach it through the
existing per-bottle loopback alias path.
- Apple Container command reference: `container run`, `build`, `exec`,
port publishing, and network commands.
+5 -2
View File
@@ -44,8 +44,11 @@ class TestGetBottleBackend(unittest.TestCase):
class TestKnownBackendNames(unittest.TestCase):
def test_returns_both_backends_sorted(self):
self.assertEqual(("docker", "smolmachines"), known_backend_names())
def test_returns_backends_sorted(self):
self.assertEqual(
("docker", "macos-container", "smolmachines"),
known_backend_names(),
)
class TestEnumerateActiveAgents(unittest.TestCase):
+71
View File
@@ -0,0 +1,71 @@
"""Unit: Apple Container bottle command construction."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
class TestMacosContainerBottle(unittest.TestCase):
def test_agent_argv_uses_container_exec(self):
bottle = MacosContainerBottle(
"bot-bottle-dev-abc",
lambda: None,
None,
agent_command="codex",
)
self.assertEqual(
[
"container", "exec", "--interactive", "--tty",
"bot-bottle-dev-abc", "codex", "run",
],
bottle.agent_argv(["run"]),
)
def test_agent_argv_includes_workdir(self):
bottle = MacosContainerBottle(
"bot-bottle-dev-abc",
lambda: None,
None,
agent_workdir="/home/node/workspace",
)
self.assertEqual(
[
"container", "exec", "--interactive", "--tty",
"--workdir", "/home/node/workspace",
"bot-bottle-dev-abc", "claude",
],
bottle.agent_argv([]),
)
def test_exec_pipes_script_to_shell(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
run.return_value.returncode = 7
run.return_value.stdout = "out"
run.return_value.stderr = "err"
result = bottle.exec("echo hi", user="root")
self.assertEqual(7, result.returncode)
self.assertEqual(
[
"container", "exec", "--user", "root", "--interactive",
"bot-bottle-dev-abc", "sh", "-s",
],
run.call_args.args[0],
)
self.assertEqual("echo hi", run.call_args.kwargs["input"])
def test_cp_in_uses_container_cp(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
bottle.cp_in("/tmp/src", "/home/node/src")
self.assertEqual(
["container", "cp", "/tmp/src", "bot-bottle-dev-abc:/home/node/src"],
run.call_args.args[0],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,67 @@
"""Unit: Apple Container cleanup/enumeration helpers."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod
from bot_bottle.backend.macos_container.bottle_cleanup_plan import (
MacosContainerBottleCleanupPlan,
)
class TestMacosContainerCleanup(unittest.TestCase):
def test_lists_prefixed_containers(self):
completed = cleanup.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
stderr="",
)
with patch.object(cleanup.subprocess, "run", return_value=completed):
self.assertEqual(
["bot-bottle-a", "bot-bottle-sidecars-a"],
cleanup._list_prefixed_containers(),
)
def test_cleanup_deletes_containers_and_networks(self):
plan = MacosContainerBottleCleanupPlan(
containers=("bot-bottle-a",),
networks=("bot-bottle-net-a",),
)
with patch.object(cleanup.subprocess, "run") as run:
cleanup.cleanup(plan)
self.assertEqual(
["container", "delete", "--force", "bot-bottle-a"],
run.call_args_list[0].args[0],
)
self.assertEqual(
["container", "network", "delete", "bot-bottle-net-a"],
run.call_args_list[1].args[0],
)
class TestMacosContainerEnumerate(unittest.TestCase):
def test_enumerate_active_reads_metadata(self):
completed = enum_mod.subprocess.CompletedProcess(
args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="",
)
class _Metadata:
agent_name = "impl"
started_at = "2026-06-10T00:00:00Z"
label = "Implement"
color = "blue"
with patch.object(enum_mod.subprocess, "run", return_value=completed), \
patch.object(enum_mod, "read_metadata", return_value=_Metadata()):
agents = enum_mod.enumerate_active()
self.assertEqual(1, len(agents))
self.assertEqual("macos-container", agents[0].backend_name)
self.assertEqual("a", agents[0].slug)
self.assertEqual("impl", agents[0].agent_name)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""Unit: Apple Container utility helpers."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.backend.macos_container import util
class TestMacosContainerAvailability(unittest.TestCase):
def test_available_only_on_macos_with_container(self):
with patch.object(util.platform, "system", return_value="Darwin"), \
patch.object(util.shutil, "which", return_value="/usr/local/bin/container"):
self.assertTrue(util.is_available())
def test_not_available_off_macos(self):
with patch.object(util.platform, "system", return_value="Linux"), \
patch.object(util.shutil, "which", return_value="/usr/local/bin/container"):
self.assertFalse(util.is_available())
def test_require_container_dies_when_missing(self):
with patch.object(util.platform, "system", return_value="Darwin"), \
patch.object(util.shutil, "which", return_value=None), \
patch.object(util, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
util.require_container()
class TestMacosContainerCommands(unittest.TestCase):
def test_build_image(self):
with patch.object(util.subprocess, "run") as run:
util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile")
self.assertEqual(
[
"container", "build", "-t", "bot-bottle-agent:latest",
"-f", "/repo/Dockerfile", "/repo",
],
run.call_args.args[0],
)
self.assertTrue(run.call_args.kwargs["check"])
def test_container_exists_parses_quiet_list(self):
completed = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="",
)
with patch.object(util.subprocess, "run", return_value=completed):
self.assertTrue(util.container_exists("bot-bottle-a"))
self.assertFalse(util.container_exists("bot-bottle-b"))
def test_image_id_reads_json_digest(self):
completed = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout='{"digest":"sha256:abc"}', stderr="",
)
with patch.object(util.subprocess, "run", return_value=completed):
self.assertEqual("sha256:abc", util.image_id("demo:latest"))
if __name__ == "__main__":
unittest.main()