diff --git a/compose/config/config.py b/compose/config/config.py index cfa8086f09..8df45b8a9f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -10,7 +10,10 @@ from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables -from .validation import validate_against_schema +from .validation import validate_against_fields_schema +from .validation import validate_against_service_schema +from .validation import validate_extended_service_exists +from .validation import validate_extends_file_path from .validation import validate_service_names from .validation import validate_top_level_object from compose.cli.utils import find_candidates_in_parent_dirs @@ -137,13 +140,17 @@ def load(config_details): config, working_dir, filename = config_details processed_config = pre_process_config(config) - validate_against_schema(processed_config) + validate_against_fields_schema(processed_config) service_dicts = [] for service_name, service_dict in list(processed_config.items()): - loader = ServiceLoader(working_dir=working_dir, filename=filename) - service_dict = loader.make_service_dict(service_name, service_dict) + loader = ServiceLoader( + working_dir=working_dir, + filename=filename, + service_name=service_name, + service_dict=service_dict) + service_dict = loader.make_service_dict() validate_paths(service_dict) service_dicts.append(service_dict) @@ -151,84 +158,117 @@ def load(config_details): class ServiceLoader(object): - def __init__(self, working_dir, filename=None, already_seen=None): + def __init__(self, working_dir, filename, service_name, service_dict, already_seen=None): + if working_dir is None: + raise Exception("No working_dir passed to ServiceLoader()") + self.working_dir = os.path.abspath(working_dir) + if filename: self.filename = os.path.abspath(filename) else: self.filename = filename self.already_seen = already_seen or [] + self.service_dict = service_dict.copy() + self.service_name = service_name + self.service_dict['name'] = service_name def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) - def make_service_dict(self, name, service_dict): - service_dict = service_dict.copy() - service_dict['name'] = name - service_dict = resolve_environment(service_dict, working_dir=self.working_dir) - service_dict = self.resolve_extends(service_dict) - return process_container_options(service_dict, working_dir=self.working_dir) + def make_service_dict(self): + self.resolve_environment() + if 'extends' in self.service_dict: + self.validate_and_construct_extends() + self.service_dict = self.resolve_extends() - def resolve_extends(self, service_dict): - if 'extends' not in service_dict: - return service_dict + if not self.already_seen: + validate_against_service_schema(self.service_dict, self.service_name) - extends_options = self.validate_extends_options(service_dict['name'], service_dict['extends']) + return process_container_options(self.service_dict, working_dir=self.working_dir) - if self.working_dir is None: - raise Exception("No working_dir passed to ServiceLoader()") + def resolve_environment(self): + """ + Unpack any environment variables from an env_file, if set. + Interpolate environment values if set. + """ + if 'environment' not in self.service_dict and 'env_file' not in self.service_dict: + return - if 'file' in extends_options: - extends_from_filename = extends_options['file'] - other_config_path = expand_path(self.working_dir, extends_from_filename) - else: - other_config_path = self.filename + env = {} + + if 'env_file' in self.service_dict: + for f in get_env_files(self.service_dict, working_dir=self.working_dir): + env.update(env_vars_from_file(f)) + del self.service_dict['env_file'] + + env.update(parse_environment(self.service_dict.get('environment'))) + env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + + self.service_dict['environment'] = env + + def validate_and_construct_extends(self): + validate_extends_file_path( + self.service_name, + self.service_dict['extends'], + self.filename + ) + self.extended_config_path = self.get_extended_config_path( + self.service_dict['extends'] + ) + self.extended_service_name = self.service_dict['extends']['service'] + + full_extended_config = pre_process_config( + load_yaml(self.extended_config_path) + ) + + validate_extended_service_exists( + self.extended_service_name, + full_extended_config, + self.extended_config_path + ) + validate_against_fields_schema(full_extended_config) + + self.extended_config = full_extended_config[self.extended_service_name] + + def resolve_extends(self): + other_working_dir = os.path.dirname(self.extended_config_path) + other_already_seen = self.already_seen + [self.signature(self.service_name)] - other_working_dir = os.path.dirname(other_config_path) - other_already_seen = self.already_seen + [self.signature(service_dict['name'])] other_loader = ServiceLoader( working_dir=other_working_dir, - filename=other_config_path, + filename=self.extended_config_path, + service_name=self.service_name, + service_dict=self.extended_config, already_seen=other_already_seen, ) - base_service = extends_options['service'] - other_config = load_yaml(other_config_path) - - if base_service not in other_config: - msg = ( - "Cannot extend service '%s' in %s: Service not found" - ) % (base_service, other_config_path) - raise ConfigurationError(msg) - - other_service_dict = other_config[base_service] - other_loader.detect_cycle(extends_options['service']) - other_service_dict = other_loader.make_service_dict( - service_dict['name'], - other_service_dict, - ) + other_loader.detect_cycle(self.extended_service_name) + other_service_dict = other_loader.make_service_dict() validate_extended_service_dict( other_service_dict, - filename=other_config_path, - service=extends_options['service'], + filename=self.extended_config_path, + service=self.extended_service_name, ) - return merge_service_dicts(other_service_dict, service_dict) + return merge_service_dicts(other_service_dict, self.service_dict) + + def get_extended_config_path(self, extends_options): + """ + Service we are extending either has a value for 'file' set, which we + need to obtain a full path too or we are extending from a service + defined in our own file. + """ + if 'file' in extends_options: + extends_from_filename = extends_options['file'] + return expand_path(self.working_dir, extends_from_filename) + + return self.filename def signature(self, name): return (self.filename, name) - def validate_extends_options(self, service_name, extends_options): - error_prefix = "Invalid 'extends' configuration for %s:" % service_name - - if 'file' not in extends_options and self.filename is None: - raise ConfigurationError( - "%s you need to specify a 'file', e.g. 'file: something.yml'" % error_prefix - ) - - return extends_options - def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) @@ -320,9 +360,6 @@ def get_env_files(options, working_dir=None): if 'env_file' not in options: return {} - if working_dir is None: - raise Exception("No working_dir passed to get_env_files()") - env_files = options.get('env_file', []) if not isinstance(env_files, list): env_files = [env_files] @@ -330,26 +367,6 @@ def get_env_files(options, working_dir=None): return [expand_path(working_dir, path) for path in env_files] -def resolve_environment(service_dict, working_dir=None): - service_dict = service_dict.copy() - - if 'environment' not in service_dict and 'env_file' not in service_dict: - return service_dict - - env = {} - - if 'env_file' in service_dict: - for f in get_env_files(service_dict, working_dir=working_dir): - env.update(env_vars_from_file(f)) - del service_dict['env_file'] - - env.update(parse_environment(service_dict.get('environment'))) - env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) - - service_dict['environment'] = env - return service_dict - - def parse_environment(environment): if not environment: return {} diff --git a/compose/config/schema.json b/compose/config/fields_schema.json similarity index 86% rename from compose/config/schema.json rename to compose/config/fields_schema.json index 94fe4fc522..2a122b7a31 100644 --- a/compose/config/schema.json +++ b/compose/config/fields_schema.json @@ -19,7 +19,12 @@ "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "command": {"$ref": "#/definitions/string_or_list"}, "container_name": {"type": "string"}, - "cpu_shares": {"type": "string"}, + "cpu_shares": { + "oneOf": [ + {"type": "number"}, + {"type": "string"} + ] + }, "cpuset": {"type": "string"}, "detach": {"type": "boolean"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, @@ -27,7 +32,7 @@ "dns_search": {"$ref": "#/definitions/string_or_list"}, "dockerfile": {"type": "string"}, "domainname": {"type": "string"}, - "entrypoint": {"type": "string"}, + "entrypoint": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": { @@ -75,7 +80,7 @@ }, "name": {"type": "string"}, "net": {"type": "string"}, - "pid": {"type": "string"}, + "pid": {"type": ["string", "null"]}, "ports": { "type": "array", @@ -94,36 +99,19 @@ "uniqueItems": true }, - "privileged": {"type": "string"}, + "privileged": {"type": "boolean"}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, - "security_opt": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "stdin_open": {"type": "string"}, "tty": {"type": "string"}, "user": {"type": "string"}, "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "volume_driver": {"type": "string"}, "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "working_dir": {"type": "string"} }, - "anyOf": [ - { - "required": ["build"], - "not": {"required": ["image"]} - }, - { - "required": ["image"], - "not": {"anyOf": [ - {"required": ["build"]}, - {"required": ["dockerfile"]} - ]} - }, - { - "required": ["extends"], - "not": {"required": ["build", "image"]} - } - ], - "dependencies": { "memswap_limit": ["mem_limit"] }, diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json new file mode 100644 index 0000000000..5cb5d6d070 --- /dev/null +++ b/compose/config/service_schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type": "object", + + "properties": { + "name": {"type": "string"} + }, + + "required": ["name"], + + "allOf": [ + {"$ref": "fields_schema.json#/definitions/service"}, + {"$ref": "#/definitions/service_constraints"} + ], + + "definitions": { + "service_constraints": { + "anyOf": [ + { + "required": ["build"], + "not": {"required": ["image"]} + }, + { + "required": ["image"], + "not": {"anyOf": [ + {"required": ["build"]}, + {"required": ["dockerfile"]} + ]} + }, + { + "required": ["extends"], + "not": {"required": ["build", "image"]} + } + ] + } + } + +} diff --git a/compose/config/validation.py b/compose/config/validation.py index d83504274c..632bdf03bd 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -5,6 +5,7 @@ from functools import wraps from docker.utils.ports import split_port from jsonschema import Draft4Validator from jsonschema import FormatChecker +from jsonschema import RefResolver from jsonschema import ValidationError from .errors import ConfigurationError @@ -66,6 +67,27 @@ def validate_top_level_object(func): return func_wrapper +def validate_extends_file_path(service_name, extends_options, filename): + """ + The service to be extended must either be defined in the config key 'file', + or within 'filename'. + """ + error_prefix = "Invalid 'extends' configuration for %s:" % service_name + + if 'file' not in extends_options and filename is None: + raise ConfigurationError( + "%s you need to specify a 'file', e.g. 'file: something.yml'" % error_prefix + ) + + +def validate_extended_service_exists(extended_service_name, full_extended_config, extended_config_path): + if extended_service_name not in full_extended_config: + msg = ( + "Cannot extend service '%s' in %s: Service not found" + ) % (extended_service_name, extended_config_path) + raise ConfigurationError(msg) + + def get_unsupported_config_msg(service_name, error_key): msg = "Unsupported config option for '{}' service: '{}'".format(service_name, error_key) if error_key in DOCKER_CONFIG_HINTS: @@ -73,7 +95,7 @@ def get_unsupported_config_msg(service_name, error_key): return msg -def process_errors(errors): +def process_errors(errors, service_name=None): """ jsonschema gives us an error tree full of information to explain what has gone wrong. Process each error and pull out relevant information and re-write @@ -103,7 +125,7 @@ def process_errors(errors): for error in errors: # handle root level errors - if len(error.path) == 0: + if len(error.path) == 0 and not error.instance.get('name'): if error.validator == 'type': msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." root_msgs.append(msg) @@ -115,11 +137,14 @@ def process_errors(errors): root_msgs.append(_clean_error_message(error.message)) else: - # handle service level errors - service_name = error.path[0] - - # pop the service name off our path - error.path.popleft() + if not service_name: + # field_schema errors will have service name on the path + service_name = error.path[0] + error.path.popleft() + else: + # service_schema errors have the service name passed in, as that + # is not available on error.path or necessarily error.instance + service_name = service_name if error.validator == 'additionalProperties': invalid_config_key = _parse_key_from_error_msg(error) @@ -189,16 +214,27 @@ def process_errors(errors): return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors) -def validate_against_schema(config): +def validate_against_fields_schema(config): + schema_filename = "fields_schema.json" + return _validate_against_schema(config, schema_filename) + + +def validate_against_service_schema(config, service_name): + schema_filename = "service_schema.json" + return _validate_against_schema(config, schema_filename, service_name) + + +def _validate_against_schema(config, schema_filename, service_name=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) - schema_file = os.path.join(config_source_dir, "schema.json") + schema_file = os.path.join(config_source_dir, schema_filename) with open(schema_file, "r") as schema_fh: schema = json.load(schema_fh) - validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) + resolver = RefResolver('file://' + config_source_dir + '/', schema) + validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(["ports"])) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: - error_msg = process_errors(errors) + error_msg = process_errors(errors, service_name) raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) diff --git a/tests/fixtures/extends/invalid-links.yml b/tests/fixtures/extends/invalid-links.yml new file mode 100644 index 0000000000..edfeb8b231 --- /dev/null +++ b/tests/fixtures/extends/invalid-links.yml @@ -0,0 +1,9 @@ +myweb: + build: '.' + extends: + service: web + command: top +web: + build: '.' + links: + - "mydb:db" diff --git a/tests/fixtures/extends/invalid-net.yml b/tests/fixtures/extends/invalid-net.yml new file mode 100644 index 0000000000..fbcd020bcf --- /dev/null +++ b/tests/fixtures/extends/invalid-net.yml @@ -0,0 +1,8 @@ +myweb: + build: '.' + extends: + service: web + command: top +web: + build: '.' + net: "container:db" diff --git a/tests/fixtures/extends/invalid-volumes.yml b/tests/fixtures/extends/invalid-volumes.yml new file mode 100644 index 0000000000..3db0118e0e --- /dev/null +++ b/tests/fixtures/extends/invalid-volumes.yml @@ -0,0 +1,9 @@ +myweb: + build: '.' + extends: + service: web + command: top +web: + build: '.' + volumes_from: + - "db" diff --git a/tests/fixtures/extends/service-with-invalid-schema.yml b/tests/fixtures/extends/service-with-invalid-schema.yml new file mode 100644 index 0000000000..00c36647ef --- /dev/null +++ b/tests/fixtures/extends/service-with-invalid-schema.yml @@ -0,0 +1,4 @@ +myweb: + extends: + file: valid-composite-extends.yml + service: web diff --git a/tests/fixtures/extends/service-with-valid-composite-extends.yml b/tests/fixtures/extends/service-with-valid-composite-extends.yml new file mode 100644 index 0000000000..6c419ed070 --- /dev/null +++ b/tests/fixtures/extends/service-with-valid-composite-extends.yml @@ -0,0 +1,5 @@ +myweb: + build: '.' + extends: + file: 'valid-composite-extends.yml' + service: web diff --git a/tests/fixtures/extends/valid-common-config.yml b/tests/fixtures/extends/valid-common-config.yml new file mode 100644 index 0000000000..d8f13e7a86 --- /dev/null +++ b/tests/fixtures/extends/valid-common-config.yml @@ -0,0 +1,6 @@ +myweb: + build: '.' + extends: + file: valid-common.yml + service: common-config + command: top diff --git a/tests/fixtures/extends/valid-common.yml b/tests/fixtures/extends/valid-common.yml new file mode 100644 index 0000000000..07ad68e3e7 --- /dev/null +++ b/tests/fixtures/extends/valid-common.yml @@ -0,0 +1,3 @@ +common-config: + environment: + - FOO=1 diff --git a/tests/fixtures/extends/valid-composite-extends.yml b/tests/fixtures/extends/valid-composite-extends.yml new file mode 100644 index 0000000000..8816c3f3b2 --- /dev/null +++ b/tests/fixtures/extends/valid-composite-extends.yml @@ -0,0 +1,2 @@ +web: + command: top diff --git a/tests/fixtures/extends/valid-interpolation-2.yml b/tests/fixtures/extends/valid-interpolation-2.yml new file mode 100644 index 0000000000..cb7bd93fc2 --- /dev/null +++ b/tests/fixtures/extends/valid-interpolation-2.yml @@ -0,0 +1,3 @@ +web: + build: '.' + hostname: "host-${HOSTNAME_VALUE}" diff --git a/tests/fixtures/extends/valid-interpolation.yml b/tests/fixtures/extends/valid-interpolation.yml new file mode 100644 index 0000000000..68e8740fb4 --- /dev/null +++ b/tests/fixtures/extends/valid-interpolation.yml @@ -0,0 +1,5 @@ +myweb: + extends: + service: web + file: valid-interpolation-2.yml + command: top diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0cf8cdb0ef..177471ffa9 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -165,16 +165,6 @@ class ServiceTest(DockerClientTestCase): service.start_container(container) self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) - def test_create_container_with_extra_hosts_string(self): - extra_hosts = 'somehost:162.242.195.82' - service = self.create_service('db', extra_hosts=extra_hosts) - self.assertRaises(ConfigError, lambda: service.create_container()) - - def test_create_container_with_extra_hosts_list_of_dicts(self): - extra_hosts = [{'somehost': '162.242.195.82'}, {'otherhost': '50.31.209.229'}] - service = self.create_service('db', extra_hosts=extra_hosts) - self.assertRaises(ConfigError, lambda: service.create_container()) - def test_create_container_with_extra_hosts_dicts(self): extra_hosts = {'somehost': '162.242.195.82', 'otherhost': '50.31.209.229'} extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] @@ -515,7 +505,7 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(container['HostConfig']['Privileged'], True) def test_expose_does_not_publish_ports(self): - service = self.create_service('web', expose=[8000]) + service = self.create_service('web', expose=["8000"]) container = create_and_start_container(service).inspect() self.assertEqual(container['NetworkSettings']['Ports'], {'8000/tcp': None}) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 08ef9f272f..4557c07b6a 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -31,11 +31,29 @@ class DockerClientTestCase(unittest.TestCase): if 'command' not in kwargs: kwargs['command'] = ["top"] - options = ServiceLoader(working_dir='.').make_service_dict(name, kwargs) + links = kwargs.get('links', None) + volumes_from = kwargs.get('volumes_from', None) + net = kwargs.get('net', None) + + workaround_options = ['links', 'volumes_from', 'net'] + for key in workaround_options: + try: + del kwargs[key] + except KeyError: + pass + + options = ServiceLoader(working_dir='.', filename=None, service_name=name, service_dict=kwargs).make_service_dict() labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() + if links: + options['links'] = links + if volumes_from: + options['volumes_from'] = volumes_from + if net: + options['net'] = net + return Service( project='composetest', client=self.client, diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index e488ceb527..21f1261ec0 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -11,11 +11,15 @@ from compose.config import config from compose.config.errors import ConfigurationError -def make_service_dict(name, service_dict, working_dir): +def make_service_dict(name, service_dict, working_dir, filename=None): """ Test helper function to construct a ServiceLoader """ - return config.ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) + return config.ServiceLoader( + working_dir=working_dir, + filename=filename, + service_name=name, + service_dict=service_dict).make_service_dict() def service_sort(services): @@ -202,6 +206,39 @@ class ConfigTest(unittest.TestCase): ) ) + def test_config_extra_hosts_string_raises_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'extra_hosts': 'somehost:162.242.195.82' + }}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_extra_hosts_list_of_dicts_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'extra_hosts': [ + {'somehost': '162.242.195.82'}, + {'otherhost': '50.31.209.229'} + ] + }}, + 'working_dir', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) @@ -236,7 +273,7 @@ class InterpolationTest(unittest.TestCase): 'web': { 'image': '${FOO}', 'command': '${BAR}', - 'entrypoint': '${BAR}', + 'container_name': '${BAR}', }, }, working_dir='.', @@ -282,12 +319,13 @@ class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' - d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') + d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) @mock.patch.dict(os.environ) def test_volume_binding_with_local_dir_name_raises_warning(self): def make_dict(**config): + config['build'] = '.' make_service_dict('foo', config, working_dir='.') with mock.patch('compose.config.config.log.warn') as warn: @@ -332,6 +370,7 @@ class InterpolationTest(unittest.TestCase): def test_named_volume_with_driver_does_not_expand(self): d = make_service_dict('foo', { + 'build': '.', 'volumes': ['namedvolume:/data'], 'volume_driver': 'foodriver', }, working_dir='.') @@ -341,6 +380,7 @@ class InterpolationTest(unittest.TestCase): def test_home_directory_with_driver_does_not_expand(self): os.environ['NAME'] = 'surprise!' d = make_service_dict('foo', { + 'build': '.', 'volumes': ['~:/data'], 'volume_driver': 'foodriver', }, working_dir='.') @@ -500,36 +540,36 @@ class MergeLabelsTest(unittest.TestCase): def test_no_override(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), + make_service_dict('foo', {'build': '.'}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) def test_no_base(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {}, 'tests/'), - make_service_dict('foo', {'labels': ['foo=2']}, 'tests/'), + make_service_dict('foo', {'build': '.'}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '2'}) def test_override_explicit_value(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'labels': ['foo=2']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '2', 'bar': ''}) def test_add_explicit_value(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'labels': ['bar=2']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['bar=2']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': '2'}) def test_remove_explicit_value(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar=2']}, 'tests/'), - make_service_dict('foo', {'labels': ['bar']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar=2']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['bar']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) @@ -611,6 +651,7 @@ class EnvTest(unittest.TestCase): service_dict = make_service_dict( 'foo', { + 'build': '.', 'environment': { 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', @@ -629,7 +670,7 @@ class EnvTest(unittest.TestCase): def test_env_from_file(self): service_dict = make_service_dict( 'foo', - {'env_file': 'one.env'}, + {'build': '.', 'env_file': 'one.env'}, 'tests/fixtures/env', ) self.assertEqual( @@ -640,7 +681,7 @@ class EnvTest(unittest.TestCase): def test_env_from_multiple_files(self): service_dict = make_service_dict( 'foo', - {'env_file': ['one.env', 'two.env']}, + {'build': '.', 'env_file': ['one.env', 'two.env']}, 'tests/fixtures/env', ) self.assertEqual( @@ -662,7 +703,7 @@ class EnvTest(unittest.TestCase): os.environ['ENV_DEF'] = 'E3' service_dict = make_service_dict( 'foo', - {'env_file': 'resolve.env'}, + {'build': '.', 'env_file': 'resolve.env'}, 'tests/fixtures/env', ) self.assertEqual( @@ -862,6 +903,17 @@ class ExtendsTest(unittest.TestCase): self.assertEquals(len(service), 1) self.assertIsInstance(service[0], dict) + self.assertEquals(service[0]['command'], "/bin/true") + + def test_extended_service_with_invalid_config(self): + expected_error_msg = "Service 'myweb' has neither an image nor a build path specified" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') + + def test_extended_service_with_valid_config(self): + service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml') + self.assertEquals(service[0]['command'], "top") def test_extends_file_defaults_to_self(self): """ @@ -887,37 +939,33 @@ class ExtendsTest(unittest.TestCase): } ])) - def test_blacklisted_options(self): - def load_config(): - return make_service_dict('myweb', { - 'extends': { - 'file': 'whatever', - 'service': 'web', - } - }, '.') + def test_invalid_links_in_extended_service(self): + expected_error_msg = "services with 'links' cannot be extended" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/invalid-links.yml') - with self.assertRaisesRegexp(ConfigurationError, 'links'): - other_config = {'web': {'links': ['db']}} + def test_invalid_volumes_from_in_extended_service(self): + expected_error_msg = "services with 'volumes_from' cannot be extended" - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/invalid-volumes.yml') - with self.assertRaisesRegexp(ConfigurationError, 'volumes_from'): - other_config = {'web': {'volumes_from': ['db']}} + def test_invalid_net_in_extended_service(self): + expected_error_msg = "services with 'net: container' cannot be extended" - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/invalid-net.yml') - with self.assertRaisesRegexp(ConfigurationError, 'net'): - other_config = {'web': {'net': 'container:db'}} + @mock.patch.dict(os.environ) + def test_valid_interpolation_in_extended_service(self): + os.environ.update( + HOSTNAME_VALUE="penguin", + ) + expected_interpolated_value = "host-penguin" - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) - - other_config = {'web': {'net': 'host'}} - - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) + service_dicts = load_from_filename('tests/fixtures/extends/valid-interpolation.yml') + for service in service_dicts: + self.assertTrue(service['hostname'], expected_interpolated_value) def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') @@ -949,6 +997,10 @@ class ExtendsTest(unittest.TestCase): with self.assertRaisesRegexp(ConfigurationError, err_msg): load_from_filename('tests/fixtures/extends/nonexistent-service.yml') + def test_partial_service_config_in_extends_is_still_valid(self): + dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml') + self.assertEqual(dicts[0]['environment'], {'FOO': '1'}) + class BuildPathTest(unittest.TestCase): def setUp(self):