浏览代码

Merge pull request #5684 from docker/compat_mode

Compatibility mode
Joffrey F 7 年之前
父节点
当前提交
ec0de7eb68

+ 6 - 3
compose/cli/command.py

@@ -38,6 +38,7 @@ def project_from_options(project_dir, options):
         tls_config=tls_config_from_options(options, environment),
         tls_config=tls_config_from_options(options, environment),
         environment=environment,
         environment=environment,
         override_dir=options.get('--project-directory'),
         override_dir=options.get('--project-directory'),
+        compatibility=options.get('--compatibility'),
     )
     )
 
 
 
 
@@ -63,7 +64,8 @@ def get_config_from_options(base_dir, options):
         base_dir, options, environment
         base_dir, options, environment
     )
     )
     return config.load(
     return config.load(
-        config.find(base_dir, config_path, environment)
+        config.find(base_dir, config_path, environment),
+        options.get('--compatibility')
     )
     )
 
 
 
 
@@ -100,14 +102,15 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N
 
 
 
 
 def get_project(project_dir, config_path=None, project_name=None, verbose=False,
 def get_project(project_dir, config_path=None, project_name=None, verbose=False,
-                host=None, tls_config=None, environment=None, override_dir=None):
+                host=None, tls_config=None, environment=None, override_dir=None,
+                compatibility=False):
     if not environment:
     if not environment:
         environment = Environment.from_env_file(project_dir)
         environment = Environment.from_env_file(project_dir)
     config_details = config.find(project_dir, config_path, environment, override_dir)
     config_details = config.find(project_dir, config_path, environment, override_dir)
     project_name = get_project_name(
     project_name = get_project_name(
         config_details.working_dir, project_name, environment
         config_details.working_dir, project_name, environment
     )
     )
-    config_data = config.load(config_details)
+    config_data = config.load(config_details, compatibility)
 
 
     api_version = environment.get(
     api_version = environment.get(
         'COMPOSE_API_VERSION',
         'COMPOSE_API_VERSION',

+ 8 - 5
compose/cli/main.py

@@ -186,8 +186,10 @@ class TopLevelCommand(object):
       docker-compose -h|--help
       docker-compose -h|--help
 
 
     Options:
     Options:
-      -f, --file FILE             Specify an alternate compose file (default: docker-compose.yml)
-      -p, --project-name NAME     Specify an alternate project name (default: directory name)
+      -f, --file FILE             Specify an alternate compose file
+                                  (default: docker-compose.yml)
+      -p, --project-name NAME     Specify an alternate project name
+                                  (default: directory name)
       --verbose                   Show more output
       --verbose                   Show more output
       --log-level LEVEL           Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
       --log-level LEVEL           Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
       --no-ansi                   Do not print ANSI control characters
       --no-ansi                   Do not print ANSI control characters
@@ -199,11 +201,12 @@ class TopLevelCommand(object):
       --tlscert CLIENT_CERT_PATH  Path to TLS certificate file
       --tlscert CLIENT_CERT_PATH  Path to TLS certificate file
       --tlskey TLS_KEY_PATH       Path to TLS key file
       --tlskey TLS_KEY_PATH       Path to TLS key file
       --tlsverify                 Use TLS and verify the remote
       --tlsverify                 Use TLS and verify the remote
-      --skip-hostname-check       Don't check the daemon's hostname against the name specified
-                                  in the client certificate (for example if your docker host
-                                  is an IP address)
+      --skip-hostname-check       Don't check the daemon's hostname against the
+                                  name specified in the client certificate
       --project-directory PATH    Specify an alternate working directory
       --project-directory PATH    Specify an alternate working directory
                                   (default: the path of the Compose file)
                                   (default: the path of the Compose file)
+      --compatibility             If set, Compose will attempt to convert deploy
+                                  keys in v3 files to their non-Swarm equivalent
 
 
     Commands:
     Commands:
       build              Build or rebuild services
       build              Build or rebuild services

+ 85 - 10
compose/config/config.py

@@ -16,6 +16,7 @@ from . import types
 from .. import const
 from .. import const
 from ..const import COMPOSEFILE_V1 as V1
 from ..const import COMPOSEFILE_V1 as V1
 from ..const import COMPOSEFILE_V2_1 as V2_1
 from ..const import COMPOSEFILE_V2_1 as V2_1
+from ..const import COMPOSEFILE_V2_3 as V2_3
 from ..const import COMPOSEFILE_V3_0 as V3_0
 from ..const import COMPOSEFILE_V3_0 as V3_0
 from ..const import COMPOSEFILE_V3_4 as V3_4
 from ..const import COMPOSEFILE_V3_4 as V3_4
 from ..utils import build_string_dict
 from ..utils import build_string_dict
@@ -341,7 +342,7 @@ def find_candidates_in_parent_dirs(filenames, path):
     return (candidates, path)
     return (candidates, path)
 
 
 
 
-def check_swarm_only_config(service_dicts):
+def check_swarm_only_config(service_dicts, compatibility=False):
     warning_template = (
     warning_template = (
         "Some services ({services}) use the '{key}' key, which will be ignored. "
         "Some services ({services}) use the '{key}' key, which will be ignored. "
         "Compose does not support '{key}' configuration - use "
         "Compose does not support '{key}' configuration - use "
@@ -357,13 +358,13 @@ def check_swarm_only_config(service_dicts):
                     key=key
                     key=key
                 )
                 )
             )
             )
