82cf9bab5a
Answers the last open question in #392: DNS from inside a nested container was not a phantom, but it was also not a DNS problem. Public resolution fails there by design — everything egresses through the proxy — and the actual breakage was two missing pieces: - The proxy URL names `bot-bottle-gateway`, which resolves only through the bottle's own /etc/hosts. Containers got their own hosts file, so they failed at "Could not resolve proxy", which reads like broken DNS. base_hosts_file propagates the bottle's entries. - The gateway TLS-intercepts, so a container that does not trust the bottle's CA bundle fails with "unable to get local issuer certificate". The bundle is now mounted read-only and the usual env vars point at it, which covers curl, wget, python, and node without distro-specific trust commands. Verified on the macOS host by reproducing each failure and confirming that these three conditions applied by hand produced HTTP 200 from inside a nested container. podman already forwards the proxy env, so that needed nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""Unit coverage for the fail-closed guest-local container engine (#392)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
from unittest.mock import patch
|
|
|
|
from bot_bottle.backend.macos_container import nested_containers
|
|
from bot_bottle.backend.macos_container import launch as launch_mod
|
|
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
|
|
|
|
|
class _Bottle:
|
|
def __init__(self, results: list[SimpleNamespace]) -> None:
|
|
self.results = results
|
|
self.commands: list[str] = []
|
|
|
|
def exec(self, command: str) -> SimpleNamespace:
|
|
self.commands.append(command)
|
|
return self.results.pop(0)
|
|
|
|
|
|
def _result(returncode: int, *, stdout: str = "", stderr: str = "") -> SimpleNamespace:
|
|
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _AgentProvision:
|
|
image: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Spec:
|
|
image_policy: str = "build"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Plan:
|
|
slug: str
|
|
image: str
|
|
dockerfile_path: str
|
|
nested_containers: bool
|
|
agent_provision: _AgentProvision
|
|
spec: _Spec = _Spec()
|
|
|
|
|
|
def _base_image_only(ref: str) -> bool:
|
|
"""Only the un-derived agent image is cached on the host."""
|
|
return not ref.endswith(nested_containers.IMAGE_SUFFIX)
|
|
|
|
|
|
def _plan(**kwargs: object) -> MacosContainerBottlePlan:
|
|
return cast(MacosContainerBottlePlan, cast(object, _Plan(**kwargs))) # type: ignore[arg-type]
|
|
|
|
|
|
class TestNestedContainersStart(unittest.TestCase):
|
|
def test_bootstraps_then_waits_for_guest_local_service(self) -> None:
|
|
bottle = _Bottle([_result(0), _result(1), _result(0)])
|
|
with patch.object(nested_containers.time, "sleep"):
|
|
nested_containers.start(bottle)
|
|
self.assertIn("nested-containers-init", bottle.commands[0])
|
|
self.assertEqual(2, bottle.commands.count("docker info >/dev/null 2>&1"))
|
|
|
|
def test_bootstrap_failure_is_fatal_without_privilege_fallback(self) -> None:
|
|
bottle = _Bottle([_result(1, stderr="slirp4netns missing")])
|
|
with patch.object(nested_containers, "die", side_effect=RuntimeError) as die:
|
|
with self.assertRaises(RuntimeError):
|
|
nested_containers.start(bottle)
|
|
self.assertIn("slirp4netns missing", die.call_args.args[0])
|
|
self.assertEqual(1, len(bottle.commands))
|
|
|
|
def test_timeout_reports_guest_log(self) -> None:
|
|
bottle = _Bottle(
|
|
[_result(0)]
|
|
+ [_result(1) for _ in range(nested_containers.READY_RETRIES)]
|
|
+ [_result(0, stdout="operation not permitted")]
|
|
)
|
|
with patch.object(nested_containers.time, "sleep"), \
|
|
patch.object(nested_containers, "die", side_effect=RuntimeError) as die:
|
|
with self.assertRaises(RuntimeError):
|
|
nested_containers.start(bottle)
|
|
self.assertIn("operation not permitted", die.call_args.args[0])
|
|
|
|
|
|
class TestNestedContainersDevices(unittest.TestCase):
|
|
def test_relaxes_only_the_two_blocked_device_nodes_as_root(self) -> None:
|
|
calls: list[tuple[str, list[str]]] = []
|
|
|
|
def record(name: str, argv: list[str]) -> None:
|
|
calls.append((name, argv))
|
|
|
|
nested_containers.prepare_guest_devices("bottle-1", record)
|
|
self.assertEqual(1, len(calls))
|
|
name, argv = calls[0]
|
|
self.assertEqual("bottle-1", name)
|
|
self.assertIn("chmod 0666 /dev/fuse /dev/net/tun", argv[-1])
|
|
|
|
|
|
class TestNestedContainersImage(unittest.TestCase):
|
|
def test_layers_tooling_without_changing_base_image(self) -> None:
|
|
calls: list[tuple[str, str, str]] = []
|
|
|
|
def build(image: str, context: str, *, dockerfile: str) -> None:
|
|
calls.append((image, context, dockerfile))
|
|
text = Path(dockerfile).read_text(encoding="utf-8")
|
|
self.assertIn("FROM agent:base", text)
|
|
self.assertIn("aardvark-dns fuse-overlayfs netavark nftables passt", text)
|
|
self.assertIn("USER node", text)
|
|
self.assertTrue((Path(context) / "nested-containers-init.sh").is_file())
|
|
|
|
image = nested_containers.build_image("agent:base", build)
|
|
self.assertEqual("agent:base-nested-containers", image)
|
|
self.assertEqual("agent:base-nested-containers", calls[0][0])
|
|
|
|
def test_installs_the_whole_podman_5_networking_stack(self) -> None:
|
|
"""Each missing piece fails at a different, misleading layer: no pasta
|
|
and nothing starts; no nft and netavark cannot build the bridge that
|
|
compose expects; no aardvark-dns and DNS inside nested containers
|
|
fails while everything else looks healthy. Image pulls keep working
|
|
throughout, which is what made these read as compat-API bugs."""
|
|
seen: list[str] = []
|
|
|
|
def build(_image: str, _context: str, *, dockerfile: str) -> None:
|
|
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
|
|
|
|
nested_containers.build_image("agent:base", build)
|
|
for package in ("passt", "nftables", "aardvark-dns"):
|
|
self.assertIn(package, seen[0])
|
|
|
|
def test_strips_subordinate_ranges_so_podman_avoids_newuidmap(self) -> None:
|
|
"""The single-UID fallback is the entire reason podman works here.
|
|
|
|
A subordinate range would send podman down the newuidmap path, which
|
|
cannot write a multi-range uid_map without CAP_SYS_ADMIN in an Apple
|
|
Container guest — the failure that killed the rootless-Docker spike.
|
|
"""
|
|
seen: list[str] = []
|
|
|
|
def build(_image: str, _context: str, *, dockerfile: str) -> None:
|
|
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
|
|
|
|
nested_containers.build_image("agent:base", build)
|
|
text = seen[0]
|
|
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
|
|
self.assertNotIn("subuid", text.replace(
|
|
"sed -i '/^node:/d' /etc/subuid /etc/subgid", "",
|
|
))
|
|
|
|
|
|
class TestInitScript(unittest.TestCase):
|
|
"""The bootstrap runs inside the bottle, so its wiring is only checkable
|
|
here or on a live macOS host."""
|
|
|
|
def setUp(self) -> None:
|
|
self.script = (
|
|
Path(nested_containers.__file__).with_name("nested-containers-init.sh")
|
|
.read_text(encoding="utf-8")
|
|
)
|
|
|
|
def test_ca_bundle_path_matches_the_backend_constant(self) -> None:
|
|
from bot_bottle.backend.util import AGENT_CA_BUNDLE
|
|
self.assertIn(f'CA_BUNDLE="{AGENT_CA_BUNDLE}"', self.script)
|
|
|
|
def test_propagates_the_bottle_hosts_file_into_containers(self) -> None:
|
|
"""The proxy URL names bot-bottle-gateway, which resolves only via the
|
|
bottle's /etc/hosts. Without this a nested container dies at "Could
|
|
not resolve proxy" — which reads as broken DNS but is a missing hosts
|
|
entry; public DNS is meant to fail."""
|
|
self.assertIn('base_hosts_file="/etc/hosts"', self.script)
|
|
|
|
def test_mounts_and_trusts_the_gateway_ca(self) -> None:
|
|
"""The gateway TLS-intercepts, so without the bundle every HTTPS call
|
|
from a nested container fails with "unable to get local issuer
|
|
certificate"."""
|
|
self.assertIn("volumes=[", self.script)
|
|
for var in (
|
|
"SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
):
|
|
self.assertIn(f'"{var}=${{CA_BUNDLE}}"', self.script)
|
|
|
|
|
|
class TestGuestEnvironment(unittest.TestCase):
|
|
def test_disabled_bottle_gets_no_docker_environment(self) -> None:
|
|
self.assertEqual({}, nested_containers.guest_env(False))
|
|
|
|
def test_enabled_bottle_uses_only_the_guest_local_socket(self) -> None:
|
|
env = nested_containers.guest_env(True)
|
|
self.assertEqual("unix:///tmp/bbp/podman.sock", env["DOCKER_HOST"])
|
|
self.assertNotIn("/var/run/docker.sock", " ".join(env.values()))
|
|
|
|
def test_runtime_dir_leaves_room_for_conmons_attach_socket(self) -> None:
|
|
"""podman builds `$XDG_RUNTIME_DIR/libpod/tmp/socket/<64-hex>/attach`,
|
|
which must fit in sun_path (108 bytes). A descriptive runtime dir blew
|
|
past it and every attached `docker run` failed with "unable to upgrade
|
|
to tcp, received 500" while pulls and detached runs looked fine."""
|
|
attach = (
|
|
nested_containers.guest_env(True)["XDG_RUNTIME_DIR"]
|
|
+ "/libpod/tmp/socket/" + "a" * 64 + "/attach"
|
|
)
|
|
self.assertLessEqual(len(attach), 107, attach)
|
|
|
|
def test_macos_backend_declares_support(self) -> None:
|
|
from bot_bottle.backend.macos_container.backend import (
|
|
MacosContainerBottleBackend,
|
|
)
|
|
self.assertTrue(MacosContainerBottleBackend.supports_nested_containers)
|
|
|
|
|
|
class TestBuildOrLoadImages(unittest.TestCase):
|
|
def test_disabled_bottle_keeps_the_plain_agent_image(self) -> None:
|
|
plan = _plan(
|
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
|
nested_containers=False, agent_provision=_AgentProvision("agent:base"),
|
|
)
|
|
with patch.object(launch_mod, "read_committed_image", return_value=None), \
|
|
patch.object(launch_mod.container_mod, "build_image"), \
|
|
patch.object(launch_mod.nested_containers_mod, "build_image") as derived:
|
|
images = launch_mod.build_or_load_images(plan)
|
|
derived.assert_not_called()
|
|
self.assertEqual("agent:base", images.agent)
|
|
|
|
def test_enabled_bottle_builds_base_then_derived_variant(self) -> None:
|
|
plan = _plan(
|
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
|
nested_containers=True, agent_provision=_AgentProvision("agent:base"),
|
|
)
|
|
with patch.object(launch_mod, "read_committed_image", return_value=None), \
|
|
patch.object(launch_mod.container_mod, "build_image") as build, \
|
|
patch.object(
|
|
launch_mod.nested_containers_mod,
|
|
"build_image",
|
|
return_value="agent:base-nested-containers",
|
|
) as derived:
|
|
images = launch_mod.build_or_load_images(plan)
|
|
|
|
build.assert_called_once_with(
|
|
"agent:base", launch_mod._REPO_DIR, # pylint: disable=protected-access
|
|
dockerfile="/repo/Dockerfile",
|
|
)
|
|
derived.assert_called_once_with("agent:base", build)
|
|
self.assertEqual("agent:base-nested-containers", images.agent)
|
|
|
|
def test_derived_image_layers_onto_a_committed_image(self) -> None:
|
|
plan = _plan(
|
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
|
nested_containers=True, agent_provision=_AgentProvision("agent:base"),
|
|
)
|
|
with patch.object(
|
|
launch_mod, "read_committed_image", return_value="agent:committed",
|
|
), \
|
|
patch.object(launch_mod.container_mod, "image_exists", return_value=True), \
|
|
patch.object(launch_mod.container_mod, "build_image") as build, \
|
|
patch.object(
|
|
launch_mod.nested_containers_mod,
|
|
"build_image",
|
|
return_value="agent:committed-nested-containers",
|
|
) as derived:
|
|
images = launch_mod.build_or_load_images(plan)
|
|
|
|
build.assert_not_called()
|
|
derived.assert_called_once_with("agent:committed", build)
|
|
self.assertEqual("agent:committed-nested-containers", images.agent)
|
|
|
|
def test_cached_policy_refuses_to_build_the_derived_image(self) -> None:
|
|
plan = _plan(
|
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
|
nested_containers=True, agent_provision=_AgentProvision("agent:base"),
|
|
spec=_Spec(image_policy="cached"),
|
|
)
|
|
with patch.object(launch_mod, "read_committed_image", return_value=None), \
|
|
patch.object(
|
|
launch_mod.container_mod, "image_exists",
|
|
side_effect=_base_image_only,
|
|
), \
|
|
patch.object(launch_mod.nested_containers_mod, "build_image") as derived, \
|
|
patch.object(launch_mod, "die", side_effect=RuntimeError) as die:
|
|
with self.assertRaises(RuntimeError):
|
|
launch_mod.build_or_load_images(plan)
|
|
derived.assert_not_called()
|
|
self.assertIn("agent:base-nested-containers", die.call_args.args[0])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|