PRD: macOS Container backend - Part III (integration coverage) #232

Merged
didericis merged 3 commits from feat/macos-container-integration-tests into feat/macos-container-launch 2026-06-10 21:32:03 -04:00
5 changed files with 299 additions and 29 deletions
Showing only changes of commit f7f9892b53 - Show all commits
+1 -1
View File
@@ -264,7 +264,7 @@ def _agent_run_argv(
def _sidecar_dns() -> str: def _sidecar_dns() -> str:
return os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "1.1.1.1") return container_mod.dns_server()
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
+7 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
@@ -12,6 +13,7 @@ from ...log import die, info
_CONTAINER = "container" _CONTAINER = "container"
_DEFAULT_DNS = "1.1.1.1"
def is_macos() -> bool: def is_macos() -> bool:
@@ -33,13 +35,17 @@ def require_container() -> None:
die("container not found") die("container not found")
def dns_server() -> str:
return os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", _DEFAULT_DNS)
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
"""Build an OCI image with Apple's BuildKit-backed `container build`.""" """Build an OCI image with Apple's BuildKit-backed `container build`."""
info( info(
f"building image {ref} from {context} with Apple Container " f"building image {ref} from {context} with Apple Container "
"(layer cache keeps repeat builds fast)" "(layer cache keeps repeat builds fast)"
) )
args = [_CONTAINER, "build", "-t", ref] args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
if dockerfile: if dockerfile:
args.extend(["-f", dockerfile]) args.extend(["-f", dockerfile])
args.append(context) args.append(context)
+47 -25
View File
@@ -9,10 +9,10 @@
Add an experimental `macos-container` backend that integrates Apple's Add an experimental `macos-container` backend that integrates Apple's
`container` CLI as a host runtime on macOS. The first shipped slice `container` CLI as a host runtime on macOS. The first shipped slice
registers the backend, implements the reusable host primitives registers the backend and implements reusable host primitives
(`build`, `exec`, `cp`, image inspection, cleanup, active (`build`, `exec`, `cp`, image inspection, cleanup, active
enumeration), and blocks full launch behind an explicit network enumeration). Follow-up slices make launch runnable with the proven
enforcement guard. This creates a real integration point without two-network sidecar topology and add real-runtime coverage, without
weakening bot-bottle's sidecar egress model. weakening bot-bottle's sidecar egress model.
## Problem ## Problem
@@ -49,10 +49,15 @@ path around the egress sidecar.
- The backend has tested wrappers for Apple Container image build, - The backend has tested wrappers for Apple Container image build,
image inspection, container `exec`, container `cp`, cleanup, and image inspection, container `exec`, container `cp`, cleanup, and
active-agent enumeration. active-agent enumeration.
- Full launch fails loudly with an operator-facing message until the - Full launch uses a host-only internal network for the agent and a
sidecar network enforcement design is implemented. separate NAT egress network for the sidecar bundle.
- The PRD records the remaining launch work so the next PR can make the - The agent container does not attach to the egress network. It reaches
backend runnable without revisiting registration or wrapper plumbing. allowed outbound hosts through HTTP(S)_PROXY pointing at the
sidecar's internal-network IP.
- `bottle.git` / git-gate bottles fail loudly on this backend until a
safe Apple Container key-delivery path exists.
- Real-runtime integration coverage is present and guarded by macOS and
Apple Container availability.
## Non-goals ## Non-goals
@@ -101,25 +106,38 @@ The bottle handle mirrors `DockerBottle`: it builds a host argv for
foreground agent execution, pipes shell snippets through stdin for foreground agent execution, pipes shell snippets through stdin for
`Bottle.exec`, and exposes `cp_in` for provisioning. `Bottle.exec`, and exposes `cp_in` for provisioning.
### Launch guard ### Launch topology
`launch()` is intentionally not enabled in the first slice. It exits `launch()` uses Apple Container's two-network topology:
with a fatal message explaining that sidecar network enforcement still
needs implementation.
This is deliberate. A runnable backend that places the agent on a - create a host-only internal network for the bottle;
normal outbound network while relying on environment variables for - create a normal NAT egress network for the sidecar bundle;
proxying would violate bot-bottle's egress model. The runnable version - start the sidecar bundle attached to the egress network first and the
must prove one of these shapes: internal network second;
- discover the sidecar's internal-network IPv4 address from
`container inspect`;
- start the agent attached only to the internal network, with
HTTP_PROXY / HTTPS_PROXY / lowercase proxy vars pointing at the
sidecar IP and egress port.
- Apple Container supports the equivalent of Docker's two-network This keeps the agent off the outbound network while preserving the
sidecar topology: agent on an internal-only network, sidecar on both proxy-env contract that existing agent tooling already honors. The
internal and egress networks. integration smoke also removes the proxy env in-guest and confirms
- The sidecar bundle runs as a separate VM/container with published direct egress fails.
loopback ports, and the agent runtime can be constrained to only
reach that per-bottle loopback alias. ### Deferred git-gate support
- Apple Container init/network hooks can enforce the egress sidecar as
the only outbound path before the agent process starts. Apple Container currently rejects single-file bind mounts, and
`container cp` into a stopped container is not available. Starting the
container earlier would allow `container cp` into a running container,
but it would also mean delivering SSH private key material into a live
sidecar before the git-gate daemon is ready to own it. Mounting broad
host SSH directories is not acceptable.
For this PRD, `bottle.git` / git-gate support is explicitly deferred on
the `macos-container` backend. Bottles with git-gate upstreams fail
loudly and should use `docker` or `smolmachines` until a narrower key
delivery design lands.
## Implementation chunks ## Implementation chunks
@@ -147,8 +165,12 @@ must prove one of these shapes:
- Unit tests cover `MacosContainerBottle` command construction and - Unit tests cover `MacosContainerBottle` command construction and
stdin-based shell execution. stdin-based shell execution.
- Unit tests cover cleanup and active enumeration parsing. - Unit tests cover cleanup and active enumeration parsing.
- Future integration tests must run on a host with Apple Container - Unit tests cover launch argv/env construction, sidecar mount
installed and should verify egress cannot bypass the sidecar. staging, sidecar IP parsing, and git-gate rejection.
- Integration tests run on macOS hosts with Apple Container installed
and verify that egress cannot bypass the sidecar. They also preflight
Apple Container BuildKit DNS because image builds must resolve
package mirrors before a launch smoke can be meaningful.
## References ## References
@@ -0,0 +1,239 @@
"""Integration: macOS Container launch topology.
End-to-end against Apple's real `container` runtime. The smoke launches
a bottle with the experimental macOS Container backend and verifies the
properties that make the explicit-proxy launch acceptable:
- the agent can exec commands after provisioning;
- HTTP(S)_PROXY points at the sidecar's internal-network IP;
- allowlisted HTTPS reaches the egress sidecar;
- direct egress with proxy env removed fails from the internal-only
agent network;
- non-allowlisted proxy traffic is blocked.
Skipped under Gitea Actions and on hosts without Apple's `container`.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.macos_container.util import (
dns_server as _container_dns_server,
is_available as _container_available,
)
from bot_bottle.manifest import Manifest
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
def _minimal_agent_dockerfile(path: Path) -> None:
path.write_text(
"\n".join((
"FROM node:22-slim",
"RUN apt-get update \\",
" && apt-get install -y --no-install-recommends \\",
" ca-certificates curl git \\",
" && rm -rf /var/lib/apt/lists/*",
"USER node",
"WORKDIR /home/node",
"CMD [\"sleep\", \"infinity\"]",
"",
)),
encoding="utf-8",
)
def _minimal_manifest(dockerfile: Path) -> Manifest:
return Manifest.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {
"template": "pi",
"dockerfile": str(dockerfile),
"settings": {
"provider": "example",
"base_url": "https://example.com/v1",
"models": ["smoke"],
},
},
"egress": {
"routes": [
{"host": "example.com"},
],
},
},
},
"agents": {
"demo": {
"skills": [],
"prompt": _AGENT_PROMPT,
"bottle": "dev",
},
},
})
def _buildkit_dns_available() -> bool:
if platform.system() != "Darwin" or not _container_available():
return False
stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns."))
image = "bot-bottle-buildkit-dns-check:latest"
try:
dockerfile = stage / "Dockerfile"
dockerfile.write_text(
"FROM debian:bookworm-slim\n"
"RUN getent hosts deb.debian.org\n",
encoding="utf-8",
)
result = subprocess.run(
[
"container", "build",
"--dns", _container_dns_server(),
"-t", image,
"-f", str(dockerfile),
str(stage),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
finally:
subprocess.run(
["container", "image", "delete", image],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
shutil.rmtree(stage, ignore_errors=True)
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: cannot host Apple Container VMs",
)
@unittest.skipUnless(
platform.system() == "Darwin",
"Apple Container is macOS-only",
)
@unittest.skipUnless(
_container_available(),
"Apple Container not on PATH; install from "
"https://github.com/apple/container/releases",
)
@unittest.skipUnless(
_buildkit_dns_available(),
"Apple Container BuildKit cannot resolve deb.debian.org on this host",
)
class TestMacosContainerLaunch(unittest.TestCase):
"""Launch once and reuse the bottle across probes."""
@classmethod
def setUpClass(cls) -> None:
cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch."))
cls._launch = None
cls.bottle = None
dockerfile = cls.stage / "Dockerfile.agent-smoke"
_minimal_agent_dockerfile(dockerfile)
os.environ["BOT_BOTTLE_BACKEND"] = "macos-container"
try:
backend = get_bottle_backend()
spec = BottleSpec(
manifest=_minimal_manifest(dockerfile),
agent_name="demo",
copy_cwd=False,
user_cwd=str(cls.stage),
)
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
cls._launch = backend.launch(cls.plan)
cls.bottle = cls._launch.__enter__()
except BaseException:
if cls._launch is not None:
cls._launch.__exit__(None, None, None)
shutil.rmtree(cls.stage, ignore_errors=True)
os.environ.pop("BOT_BOTTLE_BACKEND", None)
raise
@classmethod
def tearDownClass(cls) -> None:
try:
if cls._launch is not None:
cls._launch.__exit__(None, None, None)
finally:
shutil.rmtree(cls.stage, ignore_errors=True)
os.environ.pop("BOT_BOTTLE_BACKEND", None)
def test_smoke_exec_echo(self):
r = self.bottle.exec( # type: ignore[union-attr]
"echo hello-from-macos-container"
)
self.assertEqual(0, r.returncode, msg=r.stderr)
self.assertIn("hello-from-macos-container", r.stdout)
def test_proxy_env_points_at_sidecar_internal_ip(self):
r = self.bottle.exec( # type: ignore[union-attr]
"printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" "
"\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\""
)
self.assertEqual(0, r.returncode, msg=r.stderr)
values = [line.strip() for line in r.stdout.splitlines()]
self.assertEqual(4, len(values), values)
self.assertEqual(values[0], values[1], values)
self.assertRegex(values[0], r"^http://[0-9.]+:9099$")
self.assertNotIn("127.0.0.1", values[0])
sidecar_host = values[0].removeprefix("http://").removesuffix(":9099")
self.assertIn(sidecar_host, values[2])
self.assertEqual(
"/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt",
values[3],
)
def test_allowlisted_https_reaches_egress_proxy(self):
r = self.bottle.exec( # type: ignore[union-attr]
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
)
self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout)
self.assertIn("OK", r.stdout)
def test_direct_egress_bypass_without_proxy_fails(self):
r = self.bottle.exec( # type: ignore[union-attr]
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
"curl -s --show-error --max-time 5 https://example.com 2>&1 || true"
)
self.assertTrue(
"refused" in r.stdout.lower()
or "timed out" in r.stdout.lower()
or "unreachable" in r.stdout.lower()
or "failed" in r.stdout.lower()
or "could not resolve" in r.stdout.lower()
or "connection reset" in r.stdout.lower(),
f"expected direct egress to fail; got: {r.stdout!r}",
)
def test_non_allowlisted_host_fails_through_proxy(self):
r = self.bottle.exec( # type: ignore[union-attr]
"curl -s --show-error --max-time 10 https://iana.org 2>&1 || true"
)
self.assertTrue(
"403" in r.stdout
or "502" in r.stdout
or "blocked" in r.stdout.lower()
or "not allowed" in r.stdout.lower()
or "not in the bottle's egress.routes allowlist" in r.stdout.lower()
or "forbidden" in r.stdout.lower()
or "failed" in r.stdout.lower(),
f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}",
)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -29,12 +29,15 @@ class TestMacosContainerAvailability(unittest.TestCase):
class TestMacosContainerCommands(unittest.TestCase): class TestMacosContainerCommands(unittest.TestCase):
def test_build_image(self): def test_build_image(self):
with patch.object(util.subprocess, "run") as run: with patch.object(util.subprocess, "run") as run, \
patch.object(util.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}):
util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile") util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile")
self.assertEqual( self.assertEqual(
[ [
"container", "build", "-t", "bot-bottle-agent:latest", "container", "build", "-t", "bot-bottle-agent:latest",
"-f", "/repo/Dockerfile", "/repo", "--dns", "9.9.9.9", "-f", "/repo/Dockerfile", "/repo",
], ],
run.call_args.args[0], run.call_args.args[0],
) )