refactor(de-sidecar): remove the per-bottle companion-container architecture
The per-agent companion container (the egress/git-gate/supervise data plane run once per bottle) is the pre-consolidation architecture. Remove it and disable the backends that still depend on it, per the #385 thread. - Delete `backend/docker/sidecar_bundle.py`; docker's live path uses the consolidated shared gateway, not a per-bottle bundle. - Disable the firecracker and macos-container backends: their `launch()` fails closed (they launched a per-bottle companion; firecracker's consolidated relaunch is #354, macos follows). Their `enumerate` return empty and `cleanup` drop the companion-container discovery (firecracker keeps VMM/run-dir cleanup). - Fail-close both backends' `egress_apply` reload (it signalled the per-bottle container); consolidated egress policy resolves per-request against the orchestrator, so gateway-side apply is a follow-up. - Rename `egress_sidecar_env_entries` → `egress_gateway_env_entries`, `SIDECAR_PORTS` → `GATEWAY_PORTS`. - Move the shared DockerBottlePlan fixture to `tests/unit/_docker_bottle_plan.py`; delete tests for the removed launch paths; update cleanup/egress-apply tests. Docker consolidated launch verified end-to-end (multitenant isolation integration test passes). macos/firecracker are intentionally disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -1,142 +0,0 @@
|
||||
"""Integration: Firecracker microVM launch.
|
||||
|
||||
End-to-end against a real Firecracker microVM: prepare + launch a bottle
|
||||
on the firecracker backend and verify the agent execs after provisioning
|
||||
and that the egress proxy env is wired to the sidecar.
|
||||
|
||||
Gated on the `backend status` result for firecracker (0 == the privileged
|
||||
TAP pool + nft isolation table are provisioned). Skips cleanly with setup
|
||||
instructions otherwise, so the suite runs on hosts without the pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
def _firecracker_status_ok() -> bool:
|
||||
"""Gate on `./cli.py backend status --backend=firecracker`: a 0 exit
|
||||
means the pool + nft table are ready. Output is captured so the
|
||||
decorator stays quiet during collection; any error → not ready."""
|
||||
buf = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
return FirecrackerBottleBackend.status() == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_SKIP_MSG = (
|
||||
"firecracker backend not ready — provision the network pool with "
|
||||
"`./cli.py backend setup --backend=firecracker`, then confirm with "
|
||||
"`./cli.py backend status --backend=firecracker`"
|
||||
)
|
||||
|
||||
|
||||
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) -> ManifestIndex:
|
||||
return ManifestIndex.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": "smoke", "bottle": "dev"},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: cannot host Firecracker microVMs",
|
||||
)
|
||||
@unittest.skipUnless(_firecracker_status_ok(), _SKIP_MSG)
|
||||
class TestFirecrackerLaunch(unittest.TestCase):
|
||||
"""Launch once, reuse the bottle across probes."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.stage = Path(tempfile.mkdtemp(prefix="cb-firecracker-launch."))
|
||||
cls._launch = None
|
||||
cls.bottle = None
|
||||
dockerfile = cls.stage / "Dockerfile.agent-smoke"
|
||||
_minimal_agent_dockerfile(dockerfile)
|
||||
os.environ["BOT_BOTTLE_BACKEND"] = "firecracker"
|
||||
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) -> None:
|
||||
r = self.bottle.exec("echo hello-from-firecracker") # type: ignore[union-attr]
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
self.assertIn("hello-from-firecracker", r.stdout)
|
||||
|
||||
def test_proxy_env_points_at_sidecar(self) -> None:
|
||||
r = self.bottle.exec( # type: ignore[union-attr]
|
||||
"printf '%s\\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\""
|
||||
)
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
self.assertIn("http", r.stdout.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,239 +0,0 @@
|
||||
"""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 ManifestIndex
|
||||
|
||||
|
||||
_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) -> ManifestIndex:
|
||||
return ManifestIndex.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()
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Integration: end-to-end smoke for the PRD 0024 bundle shape.
|
||||
|
||||
Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a
|
||||
working bottle: `docker compose up` brings the agent + bundle pair
|
||||
online, the daemons inside the bundle bind their ports, and the
|
||||
agent can reach egress + supervise via the bundle's network
|
||||
aliases (no agent-side config changes between flag positions).
|
||||
|
||||
Skipped under GITEA_ACTIONS — the bundle image is a multi-stage
|
||||
build pulling 200+MB of base layers, and the bind-mounts won't
|
||||
share filesystem with the runner container. Same constraint as
|
||||
the chunk-1 image-probe test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
def _manifest() -> ManifestIndex:
|
||||
"""Bottle with supervise on so the bundle exercises egress +
|
||||
supervise. Git is off because a meaningful git-gate test needs
|
||||
a real upstream and SSH keys — out of scope for a bundle smoke."""
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"supervise": True,
|
||||
},
|
||||
},
|
||||
"agents": {
|
||||
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: multi-stage bundle build pulls 200+MB "
|
||||
"of base layers and bind-mounts don't share fs with the runner",
|
||||
)
|
||||
class TestSidecarBundleCompose(unittest.TestCase):
|
||||
"""One end-to-end pass with the bundle flag on. Skipping under
|
||||
act_runner; the local docker daemon does the work."""
|
||||
|
||||
def test_bottle_up_with_bundle_flag_on(self):
|
||||
stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke."))
|
||||
try:
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}):
|
||||
backend = get_bottle_backend("docker")
|
||||
spec = BottleSpec(
|
||||
manifest=_manifest(),
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd=str(stage_dir),
|
||||
)
|
||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||
with backend.launch(plan) as bottle:
|
||||
# The agent's HTTPS_PROXY URL (resolved at
|
||||
# renderer-time) should reach egress inside
|
||||
# the bundle. A bare CONNECT with no upstream
|
||||
# URL gets rejected with 400 or 405 but proves
|
||||
# the listener is alive at the alias.
|
||||
probe = bottle.exec(
|
||||
"set -eu\n"
|
||||
"echo HTTPS_PROXY=$HTTPS_PROXY\n"
|
||||
"PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n"
|
||||
"HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n"
|
||||
"echo HOST=$HOST PORT=$PORT\n"
|
||||
"curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' "
|
||||
" \"http://$HOST:$PORT/\" || true\n"
|
||||
)
|
||||
# The supervise URL resolves to the same bundle
|
||||
# via its supervise alias, on a different port.
|
||||
supervise_probe = bottle.exec(
|
||||
"set -eu\n"
|
||||
"curl -sS --max-time 5 -o /dev/null "
|
||||
" -w 'http=%{http_code}\\n' "
|
||||
" \"http://supervise:9100/health\" || true\n"
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||
|
||||
self.assertEqual(0, probe.returncode, msg=probe.stderr)
|
||||
# egress answered SOMETHING — any 4xx is fine, just proves
|
||||
# the egress daemon is listening at the proxy address.
|
||||
self.assertIn("http=", probe.stdout,
|
||||
f"no HTTP response from egress: {probe.stdout!r}")
|
||||
# supervise's /health endpoint exists (PRD 0013); it should
|
||||
# answer 200 or similar — anything non-empty proves the
|
||||
# third daemon's alias resolves to the same bundle.
|
||||
self.assertEqual(0, supervise_probe.returncode, msg=supervise_probe.stderr)
|
||||
self.assertIn("http=", supervise_probe.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user