Fix permission errors for mounts in rootless docker

By running containers in a rootless docker context as root. This is
because user and group IDs are remapped in the user namespaces uses by
rootless docker, and it's unlikely that the current user ID will map to
the same ID under this remap (see docs[1] for some more details).
Specifically, it means ownership of mounted volumes will not be for the
current user and trying to write can result in permission errors.

This change borrows heavily from an existing PR[2].

The output format of `docker system info` I don't think is
documented/guaranteed anywhere, but it should corresponding to the
format of a `/info` API request to Docker[3]

The added test _hopes_ to avoid regressions in this behaviour, but since
tests aren't run in a rootless docker context on the PR checks (and I
couldn't find an easy way to make it the case) there's still a risk of
regressions sneaking in.

Link: https://docs.docker.com/engine/security/rootless/ [1]
Link: https://github.com/pre-commit/pre-commit/pull/1484/ [2]
Link: https://docs.docker.com/reference/api/engine/version/v1.48/#tag/System/operation/SystemAuth [3]
Co-authored-by: Kurt von Laven <Kurt-von-Laven@users.noreply.github.com>
Co-authored-by: Fabrice Flore-Thébault <ffloreth@redhat.com>
This commit is contained in:
Matthew Hughes 2025-04-13 11:18:18 +01:00 committed by anthony sottile
parent d2b61d0ef2
commit 466f6c4a39
2 changed files with 73 additions and 0 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import functools
import hashlib import hashlib
import json import json
import os import os
@ -101,7 +102,32 @@ def install_environment(
os.mkdir(directory) os.mkdir(directory)
@functools.lru_cache(maxsize=1)
def _is_rootless() -> bool: # pragma: win32 no cover
retcode, out, _ = cmd_output_b(
'docker', 'system', 'info', '--format', '{{ json . }}',
)
if retcode != 0:
return False
info = json.loads(out)
try:
return (
# docker:
# https://docs.docker.com/reference/api/engine/version/v1.48/#tag/System/operation/SystemInfo
'name=rootless' in info.get('SecurityOptions', ()) or
# podman:
# https://docs.podman.io/en/latest/_static/api.html?version=v5.4#tag/system/operation/SystemInfoLibpod
info['host']['security']['rootless']
)
except KeyError:
return False
def get_docker_user() -> tuple[str, ...]: # pragma: win32 no cover def get_docker_user() -> tuple[str, ...]: # pragma: win32 no cover
if _is_rootless():
return ()
try: try:
return ('-u', f'{os.getuid()}:{os.getgid()}') return ('-u', f'{os.getuid()}:{os.getgid()}')
except AttributeError: except AttributeError:

View file

@ -62,6 +62,42 @@ def test_docker_fallback_user():
assert docker.get_docker_user() == () assert docker.get_docker_user() == ()
@pytest.fixture(autouse=True)
def _avoid_cache():
with mock.patch.object(
docker,
'_is_rootless',
docker._is_rootless.__wrapped__,
):
yield
@pytest.mark.parametrize(
'info_ret',
(
(0, b'{"SecurityOptions": ["name=rootless","name=cgroupns"]}', b''),
(0, b'{"host": {"security": {"rootless": true}}}', b''),
),
)
def test_docker_user_rootless(info_ret):
with mock.patch.object(docker, 'cmd_output_b', return_value=info_ret):
assert docker.get_docker_user() == ()
@pytest.mark.parametrize(
'info_ret',
(
(0, b'{"SecurityOptions": ["name=cgroupns"]}', b''),
(0, b'{"host": {"security": {"rootless": false}}}', b''),
(0, b'{"respone_from_some_other_container_engine": true}', b''),
(1, b'', b''),
),
)
def test_docker_user_non_rootless(info_ret):
with mock.patch.object(docker, 'cmd_output_b', return_value=info_ret):
assert docker.get_docker_user() != ()
def test_in_docker_no_file(): def test_in_docker_no_file():
with mock.patch.object(builtins, 'open', side_effect=FileNotFoundError): with mock.patch.object(builtins, 'open', side_effect=FileNotFoundError):
assert docker._is_in_docker() is False assert docker._is_in_docker() is False
@ -195,3 +231,14 @@ CMD ["echo", "This is overwritten by the entry"']
ret = run_language(tmp_path, docker, 'echo hello hello world') ret = run_language(tmp_path, docker, 'echo hello hello world')
assert ret == (0, b'hello hello world\n') assert ret == (0, b'hello hello world\n')
@xfailif_windows # pragma: win32 no cover
def test_docker_hook_mount_permissions(tmp_path):
dockerfile = '''\
FROM ubuntu:22.04
'''
tmp_path.joinpath('Dockerfile').write_text(dockerfile)
retcode, _ = run_language(tmp_path, docker, 'touch', ('README.md',))
assert retcode == 0