feat(macos): replace rootless Docker spike with rootless podman
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / integration-docker (pull_request) Successful in 32s
test / unit (pull_request) Successful in 36s
lint / lint (push) Failing after 52s
test / build-infra (pull_request) Successful in 4m5s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-firecracker (pull_request) Successful in 2m2s
test / coverage (pull_request) Successful in 2m7s
test / publish-infra (pull_request) Has been skipped
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / integration-docker (pull_request) Successful in 32s
test / unit (pull_request) Successful in 36s
lint / lint (push) Failing after 52s
test / build-infra (pull_request) Successful in 4m5s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-firecracker (pull_request) Successful in 2m2s
test / coverage (pull_request) Successful in 2m7s
test / publish-infra (pull_request) Has been skipped
Podman avoids the CAP_SYS_ADMIN requirement that makes rootless Docker impossible here: with no subordinate UID range configured it falls back to a single-UID self-mapping, which an unprivileged process may write itself, so newuidmap is never invoked. The image build therefore strips /etc/subuid and /etc/subgid entries rather than adding them. The agent-facing surface is unchanged — docker and docker compose talk to podman's Docker-compatible API socket. Two device nodes need relaxing as root inside the bottle (0666 on /dev/fuse and /dev/net/tun); both already exist and neither needs a capability the bottle lacks, unlike CAP_SYS_ADMIN. Verified live on macOS 26 / Apple Container 1.0.0: service starts with zero added capabilities, compose pulls and serves from the workspace on a published port, and a nested container cannot reach the network outside the egress path. Known limitation, not yet addressed: the agent base image is Debian bookworm, whose podman 4.3.1 swallows container exit codes through the compat API (docker run returns 0 regardless). podman 5.4.2 on trixie fixes it; the base bump is a separate PR. The acceptance test asserts on an in-band marker rather than an exit code so it cannot false-pass in the meantime. Nested containers give no isolation from the agent itself — root inside one is the agent user outside it. They are a build/test convenience; the bottle remains the security boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnMp8SUGH57hZX7192Rv2F
This commit is contained in:
@@ -22,7 +22,7 @@ from bot_bottle.backend.macos_container.launch import (
|
||||
_agent_run_argv,
|
||||
_identity_proxy_env,
|
||||
)
|
||||
from bot_bottle.backend.macos_container.rootless_docker import guest_env
|
||||
from bot_bottle.backend.macos_container.rootless_podman import guest_env
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
_BOTTLE = "bot_bottle.backend.macos_container.bottle"
|
||||
@@ -180,14 +180,14 @@ class TestIdentityTokenDelivery(unittest.TestCase):
|
||||
self.assertNotIn("--env", argv)
|
||||
|
||||
|
||||
class TestRootlessDockerEnvironment(unittest.TestCase):
|
||||
class TestRootlessPodmanEnvironment(unittest.TestCase):
|
||||
def test_disabled_bottle_gets_no_docker_environment(self) -> None:
|
||||
self.assertEqual({}, guest_env(False))
|
||||
|
||||
def test_enabled_bottle_uses_only_guest_local_socket(self) -> None:
|
||||
env = guest_env(True)
|
||||
self.assertEqual(
|
||||
"unix:///tmp/bot-bottle-docker-run/docker.sock", env["DOCKER_HOST"],
|
||||
"unix:///tmp/bot-bottle-podman-run/podman.sock", env["DOCKER_HOST"],
|
||||
)
|
||||
self.assertNotIn("/var/run/docker.sock", " ".join(env.values()))
|
||||
|
||||
|
||||
+55
-27
@@ -1,4 +1,4 @@
|
||||
"""Unit coverage for the fail-closed macOS rootless-Docker spike."""
|
||||
"""Unit coverage for the fail-closed macOS rootless-podman spike."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container import rootless_docker
|
||||
from bot_bottle.backend.macos_container import rootless_podman
|
||||
from bot_bottle.backend.macos_container import launch as launch_mod
|
||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
@@ -42,36 +42,48 @@ class _Plan:
|
||||
agent_provision: _AgentProvision
|
||||
|
||||
|
||||
class TestRootlessDockerStart(unittest.TestCase):
|
||||
def test_bootstraps_then_waits_for_guest_local_daemon(self) -> None:
|
||||
class TestRootlessPodmanStart(unittest.TestCase):
|
||||
def test_bootstraps_then_waits_for_guest_local_service(self) -> None:
|
||||
bottle = _Bottle([_result(0), _result(1), _result(0)])
|
||||
with patch.object(rootless_docker.time, "sleep"):
|
||||
rootless_docker.start(bottle)
|
||||
self.assertIn("rootless-docker-init", bottle.commands[0])
|
||||
with patch.object(rootless_podman.time, "sleep"):
|
||||
rootless_podman.start(bottle)
|
||||
self.assertIn("rootless-podman-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="newuidmap missing")])
|
||||
with patch.object(rootless_docker, "die", side_effect=RuntimeError) as die:
|
||||
bottle = _Bottle([_result(1, stderr="slirp4netns missing")])
|
||||
with patch.object(rootless_podman, "die", side_effect=RuntimeError) as die:
|
||||
with self.assertRaises(RuntimeError):
|
||||
rootless_docker.start(bottle)
|
||||
self.assertIn("newuidmap missing", die.call_args.args[0])
|
||||
rootless_podman.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(rootless_docker.READY_RETRIES)]
|
||||
+ [_result(1) for _ in range(rootless_podman.READY_RETRIES)]
|
||||
+ [_result(0, stdout="operation not permitted")]
|
||||
)
|
||||
with patch.object(rootless_docker.time, "sleep"), \
|
||||
patch.object(rootless_docker, "die", side_effect=RuntimeError) as die:
|
||||
with patch.object(rootless_podman.time, "sleep"), \
|
||||
patch.object(rootless_podman, "die", side_effect=RuntimeError) as die:
|
||||
with self.assertRaises(RuntimeError):
|
||||
rootless_docker.start(bottle)
|
||||
rootless_podman.start(bottle)
|
||||
self.assertIn("operation not permitted", die.call_args.args[0])
|
||||
|
||||
|
||||
class TestRootlessDockerImage(unittest.TestCase):
|
||||
class TestRootlessPodmanDevices(unittest.TestCase):
|
||||
def test_relaxes_only_the_two_blocked_device_nodes_as_root(self) -> None:
|
||||
calls: list[tuple[str, list[str]]] = []
|
||||
rootless_podman.prepare_guest_devices(
|
||||
"bottle-1", lambda name, argv: calls.append((name, argv)),
|
||||
)
|
||||
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 TestRootlessPodmanImage(unittest.TestCase):
|
||||
def test_layers_tooling_without_changing_base_image(self) -> None:
|
||||
calls: list[tuple[str, str, str]] = []
|
||||
|
||||
@@ -79,16 +91,32 @@ class TestRootlessDockerImage(unittest.TestCase):
|
||||
calls.append((image, context, dockerfile))
|
||||
text = Path(dockerfile).read_text(encoding="utf-8")
|
||||
self.assertIn("FROM agent:base", text)
|
||||
self.assertIn("docker.io uidmap rootlesskit slirp4netns", text)
|
||||
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
|
||||
self.assertEqual(1, text.count("node:100000:65536\\n' >> /etc/subuid"))
|
||||
self.assertEqual(1, text.count("node:100000:65536\\n' >> /etc/subgid"))
|
||||
self.assertIn("podman fuse-overlayfs slirp4netns uidmap", text)
|
||||
self.assertIn("USER node", text)
|
||||
self.assertTrue((Path(context) / "rootless-docker-init.sh").is_file())
|
||||
self.assertTrue((Path(context) / "rootless-podman-init.sh").is_file())
|
||||
|
||||
image = rootless_docker.build_image("agent:base", build)
|
||||
self.assertEqual("agent:base-rootless-docker", image)
|
||||
self.assertEqual("agent:base-rootless-docker", calls[0][0])
|
||||
image = rootless_podman.build_image("agent:base", build)
|
||||
self.assertEqual("agent:base-rootless-podman", image)
|
||||
self.assertEqual("agent:base-rootless-podman", calls[0][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"))
|
||||
|
||||
rootless_podman.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", "",
|
||||
))
|
||||
|
||||
def test_launch_builds_base_then_rootless_variant(self) -> None:
|
||||
plan = cast(MacosContainerBottlePlan, cast(object, _Plan(
|
||||
@@ -101,9 +129,9 @@ class TestRootlessDockerImage(unittest.TestCase):
|
||||
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.rootless_docker,
|
||||
launch_mod.rootless_podman,
|
||||
"build_image",
|
||||
return_value="agent:base-rootless-docker",
|
||||
return_value="agent:base-rootless-podman",
|
||||
) as build_rootless:
|
||||
result = launch_mod._build_images(plan) # pylint: disable=protected-access
|
||||
|
||||
@@ -112,7 +140,7 @@ class TestRootlessDockerImage(unittest.TestCase):
|
||||
dockerfile="/repo/Dockerfile",
|
||||
)
|
||||
build_rootless.assert_called_once_with("agent:base", build)
|
||||
self.assertEqual("agent:base-rootless-docker", result.agent_provision.image)
|
||||
self.assertEqual("agent:base-rootless-podman", result.agent_provision.image)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Reference in New Issue
Block a user