Merge pull request #5684 from docker/compat_mode

Compatibility mode
This commit is contained in:
Joffrey F 2018-02-26 10:42:59 -08:00 committed by GitHub
commit ec0de7eb68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 236 additions and 30 deletions

View File

@ -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',

View File

@ -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) -f, --file FILE Specify an alternate compose file
-p, --project-name NAME Specify an alternate project name (default: directory name) (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 --skip-hostname-check Don't check the daemon's hostname against the
in the client certificate (for example if your docker host name specified in the client certificate
is an IP address)
--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

View File

@ -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
) )
) )
if not compatibility:
check_swarm_only_key(service_dicts, 'deploy') 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)
return Config(main_file.version, service_dicts, volumes, networks, secrets, configs) version = V2_3 if compatibility and main_file.version >= V3_0 else main_file.version
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:

View File

@ -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"],

View File

@ -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'])

View File

@ -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

View File

@ -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

View File

@ -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: