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:
@@ -64,7 +64,7 @@ from .gateway_hosts import (
|
||||
refresh_gateway_host,
|
||||
set_gateway_host,
|
||||
)
|
||||
from . import rootless_docker
|
||||
from . import rootless_podman
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
from ...orchestrator.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import (
|
||||
@@ -174,7 +174,7 @@ def launch(
|
||||
|
||||
exec_env = {
|
||||
**_identity_proxy_env(endpoint, ctx.identity_token),
|
||||
**rootless_docker.guest_env(plan.docker_access),
|
||||
**rootless_podman.guest_env(plan.docker_access),
|
||||
}
|
||||
bottle = MacosContainerBottle(
|
||||
plan.container_name,
|
||||
@@ -194,7 +194,10 @@ def launch(
|
||||
bottle.prompt_path = provision(plan, bottle)
|
||||
|
||||
if plan.docker_access:
|
||||
rootless_docker.start(bottle)
|
||||
rootless_podman.prepare_guest_devices(
|
||||
plan.container_name, container_mod.exec_container_as_root,
|
||||
)
|
||||
rootless_podman.start(bottle)
|
||||
|
||||
yield bottle
|
||||
finally:
|
||||
@@ -218,7 +221,7 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
if plan.docker_access:
|
||||
image = rootless_docker.build_image(plan.image, container_mod.build_image)
|
||||
image = rootless_podman.build_image(plan.image, container_mod.build_image)
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=image),
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
uid="$(id -u)"
|
||||
if [ "$uid" -eq 0 ]; then
|
||||
echo "refusing to run rootless Docker as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in dockerd-rootless.sh rootlesskit slirp4netns newuidmap newgidmap docker; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
echo "missing rootless Docker prerequisite: $command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
grep -q "^$(id -un):.*:65536$" /etc/subuid || {
|
||||
echo "missing 65536-entry subordinate UID range for $(id -un)" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "^$(id -gn):.*:65536$" /etc/subgid || {
|
||||
echo "missing 65536-entry subordinate GID range for $(id -gn)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bot-bottle-docker-run}"
|
||||
mkdir -p "$XDG_RUNTIME_DIR" "$HOME/.docker"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
# Docker uses this config for build and child-container proxy injection. The
|
||||
# token-bearing proxy URL is already available to the agent; persisting it
|
||||
# inside this disposable VM does not broaden its authority.
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "")
|
||||
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "")
|
||||
config = {"proxies": {"default": {
|
||||
"httpProxy": proxy,
|
||||
"httpsProxy": proxy,
|
||||
"noProxy": no_proxy,
|
||||
}}}
|
||||
path = Path.home() / ".docker" / "config.json"
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
PY
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log=/tmp/bot-bottle-rootless-docker.log
|
||||
nohup dockerd-rootless.sh \
|
||||
--storage-driver=fuse-overlayfs \
|
||||
>"$log" 2>&1 </dev/null &
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
uid="$(id -u)"
|
||||
if [ "$uid" -eq 0 ]; then
|
||||
echo "refusing to run rootless podman as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in podman docker fuse-overlayfs slirp4netns; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
echo "missing rootless podman prerequisite: $command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# The inverse of the rootless-Docker check, and the whole point of the podman
|
||||
# variant: a subordinate range would push podman onto newuidmap, which cannot
|
||||
# write a multi-range uid_map without CAP_SYS_ADMIN in this guest. An empty
|
||||
# range keeps it on the single-UID self-mapping an unprivileged process may
|
||||
# write itself.
|
||||
if grep -q "^$(id -un):" /etc/subuid 2>/dev/null; then
|
||||
echo "unexpected subordinate UID range for $(id -un): podman would" >&2
|
||||
echo "require CAP_SYS_ADMIN via newuidmap in this guest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for device in /dev/fuse /dev/net/tun; do
|
||||
[ -r "$device" ] && [ -w "$device" ] || {
|
||||
echo "device $device is not readable/writable by $(id -un)" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bot-bottle-podman-run}"
|
||||
config="$HOME/.config/containers"
|
||||
mkdir -p "$XDG_RUNTIME_DIR" "$config"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
# ignore_chown_errors is required, not incidental: with a single-UID mapping
|
||||
# there is no second UID for image layers to be chowned to, so layers that
|
||||
# record other owners would otherwise fail to extract.
|
||||
cat > "$config/storage.conf" <<'CONF'
|
||||
[storage]
|
||||
driver="overlay"
|
||||
[storage.options.overlay]
|
||||
mount_program="/usr/bin/fuse-overlayfs"
|
||||
ignore_chown_errors="true"
|
||||
CONF
|
||||
|
||||
# No cgroup delegation reaches this guest, so asking podman to manage cgroups
|
||||
# fails; events_logger=file avoids the journald socket that is equally absent.
|
||||
cat > "$config/containers.conf" <<'CONF'
|
||||
[containers]
|
||||
cgroups="disabled"
|
||||
[engine]
|
||||
cgroup_manager="cgroupfs"
|
||||
events_logger="file"
|
||||
CONF
|
||||
|
||||
# Registry pulls egress through the bottle's proxy like everything else. The
|
||||
# token-bearing proxy URL is already in the agent's environment; persisting it
|
||||
# inside this disposable VM does not broaden its authority.
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "")
|
||||
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "")
|
||||
config = {"proxies": {"default": {
|
||||
"httpProxy": proxy,
|
||||
"httpsProxy": proxy,
|
||||
"noProxy": no_proxy,
|
||||
}}}
|
||||
path = Path.home() / ".docker" / "config.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
PY
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log=/tmp/bot-bottle-rootless-podman.log
|
||||
nohup podman system service --time=0 \
|
||||
"unix://$XDG_RUNTIME_DIR/podman.sock" \
|
||||
>"$log" 2>&1 </dev/null &
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Experimental rootless Docker bootstrap for Apple-container bottles.
|
||||
|
||||
The daemon and every child remain inside the existing per-bottle VM. This
|
||||
module deliberately refuses to compensate for missing prerequisites with
|
||||
outer capabilities, a privileged container, or a host Docker socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
_INIT = "/usr/local/libexec/bot-bottle/rootless-docker-init"
|
||||
_RUNTIME_DIR = "/tmp/bot-bottle-docker-run"
|
||||
_SOCKET = f"{_RUNTIME_DIR}/docker.sock"
|
||||
READY_RETRIES = 30
|
||||
|
||||
|
||||
def build_image(
|
||||
base_image: str,
|
||||
build: Callable[..., None],
|
||||
) -> str:
|
||||
"""Layer spike-only tooling on an already-built provider image."""
|
||||
image = f"{base_image}-rootless-docker"
|
||||
init_script = Path(__file__).with_name("rootless-docker-init.sh")
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-rootless-docker.") as tmp:
|
||||
context = Path(tmp)
|
||||
shutil.copy2(init_script, context / "rootless-docker-init.sh")
|
||||
(context / "Dockerfile").write_text(
|
||||
"FROM docker:28-cli AS docker_cli\n"
|
||||
f"FROM {base_image}\n"
|
||||
"USER root\n"
|
||||
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
|
||||
"docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose\n"
|
||||
"RUN apt-get update \\\n"
|
||||
" && apt-get install -y --no-install-recommends docker.io uidmap "
|
||||
"rootlesskit slirp4netns fuse-overlayfs \\\n"
|
||||
" && rm -rf /var/lib/apt/lists/* \\\n"
|
||||
" && sed -i '/^node:/d' /etc/subuid /etc/subgid \\\n"
|
||||
" && printf 'node:100000:65536\\n' >> /etc/subuid \\\n"
|
||||
" && printf 'node:100000:65536\\n' >> /etc/subgid \\\n"
|
||||
" && ln -s /usr/share/docker.io/contrib/dockerd-rootless.sh "
|
||||
"/usr/local/bin/dockerd-rootless.sh\n"
|
||||
"COPY rootless-docker-init.sh "
|
||||
"/usr/local/libexec/bot-bottle/rootless-docker-init\n"
|
||||
"RUN chmod 0755 /usr/local/libexec/bot-bottle/rootless-docker-init\n"
|
||||
"USER node\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
build(image, str(context), dockerfile=str(context / "Dockerfile"))
|
||||
return image
|
||||
|
||||
|
||||
def guest_env(enabled: bool) -> dict[str, str]:
|
||||
"""Environment consumed by the Docker CLI inside an enabled bottle."""
|
||||
if not enabled:
|
||||
return {}
|
||||
return {
|
||||
"DOCKER_HOST": f"unix://{_SOCKET}",
|
||||
"XDG_RUNTIME_DIR": _RUNTIME_DIR,
|
||||
}
|
||||
|
||||
|
||||
def start(bottle: object) -> None:
|
||||
"""Start and verify the unprivileged daemon through the bottle exec API."""
|
||||
info("starting experimental rootless Docker daemon")
|
||||
result = bottle.exec(shlex.quote(_INIT)) # type: ignore[attr-defined]
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(f"rootless Docker bootstrap failed: {detail or '<no output>'}")
|
||||
|
||||
for _ in range(READY_RETRIES):
|
||||
result = bottle.exec("docker info >/dev/null 2>&1") # type: ignore[attr-defined]
|
||||
if result.returncode == 0:
|
||||
info("rootless Docker daemon is ready")
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
logs = bottle.exec( # type: ignore[attr-defined]
|
||||
"tail -n 80 /tmp/bot-bottle-rootless-docker.log 2>/dev/null || true"
|
||||
)
|
||||
die(
|
||||
"rootless Docker did not become ready without additional outer "
|
||||
f"privileges:\n{(logs.stdout or logs.stderr or '<no log>').strip()}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_image", "guest_env", "start"]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Experimental rootless podman bootstrap for Apple-container bottles.
|
||||
|
||||
The service and every nested container remain inside the existing per-bottle
|
||||
VM. This module refuses to compensate for missing prerequisites with outer
|
||||
capabilities, a privileged container, or a host Docker socket.
|
||||
|
||||
Podman is used rather than rootless Docker for one specific reason: Apple
|
||||
Container's capability bounding set omits `CAP_SYS_ADMIN`, which the kernel
|
||||
requires to write a multi-range `uid_map` via `newuidmap`. Rootless Docker
|
||||
has no path that avoids that write. Podman does — with no subordinate UID
|
||||
range configured it falls back to a single-UID self-mapping, which an
|
||||
unprivileged process may write itself. See
|
||||
`docs/research/rootless-docker-in-apple-container-spike.md`.
|
||||
|
||||
That fallback is why `build_image` *removes* the agent user's `/etc/subuid`
|
||||
and `/etc/subgid` entries instead of adding them: their presence is precisely
|
||||
what would send podman down the `newuidmap` path that cannot work here.
|
||||
|
||||
The agent still talks to `docker` and `docker compose`; those speak to
|
||||
podman's Docker-compatible API socket, so nothing in the agent's habits
|
||||
changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
_INIT = "/usr/local/libexec/bot-bottle/rootless-podman-init"
|
||||
_RUNTIME_DIR = "/tmp/bot-bottle-podman-run"
|
||||
_SOCKET = f"{_RUNTIME_DIR}/podman.sock"
|
||||
_LOG = "/tmp/bot-bottle-rootless-podman.log"
|
||||
READY_RETRIES = 30
|
||||
|
||||
# Apple Container creates both device nodes 0600 root:root, so the agent user
|
||||
# cannot open them: /dev/fuse blocks the fuse-overlayfs storage driver and
|
||||
# /dev/net/tun blocks slirp4netns, which rootless podman uses for the default
|
||||
# bridge network that stock compose files expect. Relaxing the modes needs no
|
||||
# capability the bottle does not already hold — unlike CAP_SYS_ADMIN, which is
|
||||
# what killed the rootless-Docker approach.
|
||||
_GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
|
||||
|
||||
|
||||
def build_image(
|
||||
base_image: str,
|
||||
build: Callable[..., None],
|
||||
) -> str:
|
||||
"""Layer spike-only tooling on an already-built provider image."""
|
||||
image = f"{base_image}-rootless-podman"
|
||||
init_script = Path(__file__).with_name("rootless-podman-init.sh")
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-rootless-podman.") as tmp:
|
||||
context = Path(tmp)
|
||||
shutil.copy2(init_script, context / "rootless-podman-init.sh")
|
||||
(context / "Dockerfile").write_text(
|
||||
"FROM docker:28-cli AS docker_cli\n"
|
||||
f"FROM {base_image}\n"
|
||||
"USER root\n"
|
||||
"COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker\n"
|
||||
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
|
||||
"docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose\n"
|
||||
"RUN apt-get update \\\n"
|
||||
" && apt-get install -y --no-install-recommends podman "
|
||||
"fuse-overlayfs slirp4netns uidmap \\\n"
|
||||
" && rm -rf /var/lib/apt/lists/* \\\n"
|
||||
# Deliberate: an empty subordinate range keeps podman on the
|
||||
# single-UID mapping that needs no CAP_SYS_ADMIN. Adding ranges
|
||||
# here would reintroduce the newuidmap failure this spike exists
|
||||
# to route around.
|
||||
" && sed -i '/^node:/d' /etc/subuid /etc/subgid\n"
|
||||
"COPY rootless-podman-init.sh "
|
||||
"/usr/local/libexec/bot-bottle/rootless-podman-init\n"
|
||||
"RUN chmod 0755 /usr/local/libexec/bot-bottle/rootless-podman-init\n"
|
||||
"USER node\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
build(image, str(context), dockerfile=str(context / "Dockerfile"))
|
||||
return image
|
||||
|
||||
|
||||
def guest_env(enabled: bool) -> dict[str, str]:
|
||||
"""Environment consumed by the Docker CLI inside an enabled bottle."""
|
||||
if not enabled:
|
||||
return {}
|
||||
return {
|
||||
"DOCKER_HOST": f"unix://{_SOCKET}",
|
||||
"XDG_RUNTIME_DIR": _RUNTIME_DIR,
|
||||
}
|
||||
|
||||
|
||||
def prepare_guest_devices(container_name: str, exec_as_root: Callable[..., None]) -> None:
|
||||
"""Make /dev/fuse and /dev/net/tun openable by the agent user.
|
||||
|
||||
Runs as root inside the bottle because the agent must not be able to
|
||||
re-mode device nodes itself. No outer capability is involved.
|
||||
"""
|
||||
exec_as_root(
|
||||
container_name,
|
||||
["sh", "-c", f"chmod 0666 {' '.join(_GUEST_DEVICES)}"],
|
||||
)
|
||||
|
||||
|
||||
def start(bottle: object) -> None:
|
||||
"""Start and verify the unprivileged service through the bottle exec API."""
|
||||
info("starting experimental rootless podman service")
|
||||
result = bottle.exec(shlex.quote(_INIT)) # type: ignore[attr-defined]
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(f"rootless podman bootstrap failed: {detail or '<no output>'}")
|
||||
|
||||
for _ in range(READY_RETRIES):
|
||||
result = bottle.exec("docker info >/dev/null 2>&1") # type: ignore[attr-defined]
|
||||
if result.returncode == 0:
|
||||
info("rootless podman service is ready")
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
logs = bottle.exec( # type: ignore[attr-defined]
|
||||
f"tail -n 80 {_LOG} 2>/dev/null || true"
|
||||
)
|
||||
die(
|
||||
"rootless podman did not become ready without additional outer "
|
||||
f"privileges:\n{(logs.stdout or logs.stderr or '<no log>').strip()}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_image", "guest_env", "prepare_guest_devices", "start"]
|
||||
Reference in New Issue
Block a user