-
-    check_swarm_only_key(service_dicts, 'deploy')
+    if not compatibility:
+        check_swarm_only_key(service_dicts, 'deploy')
     check_swarm_only_key(service_dicts, 'credential_spec')
     check_swarm_only_key(service_dicts, 'credential_spec')
     check_swarm_only_key(service_dicts, 'configs')
     check_swarm_only_key(service_dicts, 'configs')
 
 
 
 
-def load(config_details):
+def load(config_details, compatibility=False):
     """Load the configuration from a working directory and a list of
     """Load the configuration from a working directory and a list of
     configuration files.  Files are loaded in order, and merged on top
     configuration files.  Files are loaded in order, and merged on top
     of each other to create the final configuration.
     of each other to create the final configuration.
@@ -391,15 +392,17 @@ def load(config_details):
     configs = load_mapping(
     configs = load_mapping(
         config_details.config_files, 'get_configs', 'Config', config_details.working_dir
         config_details.config_files, 'get_configs', 'Config', config_details.working_dir
     )
     )
-    service_dicts = load_services(config_details, main_file)
+    service_dicts = load_services(config_details, main_file, compatibility)
 
 
     if main_file.version != V1:
     if main_file.version != V1:
         for service_dict in service_dicts:
         for service_dict in service_dicts:
             match_named_volumes(service_dict, volumes)
             match_named_volumes(service_dict, volumes)
 
 
-    check_swarm_only_config(service_dicts)
+    check_swarm_only_config(service_dicts, compatibility)
+
+    version = V2_3 if compatibility and main_file.version >= V3_0 else main_file.version
 
 
-    return Config(main_file.version, service_dicts, volumes, networks, secrets, configs)
+    return Config(version, service_dicts, volumes, networks, secrets, configs)
 
 
 
 
 def load_mapping(config_files, get_func, entity_type, working_dir=None):
 def load_mapping(config_files, get_func, entity_type, working_dir=None):
@@ -441,7 +444,7 @@ def validate_external(entity_type, name, config, version):
                 entity_type, name, ', '.join(k for k in config if k != 'external')))
                 entity_type, name, ', '.join(k for k in config if k != 'external')))
 
 
 
 
-def load_services(config_details, config_file):
+def load_services(config_details, config_file, compatibility=False):
     def build_service(service_name, service_dict, service_names):
     def build_service(service_name, service_dict, service_names):
         service_config = ServiceConfig.with_abs_paths(
         service_config = ServiceConfig.with_abs_paths(
             config_details.working_dir,
             config_details.working_dir,
@@ -459,7 +462,9 @@ def load_services(config_details, config_file):
             service_config,
             service_config,
             service_names,
             service_names,
             config_file.version,
             config_file.version,
-            config_details.environment)
+            config_details.environment,
+            compatibility
+        )
         return service_dict
         return service_dict
 
 
     def build_services(service_config):
     def build_services(service_config):
