Repository: pre-commit/pre-commit

Issue:
Path is not mounted correctly when running Docker hooks from Docker
**Situation**:

- In our CI we want to run `pre-commit` inside Docker.
- Some of our hooks are `docker_image`

**Problem**
This line mostly https://github.com/pre-commit/pre-commit/blob/528c7afd18dafa6e47ce73add2c8e1550d105674/pre_commit/languages/docker.py#L94

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.

 Example: 
```
/opt/my_code      <- host, mounts /opt/my_code:/project
/project              <- in Docker running pre-commit, pre-commit is doing mount /project:/src
/src                   <-  (in Dockerized hook)
```

Currently pre-commit will try to mount it as `-v /project:/src,rw,Z`. Expected - to mount it as `-v /opt/my_code:/src`

**Possible solution**:

When I replaced `os.getcwd()` from the code above to `translate_path(os.getcwd())` where `translate_path` is taken from https://gist.github.com/dpfoose/f96d4e4b76c2e01265619d545b77987a, it worked perfectly. It does add extra `docker` pip-dependency though.

**See also**: https://forums.docker.com/t/mounting-a-volume-not-working-with-running-docker-in-docker/25775/2

Discussion:
okainov: ```
Name                             Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------------------
pre_commit/languages/docker.py      65      0     10      1    99%   40->36
----------------------------------------------------------------------------
TOTAL                             7089      0    714      1    99%

100 files skipped due to complete coverage.
Coverage failure: total of 100 is less than fail-under=100

```
I don't really understand why CI is not happy. All lines are test-covered. what is `40->36`?
asottile: > 
> 
> ```
> Name                             Stmts   Miss Branch BrPart  Cover   Missing
> ----------------------------------------------------------------------------
> pre_commit/languages/docker.py      65      0     10      1    99%   40->36
> ----------------------------------------------------------------------------
> TOTAL                             7089      0    714      1    99%
> 
> 100 files skipped due to complete coverage.
> Coverage failure: total of 100 is less than fail-under=100
> ```
> 
> I don't really understand why CI is not happy. All lines are test-covered. what is `40->36`?

that's branch coverage -- it means that once it enters the loop you've only tested the two positive cases inside and not the (implicit) `else: pass`
okainov: @asottile fixed, please review and merge if it's fine

Patch:
diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py
index 9d30568..5b21ec9 100644
--- a/pre_commit/languages/docker.py
+++ b/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
 
@@ -8,6 +10,7 @@ from pre_commit.hook import Hook
 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'
@@ -15,6 +18,34 @@ 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()
 
@@ -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',
     )
 


Test patch:
diff --git a/tests/languages/docker_test.py b/tests/languages/docker_test.py
index 3bed4bf..01b5e27 100644
--- a/tests/languages/docker_test.py
+++ b/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