From a637e1755c90cadfe93a83a93b76c807d18b2f17 Mon Sep 17 00:00:00 2001 From: Oleg Kainov Date: Wed, 21 Apr 2021 21:00:48 +0300 Subject: [PATCH] fix: fix path mounting when running in Docker Currently pre-commit mounts the current directory to /src and uses current directory name as mount base. However this does not work when pre-commit is run inside the container on some mounted path already, because mount points are relative to the host, not to the container. Fixes #1387 --- pre_commit/languages/docker.py | 31 +++++++++++++- tests/languages/docker_test.py | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py index 9d30568c5..5d1abb00f 100644 --- a/pre_commit/languages/docker.py +++ b/pre_commit/languages/docker.py @@ -1,5 +1,8 @@ import hashlib +import json import os +import socket +import subprocess from typing import Sequence from typing import Tuple @@ -15,6 +18,32 @@ healthy = helpers.basic_healthy +def is_in_docker() -> bool: + try: + with open('/proc/1/cgroup') as f: + return '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() + docker_output = json.loads( + subprocess.check_output(['docker', 'inspect', hostname]), + ) + for mount_path in docker_output[0]['HostConfig']['Binds']: + src_path, to_path = mount_path.split(':') + if to_path == path: + return src_path + elif path.startswith(to_path): + 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() @@ -73,7 +102,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', ) diff --git a/tests/languages/docker_test.py b/tests/languages/docker_test.py index 3bed4bfa5..4eab8676d 100644 --- a/tests/languages/docker_test.py +++ b/tests/languages/docker_test.py @@ -1,4 +1,8 @@ +import json +import subprocess +from typing import List from unittest import mock +from unittest.mock import mock_open from pre_commit.languages import docker @@ -12,3 +16,75 @@ def invalid_attribute(): getgid=invalid_attribute, ): assert docker.get_docker_user() == () + + +class TestInDocker: + @mock.patch('builtins.open', new_callable=mock_open) + def test_in_docker_no_file(self, mock_file): + mock_file.side_effect = FileNotFoundError + + assert docker.is_in_docker() is False + mock_file.assert_called() + + @mock.patch('builtins.open', new_callable=mock_open, read_data='tdockert') + def test_in_docker_docker_in_file(self, _): + assert docker.is_in_docker() is True + + @mock.patch('builtins.open', new_callable=mock_open, read_data='testtest') + def test_in_docker_docker_not_in_file(self, _): + assert docker.is_in_docker() is False + + +class TestDockerPath: + @mock.patch.object(docker, 'is_in_docker', return_value=False) + def test_not_in_docker_returns_same(self, _): + assert docker.get_docker_path('abc') == 'abc' + + @mock.patch.object(docker, 'is_in_docker', return_value=True) + def test_in_docker_no_binds_same_path(self, _): + binds_list: List[str] = [] + output_string = json.dumps([{'HostConfig': {'Binds': binds_list}}]) + with mock.patch.object( + subprocess, 'check_output', + return_value=output_string, + ): + assert docker.get_docker_path('abc') == 'abc' + + @mock.patch.object(docker, 'is_in_docker', return_value=True) + def test_in_docker_binds_path_equal(self, _): + binds_list = [ + '/opt/my_code:/project', + ] + output_string = json.dumps([{'HostConfig': {'Binds': binds_list}}]) + with mock.patch.object( + subprocess, 'check_output', + return_value=output_string, + ): + assert docker.get_docker_path('/project') == '/opt/my_code' + + @mock.patch.object(docker, 'is_in_docker', return_value=True) + def test_in_docker_binds_path_complex(self, _): + binds_list = [ + '/opt/my_code:/project', + ] + output_string = json.dumps([{'HostConfig': {'Binds': binds_list}}]) + with mock.patch.object( + subprocess, 'check_output', + return_value=output_string, + ): + assert docker.get_docker_path('/project/test/something') == \ + '/opt/my_code/test/something' + + @mock.patch.object(docker, 'is_in_docker', return_value=True) + def test_in_docker_binds_path_many_binds(self, _): + binds_list = [ + '/something_random:/not_related', + '/opt/my_code:/project', + '/something_random2:/not_related2', + ] + output_string = json.dumps([{'HostConfig': {'Binds': binds_list}}]) + with mock.patch.object( + subprocess, 'check_output', + return_value=output_string, + ): + assert docker.get_docker_path('/project') == '/opt/my_code' \ No newline at end of file