@@ -827,7 +832,7 @@ def finalize_service_volumes(service_dict, environment):
     return service_dict
     return service_dict
 
 
 
 
-def finalize_service(service_config, service_names, version, environment):
+def finalize_service(service_config, service_names, version, environment, compatibility):
     service_dict = dict(service_config.config)
     service_dict = dict(service_config.config)
 
 
     if 'environment' in service_dict or 'env_file' in service_dict:
     if 'environment' in service_dict or 'env_file' in service_dict:
@@ -868,10 +873,80 @@ def finalize_service(service_config, service_names, version, environment):
 
 
     normalize_build(service_dict, service_config.working_dir, environment)
     normalize_build(service_dict, service_config.working_dir, environment)
 
 
+    if compatibility:
+        service_dict, ignored_keys = translate_deploy_keys_to_container_config(
+            service_dict
+        )
+        if ignored_keys:
+            log.warn(
+                'The following deploy sub-keys are not supported in compatibility mode and have'
+                ' been ignored: {}'.format(', '.join(ignored_keys))
+            )
+
     service_dict['name'] = service_config.name
     service_dict['name'] = service_config.name
     return normalize_v1_service_format(service_dict)
     return normalize_v1_service_format(service_dict)
 
 
 
 
+def translate_resource_keys_to_container_config(resources_dict, service_dict):
+    if 'limits' in resources_dict:
+        service_dict['mem_limit'] = resources_dict['limits'].get('memory')
+        if 'cpus' in resources_dict['limits']:
+            service_dict['cpus'] = float(resources_dict['limits']['cpus'])
+    if 'reservations' in resources_dict:
+        service_dict['mem_reservation'] = resources_dict['reservations'].get('memory')
+        if 'cpus' in resources_dict['reservations']:
+            return ['resources.reservations.cpus']
+    return []
+
+
+def convert_restart_policy(name):
+    try:
+        return {
+            'any': 'always',
+            'none': 'no',
+            'on-failure': 'on-failure'
+        }[name]
+    except KeyError:
+        raise ConfigurationError('Invalid restart policy "{}"'.format(name))
+
+
+def translate_deploy_keys_to_container_config(service_dict):
+    if 'deploy' not in service_dict:
+        return service_dict, []
+
+    deploy_dict = service_dict['deploy']
+    ignored_keys = [
+        k for k in ['endpoint_mode', 'labels', 'update_config', 'placement']
+        if k in deploy_dict
+    ]
+
+    if 'replicas' in deploy_dict and deploy_dict.get('mode', 'replicated') == 'replicated':
+        service_dict['scale'] = deploy_dict['replicas']
+
+    if 'restart_policy' in deploy_dict:
+        service_dict['restart'] = {
+            'Name': convert_restart_policy(deploy_dict['restart_policy'].get('condition', 'any')),
+            'MaximumRetryCount': deploy_dict['restart_policy'].get('max_attempts', 0)
+        }
+        for k in deploy_dict['restart_policy'].keys():
+            if k != 'condition' and k != 'max_attempts':
+                ignored_keys.append('restart_policy.{}'.format(k))
+
+    ignored_keys.extend(
+        translate_resource_keys_to_container_config(
+            deploy_dict.get('resources', {}), service_dict
+        )
+    )
+
+    del service_dict['deploy']
+    if 'credential_spec' in service_dict:
+        del service_dict['credential_spec']
+    if 'configs' in service_dict:
+        del service_dict['configs']
+
+    return service_dict, ignored_keys
+
+
 def normalize_v1_service_format(service_dict):
 def normalize_v1_service_format(service_dict):
     if 'log_driver' in service_dict or 'log_opt' in service_dict:
     if 'log_driver' in service_dict or 'log_opt' in service_dict:
         if 'logging' not in service_dict:
         if 'logging' not in service_dict:

