PRD: macOS Container backend - Part III (integration coverage) #232
@@ -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, ...]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user