Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: fix path mounting when running in Docker #1888

Merged
merged 1 commit into from Apr 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 32 additions & 1 deletion pre_commit/languages/docker.py
@@ -1,5 +1,7 @@
import hashlib
import json
import os
import socket
from typing import Sequence
from typing import Tuple

Expand All @@ -8,13 +10,42 @@
from pre_commit.languages import helpers
from pre_commit.prefix import Prefix
from pre_commit.util import clean_path_on_failure
from pre_commit.util import cmd_output_b

ENVIRONMENT_DIR = 'docker'
PRE_COMMIT_LABEL = 'PRE_COMMIT'
get_default_version = helpers.basic_get_default_version
healthy = helpers.basic_healthy


def _is_in_docker() -> bool:
try:
with open('/proc/1/cgroup', 'rb') as f:
return b'docker' in f.read()
except FileNotFoundError:
return False


def _get_docker_path(path: str) -> str:
if not _is_in_docker():
return path
hostname = socket.gethostname()

_, out, _ = cmd_output_b('docker', 'inspect', hostname)

container, = json.loads(out)
for mount in container['Mounts']:
src_path = mount['Source']
to_path = mount['Destination']
if os.path.commonpath((path, to_path)) == to_path:
# So there is something in common,
# and we can proceed remapping it
return path.replace(to_path, src_path)
# we're in Docker, but the path is not mounted, cannot really do anything,
# so fall back to original path
return path


def md5(s: str) -> str: # pragma: win32 no cover
return hashlib.md5(s.encode()).hexdigest()

Expand Down Expand Up @@ -73,7 +104,7 @@ def docker_cmd() -> Tuple[str, ...]: # pragma: win32 no cover
# https://docs.docker.com/engine/reference/commandline/run/#mount-volumes-from-container-volumes-from
# The `Z` option tells Docker to label the content with a private
# unshared label. Only the current container can use a private volume.
'-v', f'{os.getcwd()}:/src:rw,Z',
'-v', f'{_get_docker_path(os.getcwd())}:/src:rw,Z',
'--workdir', '/src',
)

Expand Down
147 changes: 144 additions & 3 deletions tests/languages/docker_test.py
@@ -1,14 +1,155 @@
import builtins
import json
import ntpath
import os.path
import posixpath
from unittest import mock

import pytest

from pre_commit.languages import docker


def test_docker_fallback_user():
def invalid_attribute():
raise AttributeError

with mock.patch.multiple(
'os', create=True,
getuid=invalid_attribute,
getgid=invalid_attribute,
'os', create=True,
getuid=invalid_attribute,
getgid=invalid_attribute,
):
assert docker.get_docker_user() == ()


def test_in_docker_no_file():
with mock.patch.object(builtins, 'open', side_effect=FileNotFoundError):
assert docker._is_in_docker() is False


def _mock_open(data):
return mock.patch.object(
builtins,
'open',
new_callable=mock.mock_open,
read_data=data,
)


def test_in_docker_docker_in_file():
docker_cgroup_example = b'''\
12:hugetlb:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
11:blkio:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
10:freezer:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
9:cpu,cpuacct:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
8:pids:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
7:rdma:/
6:net_cls,net_prio:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
5:cpuset:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
4:devices:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
3:memory:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
2:perf_event:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
1:name=systemd:/docker/c33988ec7651ebc867cb24755eaf637a6734088bc7eef59d5799293a9e5450f7
0::/system.slice/containerd.service
''' # noqa: E501
with _mock_open(docker_cgroup_example):
assert docker._is_in_docker() is True


def test_in_docker_docker_not_in_file():
non_docker_cgroup_example = b'''\
12:perf_event:/
11:hugetlb:/
10:devices:/
9:blkio:/
8:rdma:/
7:cpuset:/
6:cpu,cpuacct:/
5:freezer:/
4:memory:/
3:pids:/
2:net_cls,net_prio:/
1:name=systemd:/init.scope
0::/init.scope
'''
with _mock_open(non_docker_cgroup_example):
assert docker._is_in_docker() is False


def test_get_docker_path_not_in_docker_returns_same():
with mock.patch.object(docker, '_is_in_docker', return_value=False):
assert docker._get_docker_path('abc') == 'abc'


@pytest.fixture
def in_docker():
with mock.patch.object(docker, '_is_in_docker', return_value=True):
yield


def _linux_commonpath():
return mock.patch.object(os.path, 'commonpath', posixpath.commonpath)


def _nt_commonpath():
return mock.patch.object(os.path, 'commonpath', ntpath.commonpath)


def _docker_output(out):
ret = (0, out, b'')
return mock.patch.object(docker, 'cmd_output_b', return_value=ret)


def test_get_docker_path_in_docker_no_binds_same_path(in_docker):
docker_out = json.dumps([{'Mounts': []}]).encode()

with _docker_output(docker_out):
assert docker._get_docker_path('abc') == 'abc'


def test_get_docker_path_in_docker_binds_path_equal(in_docker):
binds_list = [{'Source': '/opt/my_code', 'Destination': '/project'}]
docker_out = json.dumps([{'Mounts': binds_list}]).encode()

with _linux_commonpath(), _docker_output(docker_out):
assert docker._get_docker_path('/project') == '/opt/my_code'


def test_get_docker_path_in_docker_binds_path_complex(in_docker):
binds_list = [{'Source': '/opt/my_code', 'Destination': '/project'}]
docker_out = json.dumps([{'Mounts': binds_list}]).encode()

with _linux_commonpath(), _docker_output(docker_out):
path = '/project/test/something'
assert docker._get_docker_path(path) == '/opt/my_code/test/something'


def test_get_docker_path_in_docker_no_substring(in_docker):
binds_list = [{'Source': '/opt/my_code', 'Destination': '/project'}]
docker_out = json.dumps([{'Mounts': binds_list}]).encode()

with _linux_commonpath(), _docker_output(docker_out):
path = '/projectSuffix/test/something'
assert docker._get_docker_path(path) == path


def test_get_docker_path_in_docker_binds_path_many_binds(in_docker):
binds_list = [
{'Source': '/something_random', 'Destination': '/not-related'},
{'Source': '/opt/my_code', 'Destination': '/project'},
{'Source': '/something-random-2', 'Destination': '/not-related-2'},
]
docker_out = json.dumps([{'Mounts': binds_list}]).encode()

with _linux_commonpath(), _docker_output(docker_out):
assert docker._get_docker_path('/project') == '/opt/my_code'


def test_get_docker_path_in_docker_windows(in_docker):
binds_list = [{'Source': r'c:\users\user', 'Destination': r'c:\folder'}]
docker_out = json.dumps([{'Mounts': binds_list}]).encode()

with _nt_commonpath(), _docker_output(docker_out):
path = r'c:\folder\test\something'
expected = r'c:\users\user\test\something'
assert docker._get_docker_path(path) == expected