+ 1 - 1
compose/config/config_schema_v3.6.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "http://json-schema.org/draft-04/schema#",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "id": "config_schema_v3.5.json",
+  "id": "config_schema_v3.6.json",
   "type": "object",
   "type": "object",
   "required": ["version"],
   "required": ["version"],
 
 

+ 29 - 5
tests/acceptance/cli_test.py

@@ -395,7 +395,7 @@ class CLITestCase(DockerClientTestCase):
         result = self.dispatch(['config'])
         result = self.dispatch(['config'])
 
 
         assert yaml.load(result.stdout) == {
         assert yaml.load(result.stdout) == {
-            'version': '3.2',
+            'version': '3.5',
             'volumes': {
             'volumes': {
                 'foobar': {
                 'foobar': {
                     'labels': {
                     'labels': {
@@ -419,22 +419,25 @@ class CLITestCase(DockerClientTestCase):
                         },
                         },
                         'resources': {
                         'resources': {
                             'limits': {
                             'limits': {
-                                'cpus': '0.001',
+                                'cpus': '0.05',
                                 'memory': '50M',
                                 'memory': '50M',
                             },
                             },
                             'reservations': {
                             'reservations': {
-                                'cpus': '0.0001',
+                                'cpus': '0.01',
                                 'memory': '20M',
                                 'memory': '20M',
                             },
                             },
                         },
                         },
                         'restart_policy': {
                         'restart_policy': {
-                            'condition': 'on_failure',
+                            'condition': 'on-failure',
                             'delay': '5s',
                             'delay': '5s',
                             'max_attempts': 3,
                             'max_attempts': 3,
                             'window': '120s',
                             'window': '120s',
                         },
                         },
                         'placement': {
                         'placement': {
-                            'constraints': ['node=foo'],
+                            'constraints': [
+                                'node.hostname==foo', 'node.role != manager'
+                            ],
+                            'preferences': [{'spread': 'node.labels.datacenter'}]
                         },
                         },
                     },
                     },
 
 
@@ -464,6 +467,27 @@ class CLITestCase(DockerClientTestCase):
             },
             },
         }
         }
 
 
+    def test_config_compatibility_mode(self):
+        self.base_dir = 'tests/fixtures/compatibility-mode'
+        result = self.dispatch(['--compatibility', 'config'])
+
+        assert yaml.load(result.stdout) == {
+            'version': '2.3',
+            'volumes': {'foo': {'driver': 'default'}},
+            'services': {
+                'foo': {
+                    'command': '/bin/true',
+                    'image': 'alpine:3.7',
+                    'scale': 3,
+                    'restart': 'always:7',
+                    'mem_limit': '300M',
+                    'mem_reservation': '100M',
+                    'cpus': 0.7,
+                    'volumes': ['foo:/bar:rw']
+                }
+            }
+        }
+
     def test_ps(self):
     def test_ps(self):
         self.project.get_service('simple').create_container()
         self.project.get_service('simple').create_container()
         result = self.dispatch(['ps'])
         result = self.dispatch(['ps'])

+ 22 - 0
tests/fixtures/compatibility-mode/docker-compose.yml

@@ -0,0 +1,22 @@
+version: '3.5'
+services:
+  foo:
+    image: alpine:3.7
+    command: /bin/true
+    deploy:
+      replicas: 3
+      restart_policy:
+        condition: any
+        max_attempts: 7
+      resources:
+        limits:
+          memory: 300M
+          cpus: '0.7'
+        reservations:
+          memory: 100M
+    volumes:
+      - foo:/bar
+
+volumes:
+  foo:
+    driver: default

+ 9 - 6
tests/fixtures/v3-full/docker-compose.yml

@@ -1,8 +1,7 @@
-version: "3.2"
+version: "3.5"
 services:
 services:
   web:
   web:
     image: busybox
     image: busybox
