Merge pull request #1927 from mnowster/validate-extended-services

Validate and interpolate extended services
This commit is contained in:
Aanand Prasad 2015-09-02 13:41:21 -07:00
commit 0060437f57
17 changed files with 358 additions and 164 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
myweb:
build: '.'
extends:
service: web
command: top
web:
build: '.'
links:
- "mydb:db"

View File

@ -0,0 +1,8 @@
myweb:
build: '.'
extends:
service: web
command: top
web:
build: '.'
net: "container:db"

View File

@ -0,0 +1,9 @@
myweb:
build: '.'
extends:
service: web
command: top
web:
build: '.'
volumes_from:
- "db"

View File

@ -0,0 +1,4 @@
myweb:
extends:
file: valid-composite-extends.yml
service: web

View File

@ -0,0 +1,5 @@
myweb:
build: '.'
extends:
file: 'valid-composite-extends.yml'
service: web

View File

@ -0,0 +1,6 @@
myweb:
build: '.'
extends:
file: valid-common.yml
service: common-config
command: top

View File

@ -0,0 +1,3 @@
common-config:
environment:
- FOO=1

View File

@ -0,0 +1,2 @@
web:
command: top

View File

@ -0,0 +1,3 @@
web:
build: '.'
hostname: "host-${HOSTNAME_VALUE}"

View File

@ -0,0 +1,5 @@
myweb:
extends:
service: web
file: valid-interpolation-2.yml
command: top

View File

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

View File

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

View File

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