fix(install): use --userns=keep-id under Podman so bind-mount writes don't fail (#2846)
## Description
`build_runtime_command` unconditionally adds `--user <uid>:<gid>` on
non-Windows hosts:
```python
# headroom/install/runtime.py
if not _is_windows():
getuid = getattr(os, "getuid", None)
getgid = getattr(os, "getgid", None)
if callable(getuid) and callable(getgid):
command.extend(["--user", f"{getuid()}:{getgid()}"])
```
That is correct for Docker, where container UIDs equal host UIDs, but
wrong for rootless Podman, where the host user is already mapped to
container UID 0 and the `/etc/subuid` range is mapped to container UIDs
1 and above. Passing `--user $(id -u):$(id -g)` therefore selects a
container UID backed by a subordinate host UID that owns nothing. The
bind-mounted `~/.headroom` appears inside the container as `root:root`
and is unwritable, so every write fails:
```text
PermissionError: [Errno 13] Permission denied: '/tmp/headroom-home/.headroom/memories'
event=proxy_inbound_request_aborted path=/v1/messages reason=PermissionError
```
The proxy still starts and reports healthy, so the failure only surfaces
once a request touches a write path. As the reporter confirmed,
`--userns=keep-id` (or omitting `--user`) fixes it.
The fix detects Podman and uses `--userns=keep-id` instead of `--user`,
which maps the host user to the same UID inside the container and keeps
the bind mounts writable. Docker still gets `--user`, unchanged.
Detection is subprocess-free: it resolves the `docker` binary and checks
its real name for the common `docker -> podman` symlink shim (e.g. NixOS
`/run/current-system/sw/bin/docker -> podman`), with an explicit
`HEADROOM_CONTAINER_RUNTIME` (`podman` / `docker`) override for setups
the symlink heuristic cannot see, such as a wrapper script.
Fixes #2804
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/install/runtime.py`: added `_container_runtime_is_podman()`
(env override, then a `docker`-binary realpath basename check, no
subprocess). In `build_runtime_command`, when Podman is detected the
command uses `--userns=keep-id` instead of `--user <uid>:<gid>`.
- `tests/test_install/test_runtime.py`: pinned the existing docker test
to the Docker path via `HEADROOM_CONTAINER_RUNTIME=docker` and asserted
`--userns=keep-id` is absent there; added
`test_build_runtime_command_podman_uses_keep_id_not_user` asserting the
Podman path drops `--user` and adds `--userns=keep-id`.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
# Fail-before (source fix stashed, new test kept):
tests/test_install/test_runtime.py::test_build_runtime_command_podman_uses_keep_id_not_user FAILED
assert "--userns=keep-id" in command
AssertionError: assert '--userns=keep-id' in ['docker', 'run', '--rm', ...]
# Pass-after (fix applied):
tests/test_install/test_runtime.py 26 passed
# Broader install suite (excluding the pre-existing env-specific PowerShell installer test):
tests/test_install/ 142 passed, 1 skipped
# uvx ruff@0.15.17 check -> All checks passed!
# uvx mypy@1.20.2 headroom/install/runtime.py -> Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: confirmed `build_runtime_command` adds `--user`
unconditionally on non-Windows, then drove both runtimes
deterministically via the `HEADROOM_CONTAINER_RUNTIME` override.
Fail-before with `git stash push headroom/install/runtime.py` and
`python -m pytest tests/test_install/test_runtime.py -k
podman_uses_keep_id` (the command still carries `--user`, no keep-id),
pass-after with `git stash pop` and rerunning the file (26 passed).
- Observed result: with Podman detected the docker command now contains
`--userns=keep-id` and no `--user`/`1000:1001`, matching the
`--userns=keep-id` invocation the reporter verified writes successfully;
with Docker it is unchanged (`--user 1000:1001`, no keep-id).
- Not tested: a live rootless-Podman deployment writing to a bind mount
(no Podman in this environment). The command construction is verified
directly, and `--userns=keep-id` is the documented, reporter-confirmed
switch for the rootless-Podman ID-mapping.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Additional Notes
Detection is intentionally subprocess-free and conservative: it only
diverges from today's behavior when the `docker` binary literally
resolves to a `podman`-named target, or when
`HEADROOM_CONTAINER_RUNTIME` is set. Real Docker installs are untouched.
The override also gives a clean escape hatch in both directions if a
given host's symlink layout hides the runtime. This is the `--user` half
of the persistent-docker + Podman issues; the separate host-memory-path
problem (#2803) is addressed in its own PR.
This commit is contained in:
@@ -56,6 +56,32 @@ def _is_windows() -> bool:
|
||||
return sys.platform.startswith("win")
|
||||
|
||||
|
||||
def _container_runtime_is_podman() -> bool:
|
||||
"""Best-effort: is the ``docker`` command actually Podman?
|
||||
|
||||
Rootless Podman maps the host user to container UID 0, so the
|
||||
``--user <host-uid>:<host-gid>`` flag that is correct for Docker instead
|
||||
selects a subordinate UID that owns none of the bind-mounted host
|
||||
directories, and every write into ``~/.headroom`` fails (#2804). Detect the
|
||||
common ``docker -> podman`` shim (e.g. NixOS
|
||||
``/run/current-system/sw/bin/docker -> podman``) by resolving the binary and
|
||||
checking its real name. ``HEADROOM_CONTAINER_RUNTIME`` (``podman`` / ``docker``)
|
||||
is an explicit override for setups the symlink heuristic cannot see, such as a
|
||||
wrapper script. No subprocess is spawned.
|
||||
"""
|
||||
override = os.environ.get("HEADROOM_CONTAINER_RUNTIME", "").strip().lower()
|
||||
if override:
|
||||
return override == "podman"
|
||||
resolved = shutil.which("docker")
|
||||
if not resolved:
|
||||
return False
|
||||
try:
|
||||
real = os.path.realpath(resolved)
|
||||
except OSError:
|
||||
real = resolved
|
||||
return "podman" in os.path.basename(real).lower()
|
||||
|
||||
|
||||
def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
|
||||
return {
|
||||
"HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
|
||||
@@ -136,10 +162,18 @@ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
|
||||
if docker_gpus:
|
||||
command.extend(["--gpus", docker_gpus])
|
||||
if not _is_windows():
|
||||
getuid = getattr(os, "getuid", None)
|
||||
getgid = getattr(os, "getgid", None)
|
||||
if callable(getuid) and callable(getgid):
|
||||
command.extend(["--user", f"{getuid()}:{getgid()}"])
|
||||
if _container_runtime_is_podman():
|
||||
# Rootless Podman maps the host user to container UID 0, so --user
|
||||
# would map to a subordinate UID that owns none of the bind mounts and
|
||||
# every write into ~/.headroom fails (#2804). keep-id maps the host
|
||||
# user to the same UID inside the container, keeping the mounts
|
||||
# writable. Docker maps UIDs 1:1, so --user stays correct there.
|
||||
command.append("--userns=keep-id")
|
||||
else:
|
||||
getuid = getattr(os, "getuid", None)
|
||||
getgid = getattr(os, "getgid", None)
|
||||
if callable(getuid) and callable(getgid):
|
||||
command.extend(["--user", f"{getuid()}:{getgid()}"])
|
||||
runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
|
||||
for name, value in runtime_env.items():
|
||||
command.extend(["--env", f"{name}={value}"])
|
||||
|
||||
@@ -281,6 +281,9 @@ def test_build_runtime_command_python_and_docker_user(monkeypatch, tmp_path: Pat
|
||||
monkeypatch.setattr("headroom.install.runtime.sys.platform", "linux")
|
||||
monkeypatch.setattr("headroom.install.runtime.os.getuid", lambda: 1000, raising=False)
|
||||
monkeypatch.setattr("headroom.install.runtime.os.getgid", lambda: 1001, raising=False)
|
||||
# Force the Docker path deterministically regardless of the test host's
|
||||
# `docker` binary (it might resolve to a podman shim).
|
||||
monkeypatch.setenv("HEADROOM_CONTAINER_RUNTIME", "docker")
|
||||
docker_manifest = DeploymentManifest(
|
||||
profile="default",
|
||||
preset="persistent-docker",
|
||||
@@ -299,6 +302,37 @@ def test_build_runtime_command_python_and_docker_user(monkeypatch, tmp_path: Pat
|
||||
command = build_runtime_command(docker_manifest)
|
||||
assert "--user" in command
|
||||
assert "1000:1001" in command
|
||||
assert "--userns=keep-id" not in command
|
||||
|
||||
|
||||
def test_build_runtime_command_podman_uses_keep_id_not_user(monkeypatch, tmp_path: Path) -> None:
|
||||
"""Under rootless Podman, --user <host-uid>:<host-gid> selects a subordinate
|
||||
UID that owns none of the bind mounts, so writes into ~/.headroom fail. The
|
||||
command must use --userns=keep-id and drop --user instead (#2804)."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr("headroom.install.runtime.sys.platform", "linux")
|
||||
monkeypatch.setattr("headroom.install.runtime.os.getuid", lambda: 1000, raising=False)
|
||||
monkeypatch.setattr("headroom.install.runtime.os.getgid", lambda: 1001, raising=False)
|
||||
monkeypatch.setenv("HEADROOM_CONTAINER_RUNTIME", "podman")
|
||||
manifest = DeploymentManifest(
|
||||
profile="default",
|
||||
preset="persistent-docker",
|
||||
runtime_kind="docker",
|
||||
supervisor_kind="none",
|
||||
scope="user",
|
||||
provider_mode="manual",
|
||||
targets=[],
|
||||
port=8787,
|
||||
host="127.0.0.1",
|
||||
backend="anthropic",
|
||||
image="ghcr.io/headroomlabs-ai/headroom:latest",
|
||||
base_env={"HEADROOM_PORT": "8787"},
|
||||
proxy_args=["--host", "127.0.0.1", "--port", "8787"],
|
||||
)
|
||||
command = build_runtime_command(manifest)
|
||||
assert "--userns=keep-id" in command
|
||||
assert "--user" not in command
|
||||
assert "1000:1001" not in command
|
||||
|
||||
|
||||
def test_read_pid_handles_invalid_content(monkeypatch, tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user