-
     deploy:
     deploy:
       mode: replicated
       mode: replicated
       replicas: 6
       replicas: 6
@@ -15,18 +14,22 @@ services:
         max_failure_ratio: 0.3
         max_failure_ratio: 0.3
       resources:
       resources:
         limits:
         limits:
-          cpus: '0.001'
+          cpus: '0.05'
           memory: 50M
           memory: 50M
         reservations:
         reservations:
-          cpus: '0.0001'
+          cpus: '0.01'
           memory: 20M
           memory: 20M
       restart_policy:
       restart_policy:
-        condition: on_failure
+        condition: on-failure
         delay: 5s
         delay: 5s
         max_attempts: 3
         max_attempts: 3
         window: 120s
         window: 120s
       placement:
       placement:
-        constraints: [node=foo]
+        constraints:
+          - node.hostname==foo
+          - node.role != manager
+        preferences:
+          - spread: node.labels.datacenter
 
 
     healthcheck:
     healthcheck:
       test: cat /etc/passwd
       test: cat /etc/passwd

+ 76 - 0
tests/unit/config/config_test.py

@@ -3303,6 +3303,82 @@ class InterpolationTest(unittest.TestCase):
             assert 'BAR' in warnings[0]
             assert 'BAR' in warnings[0]
             assert 'FOO' in warnings[1]
             assert 'FOO' in warnings[1]
 
 
+    def test_compatibility_mode_warnings(self):
+        config_details = build_config_details({
+            'version': '3.5',
+            'services': {
+                'web': {
+                    'deploy': {
+                        'labels': ['abc=def'],
+                        'endpoint_mode': 'dnsrr',
+                        'update_config': {'max_failure_ratio': 0.4},
+                        'placement': {'constraints': ['node.id==deadbeef']},
+                        'resources': {
+                            'reservations': {'cpus': '0.2'}
+                        },
+                        'restart_policy': {
+                            'delay': '2s',
+                            'window': '12s'
+                        }
+                    },
+                    'image': 'busybox'
+                }
+            }
+        })
+
+        with mock.patch('compose.config.config.log') as log:
+            config.load(config_details, compatibility=True)
+
+        assert log.warn.call_count == 1
+        warn_message = log.warn.call_args[0][0]
+        assert warn_message.startswith(
+            'The following deploy sub-keys are not supported in compatibility mode'
+        )
+        assert 'labels' in warn_message
+        assert 'endpoint_mode' in warn_message
+        assert 'update_config' in warn_message
+        assert 'placement' in warn_message
+        assert 'resources.reservations.cpus' in warn_message
+        assert 'restart_policy.delay' in warn_message
+        assert 'restart_policy.window' in warn_message
+
+    def test_compatibility_mode_load(self):
+        config_details = build_config_details({
+            'version': '3.5',
+            'services': {
+                'foo': {
+                    'image': 'alpine:3.7',
+                    'deploy': {
+                        'replicas': 3,
+                        'restart_policy': {
+                            'condition': 'any',
+                            'max_attempts': 7,
+                        },
+                        'resources': {
+                            'limits': {'memory': '300M', 'cpus': '0.7'},
+                            'reservations': {'memory': '100M'},
+                        },
+                    },
+                },
+            },
+        })
+
+        with mock.patch('compose.config.config.log') as log:
+            cfg = config.load(config_details, compatibility=True)
+
+        assert log.warn.call_count == 0
+
+        service_dict = cfg.services[0]
+        assert service_dict == {
+            'image': 'alpine:3.7',
+            'scale': 3,
+            'restart': {'MaximumRetryCount': 7, 'Name': 'always'},
+            'mem_limit': '300M',
+            'mem_reservation': '100M',
+            'cpus': 0.7,
+            'name': 'foo'
+        }
+
     @mock.patch.dict(os.environ)
     @mock.patch.dict(os.environ)
     def test_invalid_interpolation(self):
     def test_invalid_interpolation(self):
         with pytest.raises(config.ConfigurationError) as cm:
         with pytest.raises(config.ConfigurationError) as cm: