diff --git a/compose/config/config.py b/compose/config/config.py index 0444ba3a6bc..9e9cb857fbf 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -526,12 +526,13 @@ def path_mappings_from_dict(d): return [join_path_mapping(v) for v in d.items()] -def split_path_mapping(string): - if ':' in string: - (host, container) = string.split(':', 1) - return (container, host) +def split_path_mapping(volume_path): + drive, volume_config = os.path.splitdrive(volume_path) + if ':' in volume_config: + (host, container) = volume_config.split(':', 1) + return (container, drive + host) else: - return (string, None) + return (volume_path, None) def join_path_mapping(pair): diff --git a/compose/const.py b/compose/const.py index b43e655b19d..1b6894189e2 100644 --- a/compose/const.py +++ b/compose/const.py @@ -2,11 +2,11 @@ import sys DEFAULT_TIMEOUT = 10 +HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) +IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' LABEL_PROJECT = 'com.docker.compose.project' LABEL_SERVICE = 'com.docker.compose.service' LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' -HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) -IS_WINDOWS_PLATFORM = (sys.platform == 'win32') diff --git a/compose/service.py b/compose/service.py index 960d3936bf6..c9ca00ae414 100644 --- a/compose/service.py +++ b/compose/service.py @@ -20,6 +20,7 @@ from .config import merge_environment from .config.validation import VALID_NAME_CHARS from .const import DEFAULT_TIMEOUT +from .const import IS_WINDOWS_PLATFORM from .const import LABEL_CONFIG_HASH from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_ONE_OFF @@ -936,20 +937,54 @@ def build_volume_binding(volume_spec): return volume_spec.internal, "{}:{}:{}".format(*volume_spec) +def normalize_paths_for_engine(external_path, internal_path): + """ + Windows paths, c:\my\path\shiny, need to be changed to be compatible with + the Engine. Volume paths are expected to be linux style /c/my/path/shiny/ + """ + if IS_WINDOWS_PLATFORM: + if external_path: + drive, tail = os.path.splitdrive(external_path) + + if drive: + reformatted_drive = "/{}".format(drive.replace(":", "")) + external_path = reformatted_drive + tail + + external_path = "/".join(external_path.split("\\")) + + return external_path, "/".join(internal_path.split("\\")) + else: + return external_path, internal_path + + def parse_volume_spec(volume_config): - parts = volume_config.split(':') + """ + Parse a volume_config path and split it into external:internal[:mode] + parts to be returned as a valid VolumeSpec. + """ + if IS_WINDOWS_PLATFORM: + # relative paths in windows expand to include the drive, eg C:\ + # so we join the first 2 parts back together to count as one + drive, tail = os.path.splitdrive(volume_config) + parts = tail.split(":") + + if drive: + parts[0] = drive + parts[0] + else: + parts = volume_config.split(':') + if len(parts) > 3: raise ConfigError("Volume %s has incorrect format, should be " "external:internal[:mode]" % volume_config) if len(parts) == 1: - external = None - internal = os.path.normpath(parts[0]) + external, internal = normalize_paths_for_engine(None, os.path.normpath(parts[0])) else: - external = os.path.normpath(parts[0]) - internal = os.path.normpath(parts[1]) + external, internal = normalize_paths_for_engine(os.path.normpath(parts[0]), os.path.normpath(parts[1])) - mode = parts[2] if len(parts) == 3 else 'rw' + mode = 'rw' + if len(parts) == 3: + mode = parts[2] return VolumeSpec(external, internal, mode) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3269cdff87c..b505740f571 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -420,7 +420,6 @@ def test_no_binding(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') self.assertEqual(d['volumes'], ['/data']) - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' @@ -433,7 +432,7 @@ def test_volume_binding_with_environment_variable(self): )[0] self.assertEqual(d['volumes'], ['/host/path:/container/path']) - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' @@ -464,6 +463,7 @@ def test_relative_path_does_expand_posix(self): self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths') + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='waiting for this to be resolved: https://github.com/docker/compose/issues/2128') def test_relative_path_does_expand_windows(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['./data:/data']}, working_dir='C:\\Users\\me\\myproject') self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject\\data:/data']) @@ -1124,6 +1124,21 @@ def test_expand_path_with_tilde(self): self.assertEqual(result, user_path + 'otherdir/somefile') +class VolumePathTest(unittest.TestCase): + + @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') + def test_split_path_mapping_with_windows_path(self): + windows_volume_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config:/opt/connect/config:ro" + expected_mapping = ( + "/opt/connect/config:ro", + "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config" + ) + + mapping = config.split_path_mapping(windows_volume_path) + + self.assertEqual(mapping, expected_mapping) + + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class BuildPathTest(unittest.TestCase): def setUp(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a1c195acf00..c682b823773 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -441,7 +441,6 @@ def mock_get_image(images): raise NoSuchImageError() -@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ServiceVolumesTest(unittest.TestCase): def setUp(self): @@ -466,6 +465,21 @@ def test_parse_volume_spec_too_many_parts(self): with self.assertRaises(ConfigError): parse_volume_spec('one:two:three:four') + @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') + def test_parse_volume_windows_absolute_path(self): + windows_absolute_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" + + spec = parse_volume_spec(windows_absolute_path) + + self.assertEqual( + spec, + ( + "/c/Users/me/Documents/shiny/config", + "/opt/shiny/config", + "ro" + ) + ) + def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) self.assertEqual(binding, ('/inside', '/outside:/inside:rw'))