Merge pull request #2783 from aanand/fix-validation-v2

Fix validation and version checking
This commit is contained in:
Aanand Prasad 2016-02-02 15:19:50 +00:00
commit 60a5b39f6f
29 changed files with 414 additions and 252 deletions

View File

@ -14,10 +14,12 @@ import six
import yaml import yaml
from cached_property import cached_property from cached_property import cached_property
from ..const import COMPOSEFILE_VERSIONS from ..const import COMPOSEFILE_V1 as V1
from ..const import COMPOSEFILE_V2_0 as V2_0
from .errors import CircularReference from .errors import CircularReference
from .errors import ComposeFileNotFound from .errors import ComposeFileNotFound
from .errors import ConfigurationError from .errors import ConfigurationError
from .errors import VERSION_EXPLANATION
from .interpolation import interpolate_environment_variables from .interpolation import interpolate_environment_variables
from .sort_services import get_container_name_from_network_mode from .sort_services import get_container_name_from_network_mode
from .sort_services import get_service_name_from_network_mode from .sort_services import get_service_name_from_network_mode
@ -103,6 +105,7 @@ SUPPORTED_FILENAMES = [
DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -129,27 +132,48 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
@cached_property @cached_property
def version(self): def version(self):
if self.config is None: if 'version' not in self.config:
return 1 return V1
version = self.config.get('version', 1)
version = self.config['version']
if isinstance(version, dict): if isinstance(version, dict):
log.warn("Unexpected type for field 'version', in file {} assuming " log.warn('Unexpected type for "version" key in "{}". Assuming '
"version is the name of a service, and defaulting to " '"version" is the name of a service, and defaulting to '
"Compose file version 1".format(self.filename)) 'Compose file version 1.'.format(self.filename))
return 1 return V1
if not isinstance(version, six.string_types):
raise ConfigurationError(
'Version in "{}" is invalid - it should be a string.'
.format(self.filename))
if version == '1':
raise ConfigurationError(
'Version in "{}" is invalid. {}'
.format(self.filename, VERSION_EXPLANATION))
if version == '2':
version = V2_0
if version != V2_0:
raise ConfigurationError(
'Version in "{}" is unsupported. {}'
.format(self.filename, VERSION_EXPLANATION))
return version return version
def get_service(self, name): def get_service(self, name):
return self.get_service_dicts()[name] return self.get_service_dicts()[name]
def get_service_dicts(self): def get_service_dicts(self):
return self.config if self.version == 1 else self.config.get('services', {}) return self.config if self.version == V1 else self.config.get('services', {})
def get_volumes(self): def get_volumes(self):
return {} if self.version == 1 else self.config.get('volumes', {}) return {} if self.version == V1 else self.config.get('volumes', {})
def get_networks(self): def get_networks(self):
return {} if self.version == 1 else self.config.get('networks', {}) return {} if self.version == V1 else self.config.get('networks', {})
class Config(namedtuple('_Config', 'version services volumes networks')): class Config(namedtuple('_Config', 'version services volumes networks')):
@ -211,10 +235,6 @@ def validate_config_version(config_files):
next_file.filename, next_file.filename,
next_file.version)) next_file.version))
if main_file.version not in COMPOSEFILE_VERSIONS:
raise ConfigurationError(
'Invalid Compose file version: {0}'.format(main_file.version))
def get_default_config_files(base_dir): def get_default_config_files(base_dir):
(candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir) (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir)
@ -278,7 +298,7 @@ def load(config_details):
main_file, main_file,
[file.get_service_dicts() for file in config_details.config_files]) [file.get_service_dicts() for file in config_details.config_files])
if main_file.version >= 2: 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)
@ -363,7 +383,7 @@ def process_config_file(config_file, service_name=None):
interpolated_config = interpolate_environment_variables(service_dicts, 'service') interpolated_config = interpolate_environment_variables(service_dicts, 'service')
if config_file.version == 2: if config_file.version == V2_0:
processed_config = dict(config_file.config) processed_config = dict(config_file.config)
processed_config['services'] = services = interpolated_config processed_config['services'] = services = interpolated_config
processed_config['volumes'] = interpolate_environment_variables( processed_config['volumes'] = interpolate_environment_variables(
@ -371,7 +391,7 @@ def process_config_file(config_file, service_name=None):
processed_config['networks'] = interpolate_environment_variables( processed_config['networks'] = interpolate_environment_variables(
config_file.get_networks(), 'network') config_file.get_networks(), 'network')
if config_file.version == 1: if config_file.version == V1:
processed_config = services = interpolated_config processed_config = services = interpolated_config
config_file = config_file._replace(config=processed_config) config_file = config_file._replace(config=processed_config)
@ -655,7 +675,7 @@ def merge_service_dicts(base, override, version):
if field in base or field in override: if field in base or field in override:
d[field] = override.get(field, base.get(field)) d[field] = override.get(field, base.get(field))
if version == 1: if version == V1:
legacy_v1_merge_image_or_build(d, base, override) legacy_v1_merge_image_or_build(d, base, override)
else: else:
merge_build(d, base, override) merge_build(d, base, override)

View File

@ -2,6 +2,14 @@ from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
VERSION_EXPLANATION = (
'Either specify a version of "2" (or "2.0") and place your service '
'definitions under the `services` key, or omit the `version` key and place '
'your service definitions at the root of the file to use version 1.\n'
'For more on the Compose file format versions, see '
'https://docs.docker.com/compose/compose-file/')
class ConfigurationError(Exception): class ConfigurationError(Exception):
def __init__(self, msg): def __init__(self, msg):
self.msg = msg self.msg = msg

View File

@ -1,18 +1,18 @@
{ {
"$schema": "http://json-schema.org/draft-04/schema#", "$schema": "http://json-schema.org/draft-04/schema#",
"type": "object", "type": "object",
"id": "fields_schema_v2.json", "id": "fields_schema_v2.0.json",
"properties": { "properties": {
"version": { "version": {
"enum": [2] "type": "string"
}, },
"services": { "services": {
"id": "#/properties/services", "id": "#/properties/services",
"type": "object", "type": "object",
"patternProperties": { "patternProperties": {
"^[a-zA-Z0-9._-]+$": { "^[a-zA-Z0-9._-]+$": {
"$ref": "service_schema_v2.json#/definitions/service" "$ref": "service_schema_v2.0.json#/definitions/service"
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@ -1,6 +1,6 @@
{ {
"$schema": "http://json-schema.org/draft-04/schema#", "$schema": "http://json-schema.org/draft-04/schema#",
"id": "service_schema_v2.json", "id": "service_schema_v2.0.json",
"type": "object", "type": "object",

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
import os import os
from collections import namedtuple from collections import namedtuple
from compose.config.config import V1
from compose.config.errors import ConfigurationError from compose.config.errors import ConfigurationError
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
@ -16,7 +17,7 @@ class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')):
# TODO: drop service_names arg when v1 is removed # TODO: drop service_names arg when v1 is removed
@classmethod @classmethod
def parse(cls, volume_from_config, service_names, version): def parse(cls, volume_from_config, service_names, version):
func = cls.parse_v1 if version == 1 else cls.parse_v2 func = cls.parse_v1 if version == V1 else cls.parse_v2
return func(service_names, volume_from_config) return func(service_names, volume_from_config)
@classmethod @classmethod

View File

@ -15,6 +15,7 @@ from jsonschema import RefResolver
from jsonschema import ValidationError from jsonschema import ValidationError
from .errors import ConfigurationError from .errors import ConfigurationError
from .errors import VERSION_EXPLANATION
from .sort_services import get_service_name_from_network_mode from .sort_services import get_service_name_from_network_mode
@ -174,8 +175,8 @@ def validate_depends_on(service_config, service_names):
"undefined.".format(s=service_config, dep=dependency)) "undefined.".format(s=service_config, dep=dependency))
def get_unsupported_config_msg(service_name, error_key): def get_unsupported_config_msg(path, error_key):
msg = "Unsupported config option for '{}' service: '{}'".format(service_name, error_key) msg = "Unsupported config option for {}: '{}'".format(path_string(path), error_key)
if error_key in DOCKER_CONFIG_HINTS: if error_key in DOCKER_CONFIG_HINTS:
msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key]) msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key])
return msg return msg
@ -191,7 +192,7 @@ def is_service_dict_schema(schema_id):
return schema_id == 'fields_schema_v1.json' or schema_id == '#/properties/services' return schema_id == 'fields_schema_v1.json' or schema_id == '#/properties/services'
def handle_error_for_schema_with_id(error, service_name): def handle_error_for_schema_with_id(error, path):
schema_id = error.schema['id'] schema_id = error.schema['id']
if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties': if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties':
@ -215,62 +216,67 @@ def handle_error_for_schema_with_id(error, service_name):
# TODO: only applies to v1 # TODO: only applies to v1
if 'image' in error.instance and context: if 'image' in error.instance and context:
return ( return (
"Service '{}' has both an image and build path specified. " "{} has both an image and build path specified. "
"A service can either be built to image or use an existing " "A service can either be built to image or use an existing "
"image, not both.".format(service_name)) "image, not both.".format(path_string(path)))
if 'image' not in error.instance and not context: if 'image' not in error.instance and not context:
return ( return (
"Service '{}' has neither an image nor a build path " "{} has neither an image nor a build path specified. "
"specified. At least one must be provided.".format(service_name)) "At least one must be provided.".format(path_string(path)))
# TODO: only applies to v1 # TODO: only applies to v1
if 'image' in error.instance and dockerfile: if 'image' in error.instance and dockerfile:
return ( return (
"Service '{}' has both an image and alternate Dockerfile. " "{} has both an image and alternate Dockerfile. "
"A service can either be built to image or use an existing " "A service can either be built to image or use an existing "
"image, not both.".format(service_name)) "image, not both.".format(path_string(path)))
if schema_id == '#/definitions/service': if error.validator == 'additionalProperties':
if error.validator == 'additionalProperties': if schema_id == '#/definitions/service':
invalid_config_key = parse_key_from_error_msg(error) invalid_config_key = parse_key_from_error_msg(error)
return get_unsupported_config_msg(service_name, invalid_config_key) return get_unsupported_config_msg(path, invalid_config_key)
if not error.path:
return '{}\n{}'.format(error.message, VERSION_EXPLANATION)
def handle_generic_service_error(error, service_name): def handle_generic_service_error(error, path):
config_key = " ".join("'%s'" % k for k in error.path)
msg_format = None msg_format = None
error_msg = error.message error_msg = error.message
if error.validator == 'oneOf': if error.validator == 'oneOf':
msg_format = "Service '{}' configuration key {} {}" msg_format = "{path} {msg}"
error_msg = _parse_oneof_validator(error) config_key, error_msg = _parse_oneof_validator(error)
if config_key:
path.append(config_key)
elif error.validator == 'type': elif error.validator == 'type':
msg_format = ("Service '{}' configuration key {} contains an invalid " msg_format = "{path} contains an invalid type, it should be {msg}"
"type, it should be {}")
error_msg = _parse_valid_types_from_validator(error.validator_value) error_msg = _parse_valid_types_from_validator(error.validator_value)
# TODO: no test case for this branch, there are no config options # TODO: no test case for this branch, there are no config options
# which exercise this branch # which exercise this branch
elif error.validator == 'required': elif error.validator == 'required':
msg_format = "Service '{}' configuration key '{}' is invalid, {}" msg_format = "{path} is invalid, {msg}"
elif error.validator == 'dependencies': elif error.validator == 'dependencies':
msg_format = "Service '{}' configuration key '{}' is invalid: {}"
config_key = list(error.validator_value.keys())[0] config_key = list(error.validator_value.keys())[0]
required_keys = ",".join(error.validator_value[config_key]) required_keys = ",".join(error.validator_value[config_key])
msg_format = "{path} is invalid: {msg}"
path.append(config_key)
error_msg = "when defining '{}' you must set '{}' as well".format( error_msg = "when defining '{}' you must set '{}' as well".format(
config_key, config_key,
required_keys) required_keys)
elif error.cause: elif error.cause:
error_msg = six.text_type(error.cause) error_msg = six.text_type(error.cause)
msg_format = "Service '{}' configuration key {} is invalid: {}" msg_format = "{path} is invalid: {msg}"
elif error.path: elif error.path:
msg_format = "Service '{}' configuration key {} value {}" msg_format = "{path} value {msg}"
if msg_format: if msg_format:
return msg_format.format(service_name, config_key, error_msg) return msg_format.format(path=path_string(path), msg=error_msg)
return error.message return error.message
@ -279,6 +285,10 @@ def parse_key_from_error_msg(error):
return error.message.split("'")[1] return error.message.split("'")[1]
def path_string(path):
return ".".join(c for c in path if isinstance(c, six.string_types))
def _parse_valid_types_from_validator(validator): def _parse_valid_types_from_validator(validator):
"""A validator value can be either an array of valid types or a string of """A validator value can be either an array of valid types or a string of
a valid type. Parse the valid types and prefix with the correct article. a valid type. Parse the valid types and prefix with the correct article.
@ -304,52 +314,52 @@ def _parse_oneof_validator(error):
for context in error.context: for context in error.context:
if context.validator == 'required': if context.validator == 'required':
return context.message return (None, context.message)
if context.validator == 'additionalProperties': if context.validator == 'additionalProperties':
invalid_config_key = parse_key_from_error_msg(context) invalid_config_key = parse_key_from_error_msg(context)
return "contains unsupported option: '{}'".format(invalid_config_key) return (None, "contains unsupported option: '{}'".format(invalid_config_key))
if context.path: if context.path:
invalid_config_key = " ".join( return (
"'{}' ".format(fragment) for fragment in context.path path_string(context.path),
if isinstance(fragment, six.string_types) "contains {}, which is an invalid type, it should be {}".format(
json.dumps(context.instance),
_parse_valid_types_from_validator(context.validator_value)),
) )
return "{}contains {}, which is an invalid type, it should be {}".format(
invalid_config_key,
# Always print the json repr of the invalid value
json.dumps(context.instance),
_parse_valid_types_from_validator(context.validator_value))
if context.validator == 'uniqueItems': if context.validator == 'uniqueItems':
return "contains non unique items, please remove duplicates from {}".format( return (
context.instance) None,
"contains non unique items, please remove duplicates from {}".format(
context.instance),
)
if context.validator == 'type': if context.validator == 'type':
types.append(context.validator_value) types.append(context.validator_value)
valid_types = _parse_valid_types_from_validator(types) valid_types = _parse_valid_types_from_validator(types)
return "contains an invalid type, it should be {}".format(valid_types) return (None, "contains an invalid type, it should be {}".format(valid_types))
def process_errors(errors, service_name=None): def process_errors(errors, path_prefix=None):
"""jsonschema gives us an error tree full of information to explain what has """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 gone wrong. Process each error and pull out relevant information and re-write
helpful error messages that are relevant. helpful error messages that are relevant.
""" """
def format_error_message(error, service_name): path_prefix = path_prefix or []
if not service_name and error.path:
# field_schema errors will have service name on the path def format_error_message(error):
service_name = error.path.popleft() path = path_prefix + list(error.path)
if 'id' in error.schema: if 'id' in error.schema:
error_msg = handle_error_for_schema_with_id(error, service_name) error_msg = handle_error_for_schema_with_id(error, path)
if error_msg: if error_msg:
return error_msg return error_msg
return handle_generic_service_error(error, service_name) return handle_generic_service_error(error, path)
return '\n'.join(format_error_message(error, service_name) for error in errors) return '\n'.join(format_error_message(error) for error in errors)
def validate_against_fields_schema(config_file): def validate_against_fields_schema(config_file):
@ -366,14 +376,14 @@ def validate_against_service_schema(config, service_name, version):
config, config,
"service_schema_v{0}.json".format(version), "service_schema_v{0}.json".format(version),
format_checker=["ports"], format_checker=["ports"],
service_name=service_name) path_prefix=[service_name])
def _validate_against_schema( def _validate_against_schema(
config, config,
schema_filename, schema_filename,
format_checker=(), format_checker=(),
service_name=None, path_prefix=None,
filename=None): filename=None):
config_source_dir = os.path.dirname(os.path.abspath(__file__)) config_source_dir = os.path.dirname(os.path.abspath(__file__))
@ -399,7 +409,7 @@ def _validate_against_schema(
if not errors: if not errors:
return return
error_msg = process_errors(errors, service_name) error_msg = process_errors(errors, path_prefix=path_prefix)
file_msg = " in file '{}'".format(filename) if filename else '' file_msg = " in file '{}'".format(filename) if filename else ''
raise ConfigurationError("Validation failed{}, reason(s):\n{}".format( raise ConfigurationError("Validation failed{}, reason(s):\n{}".format(
file_msg, file_msg,

View File

@ -14,9 +14,11 @@ LABEL_PROJECT = 'com.docker.compose.project'
LABEL_SERVICE = 'com.docker.compose.service' LABEL_SERVICE = 'com.docker.compose.service'
LABEL_VERSION = 'com.docker.compose.version' LABEL_VERSION = 'com.docker.compose.version'
LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash'
COMPOSEFILE_VERSIONS = (1, 2)
COMPOSEFILE_V1 = '1'
COMPOSEFILE_V2_0 = '2.0'
API_VERSIONS = { API_VERSIONS = {
1: '1.21', COMPOSEFILE_V1: '1.21',
2: '1.22', COMPOSEFILE_V2_0: '1.22',
} }

View File

@ -10,6 +10,7 @@ from docker.errors import NotFound
from . import parallel from . import parallel
from .config import ConfigurationError from .config import ConfigurationError
from .config.config import V1
from .config.sort_services import get_container_name_from_network_mode from .config.sort_services import get_container_name_from_network_mode
from .config.sort_services import get_service_name_from_network_mode from .config.sort_services import get_service_name_from_network_mode
from .const import DEFAULT_TIMEOUT from .const import DEFAULT_TIMEOUT
@ -56,7 +57,7 @@ class Project(object):
""" """
Construct a Project from a config.Config object. Construct a Project from a config.Config object.
""" """
use_networking = (config_data.version and config_data.version >= 2) use_networking = (config_data.version and config_data.version != V1)
project = cls(name, [], client, use_networking=use_networking) project = cls(name, [], client, use_networking=use_networking)
network_config = config_data.networks or {} network_config = config_data.networks or {}
@ -94,7 +95,7 @@ class Project(object):
network_mode = project.get_network_mode(service_dict, networks) network_mode = project.get_network_mode(service_dict, networks)
volumes_from = get_volumes_from(project, service_dict) volumes_from = get_volumes_from(project, service_dict)
if config_data.version == 2: if config_data.version != V1:
service_volumes = service_dict.get('volumes', []) service_volumes = service_dict.get('volumes', [])
for volume_spec in service_volumes: for volume_spec in service_volumes:
if volume_spec.is_named_volume: if volume_spec.is_named_volume:

View File

@ -23,8 +23,8 @@ exe = EXE(pyz,
'DATA' 'DATA'
), ),
( (
'compose/config/fields_schema_v2.json', 'compose/config/fields_schema_v2.0.json',
'compose/config/fields_schema_v2.json', 'compose/config/fields_schema_v2.0.json',
'DATA' 'DATA'
), ),
( (
@ -33,8 +33,8 @@ exe = EXE(pyz,
'DATA' 'DATA'
), ),
( (
'compose/config/service_schema_v2.json', 'compose/config/service_schema_v2.0.json',
'compose/config/service_schema_v2.json', 'compose/config/service_schema_v2.0.json',
'DATA' 'DATA'
), ),
( (

View File

@ -177,7 +177,7 @@ class CLITestCase(DockerClientTestCase):
output = yaml.load(result.stdout) output = yaml.load(result.stdout)
expected = { expected = {
'version': 2, 'version': '2.0',
'volumes': {'data': {'driver': 'local'}}, 'volumes': {'data': {'driver': 'local'}},
'networks': {'front': {}}, 'networks': {'front': {}},
'services': { 'services': {

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
myweb: myweb:
build: '.' build: '.'

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
simple: simple:
image: busybox:latest image: busybox:latest

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
foo: foo:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
web: web:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
simple: simple:
image: busybox:latest image: busybox:latest

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
web: web:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
simple: simple:
image: busybox:latest image: busybox:latest

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
web: web:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
web: web:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
bridge: bridge:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
networks: networks:
foo: {} foo: {}

View File

@ -1,5 +1,5 @@
version: 2 version: "2"
services: services:
simple: simple:

View File

@ -1,5 +1,5 @@
version: 2 version: "2"
volumes: volumes:
data: data:

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
simple: simple:
image: busybox:latest image: busybox:latest

View File

@ -1,4 +1,4 @@
version: 2 version: "2"
services: services:
simple: simple:
image: busybox:latest image: busybox:latest

View File

@ -10,6 +10,7 @@ from docker.errors import NotFound
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
from compose.config import config from compose.config import config
from compose.config import ConfigurationError from compose.config import ConfigurationError
from compose.config.config import V2_0
from compose.config.types import VolumeFromSpec from compose.config.types import VolumeFromSpec
from compose.config.types import VolumeSpec from compose.config.types import VolumeSpec
from compose.const import LABEL_PROJECT from compose.const import LABEL_PROJECT
@ -112,7 +113,7 @@ class ProjectTest(DockerClientTestCase):
name='composetest', name='composetest',
client=self.client, client=self.client,
config_data=build_service_dicts({ config_data=build_service_dicts({
'version': 2, 'version': V2_0,
'services': { 'services': {
'net': { 'net': {
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -139,7 +140,7 @@ class ProjectTest(DockerClientTestCase):
return Project.from_config( return Project.from_config(
name='composetest', name='composetest',
config_data=build_service_dicts({ config_data=build_service_dicts({
'version': 2, 'version': V2_0,
'services': { 'services': {
'web': { 'web': {
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -559,7 +560,7 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_project_up_networks(self): def test_project_up_networks(self):
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -592,7 +593,7 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_up_with_ipam_config(self): def test_up_with_ipam_config(self):
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[], services=[],
volumes={}, volumes={},
networks={ networks={
@ -651,7 +652,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -677,7 +678,7 @@ class ProjectTest(DockerClientTestCase):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yml', 'base.yml',
{ {
'version': 2, 'version': V2_0,
'services': { 'services': {
'simple': {'image': 'busybox:latest', 'command': 'top'}, 'simple': {'image': 'busybox:latest', 'command': 'top'},
'another': { 'another': {
@ -696,7 +697,7 @@ class ProjectTest(DockerClientTestCase):
override_file = config.ConfigFile( override_file = config.ConfigFile(
'override.yml', 'override.yml',
{ {
'version': 2, 'version': V2_0,
'services': { 'services': {
'another': { 'another': {
'logging': { 'logging': {
@ -729,7 +730,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -754,7 +755,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -779,7 +780,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -802,7 +803,7 @@ class ProjectTest(DockerClientTestCase):
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -841,7 +842,7 @@ class ProjectTest(DockerClientTestCase):
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
self.client.create_volume(vol_name) self.client.create_volume(vol_name)
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -866,7 +867,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
config_data = config.Config( config_data = config.Config(
version=2, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -895,7 +896,7 @@ class ProjectTest(DockerClientTestCase):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yml', 'base.yml',
{ {
'version': 2, 'version': V2_0,
'services': { 'services': {
'simple': { 'simple': {
'image': 'busybox:latest', 'image': 'busybox:latest',

View File

@ -10,6 +10,8 @@ from pytest import skip
from .. import unittest from .. import unittest
from compose.cli.docker_client import docker_client from compose.cli.docker_client import docker_client
from compose.config.config import resolve_environment from compose.config.config import resolve_environment
from compose.config.config import V1
from compose.config.config import V2_0
from compose.const import API_VERSIONS from compose.const import API_VERSIONS
from compose.const import LABEL_PROJECT from compose.const import LABEL_PROJECT
from compose.progress_stream import stream_output from compose.progress_stream import stream_output
@ -54,9 +56,9 @@ class DockerClientTestCase(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
if engine_version_too_low_for_v2(): if engine_version_too_low_for_v2():
version = API_VERSIONS[1] version = API_VERSIONS[V1]
else: else:
version = API_VERSIONS[2] version = API_VERSIONS[V2_0]
cls.client = docker_client(version) cls.client = docker_client(version)

View File

@ -14,14 +14,16 @@ import pytest
from compose.config import config from compose.config import config
from compose.config.config import resolve_build_args from compose.config.config import resolve_build_args
from compose.config.config import resolve_environment from compose.config.config import resolve_environment
from compose.config.config import V1
from compose.config.config import V2_0
from compose.config.errors import ConfigurationError from compose.config.errors import ConfigurationError
from compose.config.errors import VERSION_EXPLANATION
from compose.config.types import VolumeSpec from compose.config.types import VolumeSpec
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
from tests import mock from tests import mock
from tests import unittest from tests import unittest
DEFAULT_VERSION = V2 = 2 DEFAULT_VERSION = V2_0
V1 = 1
def make_service_dict(name, service_dict, working_dir, filename=None): def make_service_dict(name, service_dict, working_dir, filename=None):
@ -78,7 +80,7 @@ class ConfigTest(unittest.TestCase):
def test_load_v2(self): def test_load_v2(self):
config_data = config.load( config_data = config.load(
build_config_details({ build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'foo': {'image': 'busybox'}, 'foo': {'image': 'busybox'},
'bar': {'image': 'busybox', 'environment': ['FOO=1']}, 'bar': {'image': 'busybox', 'environment': ['FOO=1']},
@ -143,9 +145,78 @@ class ConfigTest(unittest.TestCase):
} }
}) })
def test_valid_versions(self):
for version in ['2', '2.0']:
cfg = config.load(build_config_details({'version': version}))
assert cfg.version == V2_0
def test_v1_file_version(self):
cfg = config.load(build_config_details({'web': {'image': 'busybox'}}))
assert cfg.version == V1
assert list(s['name'] for s in cfg.services) == ['web']
cfg = config.load(build_config_details({'version': {'image': 'busybox'}}))
assert cfg.version == V1
assert list(s['name'] for s in cfg.services) == ['version']
def test_wrong_version_type(self):
for version in [None, 1, 2, 2.0]:
with pytest.raises(ConfigurationError) as excinfo:
config.load(
build_config_details(
{'version': version},
filename='filename.yml',
)
)
assert 'Version in "filename.yml" is invalid - it should be a string.' \
in excinfo.exconly()
def test_unsupported_version(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load(
build_config_details(
{'version': '2.1'},
filename='filename.yml',
)
)
assert 'Version in "filename.yml" is unsupported' in excinfo.exconly()
assert VERSION_EXPLANATION in excinfo.exconly()
def test_version_1_is_invalid(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load(
build_config_details(
{
'version': '1',
'web': {'image': 'busybox'},
},
filename='filename.yml',
)
)
assert 'Version in "filename.yml" is invalid' in excinfo.exconly()
assert VERSION_EXPLANATION in excinfo.exconly()
def test_v1_file_with_version_is_invalid(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load(
build_config_details(
{
'version': '2',
'web': {'image': 'busybox'},
},
filename='filename.yml',
)
)
assert 'Additional properties are not allowed' in excinfo.exconly()
assert VERSION_EXPLANATION in excinfo.exconly()
def test_named_volume_config_empty(self): def test_named_volume_config_empty(self):
config_details = build_config_details({ config_details = build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'simple': {'image': 'busybox'} 'simple': {'image': 'busybox'}
}, },
@ -161,13 +232,18 @@ class ConfigTest(unittest.TestCase):
assert volumes['other'] == {} assert volumes['other'] == {}
def test_load_service_with_name_version(self): def test_load_service_with_name_version(self):
config_data = config.load( with mock.patch('compose.config.config.log') as mock_logging:
build_config_details({ config_data = config.load(
'version': { build_config_details({
'image': 'busybox' 'version': {
} 'image': 'busybox'
}, 'working_dir', 'filename.yml') }
) }, 'working_dir', 'filename.yml')
)
assert 'Unexpected type for "version" key in "filename.yml"' \
in mock_logging.warn.call_args[0][0]
service_dicts = config_data.services service_dicts = config_data.services
self.assertEqual( self.assertEqual(
service_sort(service_dicts), service_sort(service_dicts),
@ -179,27 +255,6 @@ class ConfigTest(unittest.TestCase):
]) ])
) )
def test_load_invalid_version(self):
with self.assertRaises(ConfigurationError):
config.load(
build_config_details({
'version': 18,
'services': {
'foo': {'image': 'busybox'}
}
}, 'working_dir', 'filename.yml')
)
with self.assertRaises(ConfigurationError):
config.load(
build_config_details({
'version': 'two point oh',
'services': {
'foo': {'image': 'busybox'}
}
}, 'working_dir', 'filename.yml')
)
def test_load_throws_error_when_not_dict(self): def test_load_throws_error_when_not_dict(self):
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
config.load( config.load(
@ -214,7 +269,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
config.load( config.load(
build_config_details( build_config_details(
{'version': 2, 'services': {'web': 'busybox:latest'}}, {'version': '2', 'services': {'web': 'busybox:latest'}},
'working_dir', 'working_dir',
'filename.yml' 'filename.yml'
) )
@ -224,7 +279,7 @@ class ConfigTest(unittest.TestCase):
with self.assertRaises(ConfigurationError): with self.assertRaises(ConfigurationError):
config.load( config.load(
build_config_details({ build_config_details({
'version': 2, 'version': '2',
'services': {'web': 'busybox:latest'}, 'services': {'web': 'busybox:latest'},
'networks': { 'networks': {
'invalid': {'foo', 'bar'} 'invalid': {'foo', 'bar'}
@ -246,22 +301,38 @@ class ConfigTest(unittest.TestCase):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
config.load( config.load(
build_config_details({ build_config_details({
'version': 2, 'version': '2',
'services': {invalid_name: {'image': 'busybox'}} 'services': {invalid_name: {'image': 'busybox'}}
}, 'working_dir', 'filename.yml') }, 'working_dir', 'filename.yml')
) )
assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly()
def test_load_with_invalid_field_name(self): def test_load_with_invalid_field_name(self):
config_details = build_config_details(
{'web': {'image': 'busybox', 'name': 'bogus'}},
'working_dir',
'filename.yml')
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
config.load(config_details) config.load(build_config_details(
error_msg = "Unsupported config option for 'web' service: 'name'" {
assert error_msg in exc.exconly() 'version': '2',
assert "Validation failed in file 'filename.yml'" in exc.exconly() 'services': {
'web': {'image': 'busybox', 'name': 'bogus'},
}
},
'working_dir',
'filename.yml',
))
assert "Unsupported config option for services.web: 'name'" in exc.exconly()
def test_load_with_invalid_field_name_v1(self):
with pytest.raises(ConfigurationError) as exc:
config.load(build_config_details(
{
'web': {'image': 'busybox', 'name': 'bogus'},
},
'working_dir',
'filename.yml',
))
assert "Unsupported config option for web: 'name'" in exc.exconly()
def test_load_invalid_service_definition(self): def test_load_invalid_service_definition(self):
config_details = build_config_details( config_details = build_config_details(
@ -274,9 +345,7 @@ class ConfigTest(unittest.TestCase):
assert error_msg in exc.exconly() assert error_msg in exc.exconly()
def test_config_integer_service_name_raise_validation_error(self): def test_config_integer_service_name_raise_validation_error(self):
expected_error_msg = ("In file 'filename.yml' service name: 1 needs to " with pytest.raises(ConfigurationError) as excinfo:
"be a string, eg '1'")
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{1: {'image': 'busybox'}}, {1: {'image': 'busybox'}},
@ -285,15 +354,15 @@ class ConfigTest(unittest.TestCase):
) )
) )
def test_config_integer_service_name_raise_validation_error_v2(self): assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \
expected_error_msg = ("In file 'filename.yml' service name: 1 needs to " in excinfo.exconly()
"be a string, eg '1'")
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): def test_config_integer_service_name_raise_validation_error_v2(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load( config.load(
build_config_details( build_config_details(
{ {
'version': 2, 'version': '2',
'services': {1: {'image': 'busybox'}} 'services': {1: {'image': 'busybox'}}
}, },
'working_dir', 'working_dir',
@ -301,6 +370,9 @@ class ConfigTest(unittest.TestCase):
) )
) )
assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \
in excinfo.exconly()
def test_load_with_multiple_files_v1(self): def test_load_with_multiple_files_v1(self):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yaml', 'base.yaml',
@ -353,7 +425,7 @@ class ConfigTest(unittest.TestCase):
def test_load_with_multiple_files_and_empty_override_v2(self): def test_load_with_multiple_files_and_empty_override_v2(self):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yml', 'base.yml',
{'version': 2, 'services': {'web': {'image': 'example/web'}}}) {'version': '2', 'services': {'web': {'image': 'example/web'}}})
override_file = config.ConfigFile('override.yml', None) override_file = config.ConfigFile('override.yml', None)
details = config.ConfigDetails('.', [base_file, override_file]) details = config.ConfigDetails('.', [base_file, override_file])
@ -377,7 +449,7 @@ class ConfigTest(unittest.TestCase):
base_file = config.ConfigFile('base.yml', None) base_file = config.ConfigFile('base.yml', None)
override_file = config.ConfigFile( override_file = config.ConfigFile(
'override.tml', 'override.tml',
{'version': 2, 'services': {'web': {'image': 'example/web'}}} {'version': '2', 'services': {'web': {'image': 'example/web'}}}
) )
details = config.ConfigDetails('.', [base_file, override_file]) details = config.ConfigDetails('.', [base_file, override_file])
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
@ -477,7 +549,7 @@ class ConfigTest(unittest.TestCase):
config.load( config.load(
build_config_details( build_config_details(
{ {
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'build': '.', 'build': '.',
@ -492,7 +564,7 @@ class ConfigTest(unittest.TestCase):
service = config.load( service = config.load(
build_config_details({ build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'build': '.' 'build': '.'
@ -505,7 +577,7 @@ class ConfigTest(unittest.TestCase):
service = config.load( service = config.load(
build_config_details( build_config_details(
{ {
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'build': { 'build': {
@ -526,7 +598,7 @@ class ConfigTest(unittest.TestCase):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yaml', 'base.yaml',
{ {
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'example/web', 'image': 'example/web',
@ -539,7 +611,7 @@ class ConfigTest(unittest.TestCase):
override_file = config.ConfigFile( override_file = config.ConfigFile(
'override.yaml', 'override.yaml',
{ {
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'build': '/', 'build': '/',
@ -568,7 +640,7 @@ class ConfigTest(unittest.TestCase):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yaml', 'base.yaml',
{ {
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -584,7 +656,7 @@ class ConfigTest(unittest.TestCase):
base_file = config.ConfigFile( base_file = config.ConfigFile(
'base.yaml', 'base.yaml',
{ {
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox:latest', 'image': 'busybox:latest',
@ -624,8 +696,7 @@ class ConfigTest(unittest.TestCase):
assert services[0]['name'] == valid_name assert services[0]['name'] == valid_name
def test_config_hint(self): def test_config_hint(self):
expected_error_msg = "(did you mean 'privileged'?)" with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -636,6 +707,8 @@ class ConfigTest(unittest.TestCase):
) )
) )
assert "(did you mean 'privileged'?)" in excinfo.exconly()
def test_load_errors_on_uppercase_with_no_image(self): def test_load_errors_on_uppercase_with_no_image(self):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
config.load(build_config_details({ config.load(build_config_details({
@ -643,9 +716,41 @@ class ConfigTest(unittest.TestCase):
}, 'tests/fixtures/build-ctx')) }, 'tests/fixtures/build-ctx'))
assert "Service 'Foo' contains uppercase characters" in exc.exconly() assert "Service 'Foo' contains uppercase characters" in exc.exconly()
def test_invalid_config_build_and_image_specified(self): def test_invalid_config_v1(self):
expected_error_msg = "Service 'foo' has both an image and build path specified." with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load(
build_config_details(
{
'foo': {'image': 1},
},
'tests/fixtures/extends',
'filename.yml'
)
)
assert "foo.image contains an invalid type, it should be a string" \
in excinfo.exconly()
def test_invalid_config_v2(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load(
build_config_details(
{
'version': '2',
'services': {
'foo': {'image': 1},
},
},
'tests/fixtures/extends',
'filename.yml'
)
)
assert "services.foo.image contains an invalid type, it should be a string" \
in excinfo.exconly()
def test_invalid_config_build_and_image_specified_v1(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -656,9 +761,10 @@ class ConfigTest(unittest.TestCase):
) )
) )
assert "foo has both an image and build path specified." in excinfo.exconly()
def test_invalid_config_type_should_be_an_array(self): def test_invalid_config_type_should_be_an_array(self):
expected_error_msg = "Service 'foo' configuration key 'links' contains an invalid type, it should be an array" with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -669,10 +775,11 @@ class ConfigTest(unittest.TestCase):
) )
) )
assert "foo.links contains an invalid type, it should be an array" \
in excinfo.exconly()
def test_invalid_config_not_a_dictionary(self): def test_invalid_config_not_a_dictionary(self):
expected_error_msg = ("Top level object in 'filename.yml' needs to be " with pytest.raises(ConfigurationError) as excinfo:
"an object.")
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
['foo', 'lol'], ['foo', 'lol'],
@ -681,9 +788,11 @@ class ConfigTest(unittest.TestCase):
) )
) )
assert "Top level object in 'filename.yml' needs to be an object" \
in excinfo.exconly()
def test_invalid_config_not_unique_items(self): def test_invalid_config_not_unique_items(self):
expected_error_msg = "has non-unique elements" with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -694,10 +803,10 @@ class ConfigTest(unittest.TestCase):
) )
) )
assert "has non-unique elements" in excinfo.exconly()
def test_invalid_list_of_strings_format(self): def test_invalid_list_of_strings_format(self):
expected_error_msg = "Service 'web' configuration key 'command' contains 1" with pytest.raises(ConfigurationError) as excinfo:
expected_error_msg += ", which is an invalid type, it should be a string"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -708,7 +817,10 @@ class ConfigTest(unittest.TestCase):
) )
) )
def test_load_config_dockerfile_without_build_raises_error(self): assert "web.command contains 1, which is an invalid type, it should be a string" \
in excinfo.exconly()
def test_load_config_dockerfile_without_build_raises_error_v1(self):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
config.load(build_config_details({ config.load(build_config_details({
'web': { 'web': {
@ -716,12 +828,11 @@ class ConfigTest(unittest.TestCase):
'dockerfile': 'Dockerfile.alt' 'dockerfile': 'Dockerfile.alt'
} }
})) }))
assert "Service 'web' has both an image and alternate Dockerfile." in exc.exconly()
assert "web has both an image and alternate Dockerfile." in exc.exconly()
def test_config_extra_hosts_string_raises_validation_error(self): def test_config_extra_hosts_string_raises_validation_error(self):
expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type" with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{'web': { {'web': {
@ -733,12 +844,11 @@ class ConfigTest(unittest.TestCase):
) )
) )
def test_config_extra_hosts_list_of_dicts_validation_error(self): assert "web.extra_hosts contains an invalid type" \
expected_error_msg = ( in excinfo.exconly()
"key 'extra_hosts' contains {\"somehost\": \"162.242.195.82\"}, "
"which is an invalid type, it should be a string")
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): def test_config_extra_hosts_list_of_dicts_validation_error(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load( config.load(
build_config_details( build_config_details(
{'web': { {'web': {
@ -753,10 +863,11 @@ class ConfigTest(unittest.TestCase):
) )
) )
def test_config_ulimits_invalid_keys_validation_error(self): assert "web.extra_hosts contains {\"somehost\": \"162.242.195.82\"}, " \
expected = ("Service 'web' configuration key 'ulimits' 'nofile' contains " "which is an invalid type, it should be a string" \
"unsupported option: 'not_soft_or_hard'") in excinfo.exconly()
def test_config_ulimits_invalid_keys_validation_error(self):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
config.load(build_config_details( config.load(build_config_details(
{ {
@ -773,10 +884,11 @@ class ConfigTest(unittest.TestCase):
}, },
'working_dir', 'working_dir',
'filename.yml')) 'filename.yml'))
assert expected in exc.exconly()
assert "web.ulimits.nofile contains unsupported option: 'not_soft_or_hard'" \
in exc.exconly()
def test_config_ulimits_required_keys_validation_error(self): def test_config_ulimits_required_keys_validation_error(self):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
config.load(build_config_details( config.load(build_config_details(
{ {
@ -787,7 +899,7 @@ class ConfigTest(unittest.TestCase):
}, },
'working_dir', 'working_dir',
'filename.yml')) 'filename.yml'))
assert "Service 'web' configuration key 'ulimits' 'nofile'" in exc.exconly() assert "web.ulimits.nofile" in exc.exconly()
assert "'hard' is a required property" in exc.exconly() assert "'hard' is a required property" in exc.exconly()
def test_config_ulimits_soft_greater_than_hard_error(self): def test_config_ulimits_soft_greater_than_hard_error(self):
@ -888,7 +1000,7 @@ class ConfigTest(unittest.TestCase):
'extra_hosts': "www.example.com: 192.168.0.17", 'extra_hosts': "www.example.com: 192.168.0.17",
} }
})) }))
assert "'extra_hosts' contains an invalid type" in exc.exconly() assert "web.extra_hosts contains an invalid type" in exc.exconly()
def test_validate_extra_hosts_invalid_list(self): def test_validate_extra_hosts_invalid_list(self):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
@ -959,7 +1071,7 @@ class ConfigTest(unittest.TestCase):
def test_external_volume_config(self): def test_external_volume_config(self):
config_details = build_config_details({ config_details = build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'bogus': {'image': 'busybox'} 'bogus': {'image': 'busybox'}
}, },
@ -977,7 +1089,7 @@ class ConfigTest(unittest.TestCase):
def test_external_volume_invalid_config(self): def test_external_volume_invalid_config(self):
config_details = build_config_details({ config_details = build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'bogus': {'image': 'busybox'} 'bogus': {'image': 'busybox'}
}, },
@ -990,7 +1102,7 @@ class ConfigTest(unittest.TestCase):
def test_depends_on_orders_services(self): def test_depends_on_orders_services(self):
config_details = build_config_details({ config_details = build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'one': {'image': 'busybox', 'depends_on': ['three', 'two']}, 'one': {'image': 'busybox', 'depends_on': ['three', 'two']},
'two': {'image': 'busybox', 'depends_on': ['three']}, 'two': {'image': 'busybox', 'depends_on': ['three']},
@ -1005,7 +1117,7 @@ class ConfigTest(unittest.TestCase):
def test_depends_on_unknown_service_errors(self): def test_depends_on_unknown_service_errors(self):
config_details = build_config_details({ config_details = build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'one': {'image': 'busybox', 'depends_on': ['three']}, 'one': {'image': 'busybox', 'depends_on': ['three']},
}, },
@ -1018,7 +1130,7 @@ class ConfigTest(unittest.TestCase):
class NetworkModeTest(unittest.TestCase): class NetworkModeTest(unittest.TestCase):
def test_network_mode_standard(self): def test_network_mode_standard(self):
config_data = config.load(build_config_details({ config_data = config.load(build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -1044,7 +1156,7 @@ class NetworkModeTest(unittest.TestCase):
def test_network_mode_container(self): def test_network_mode_container(self):
config_data = config.load(build_config_details({ config_data = config.load(build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -1069,7 +1181,7 @@ class NetworkModeTest(unittest.TestCase):
def test_network_mode_service(self): def test_network_mode_service(self):
config_data = config.load(build_config_details({ config_data = config.load(build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -1103,7 +1215,7 @@ class NetworkModeTest(unittest.TestCase):
def test_network_mode_service_nonexistent(self): def test_network_mode_service_nonexistent(self):
with pytest.raises(ConfigurationError) as excinfo: with pytest.raises(ConfigurationError) as excinfo:
config.load(build_config_details({ config.load(build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -1118,7 +1230,7 @@ class NetworkModeTest(unittest.TestCase):
def test_network_mode_plus_networks_is_invalid(self): def test_network_mode_plus_networks_is_invalid(self):
with pytest.raises(ConfigurationError) as excinfo: with pytest.raises(ConfigurationError) as excinfo:
config.load(build_config_details({ config.load(build_config_details({
'version': 2, 'version': '2',
'services': { 'services': {
'web': { 'web': {
'image': 'busybox', 'image': 'busybox',
@ -1574,11 +1686,7 @@ class MemoryOptionsTest(unittest.TestCase):
When you set a 'memswap_limit' it is invalid config unless you also set When you set a 'memswap_limit' it is invalid config unless you also set
a mem_limit a mem_limit
""" """
expected_error_msg = ( with pytest.raises(ConfigurationError) as excinfo:
"Service 'foo' configuration key 'memswap_limit' is invalid: when "
"defining 'memswap_limit' you must set 'mem_limit' as well"
)
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -1589,6 +1697,10 @@ class MemoryOptionsTest(unittest.TestCase):
) )
) )
assert "foo.memswap_limit is invalid: when defining " \
"'memswap_limit' you must set 'mem_limit' as well" \
in excinfo.exconly()
def test_validation_with_correct_memswap_values(self): def test_validation_with_correct_memswap_values(self):
service_dict = config.load( service_dict = config.load(
build_config_details( build_config_details(
@ -1851,7 +1963,7 @@ class ExtendsTest(unittest.TestCase):
self.assertEqual(path, expected) self.assertEqual(path, expected)
def test_extends_validation_empty_dictionary(self): def test_extends_validation_empty_dictionary(self):
with self.assertRaisesRegexp(ConfigurationError, 'service'): with pytest.raises(ConfigurationError) as excinfo:
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -1862,8 +1974,10 @@ class ExtendsTest(unittest.TestCase):
) )
) )
assert 'service' in excinfo.exconly()
def test_extends_validation_missing_service_key(self): def test_extends_validation_missing_service_key(self):
with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"): with pytest.raises(ConfigurationError) as excinfo:
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -1874,12 +1988,10 @@ class ExtendsTest(unittest.TestCase):
) )
) )
assert "'service' is a required property" in excinfo.exconly()
def test_extends_validation_invalid_key(self): def test_extends_validation_invalid_key(self):
expected_error_msg = ( with pytest.raises(ConfigurationError) as excinfo:
"Service 'web' configuration key 'extends' "
"contains unsupported option: 'rogue_key'"
)
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -1897,12 +2009,11 @@ class ExtendsTest(unittest.TestCase):
) )
) )
assert "web.extends contains unsupported option: 'rogue_key'" \
in excinfo.exconly()
def test_extends_validation_sub_property_key(self): def test_extends_validation_sub_property_key(self):
expected_error_msg = ( with pytest.raises(ConfigurationError) as excinfo:
"Service 'web' configuration key 'extends' 'file' contains 1, "
"which is an invalid type, it should be a string"
)
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
config.load( config.load(
build_config_details( build_config_details(
{ {
@ -1919,13 +2030,16 @@ class ExtendsTest(unittest.TestCase):
) )
) )
assert "web.extends.file contains 1, which is an invalid type, it should be a string" \
in excinfo.exconly()
def test_extends_validation_no_file_key_no_filename_set(self): def test_extends_validation_no_file_key_no_filename_set(self):
dictionary = {'extends': {'service': 'web'}} dictionary = {'extends': {'service': 'web'}}
def load_config(): with pytest.raises(ConfigurationError) as excinfo:
return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends')
self.assertRaisesRegexp(ConfigurationError, 'file', load_config) assert 'file' in excinfo.exconly()
def test_extends_validation_valid_config(self): def test_extends_validation_valid_config(self):
service = config.load( service = config.load(
@ -1946,7 +2060,7 @@ class ExtendsTest(unittest.TestCase):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml')
assert ( assert (
"Service 'myweb' has neither an image nor a build path specified" in "myweb has neither an image nor a build path specified" in
exc.exconly() exc.exconly()
) )
@ -1979,16 +2093,17 @@ class ExtendsTest(unittest.TestCase):
])) ]))
def test_invalid_links_in_extended_service(self): def test_invalid_links_in_extended_service(self):
expected_error_msg = "services with 'links' cannot be extended" with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
load_from_filename('tests/fixtures/extends/invalid-links.yml') load_from_filename('tests/fixtures/extends/invalid-links.yml')
def test_invalid_volumes_from_in_extended_service(self): assert "services with 'links' cannot be extended" in excinfo.exconly()
expected_error_msg = "services with 'volumes_from' cannot be extended"
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): def test_invalid_volumes_from_in_extended_service(self):
with pytest.raises(ConfigurationError) as excinfo:
load_from_filename('tests/fixtures/extends/invalid-volumes.yml') load_from_filename('tests/fixtures/extends/invalid-volumes.yml')
assert "services with 'volumes_from' cannot be extended" in excinfo.exconly()
def test_invalid_net_in_extended_service(self): def test_invalid_net_in_extended_service(self):
with pytest.raises(ConfigurationError) as excinfo: with pytest.raises(ConfigurationError) as excinfo:
load_from_filename('tests/fixtures/extends/invalid-net-v2.yml') load_from_filename('tests/fixtures/extends/invalid-net-v2.yml')
@ -2044,10 +2159,12 @@ class ExtendsTest(unittest.TestCase):
]) ])
def test_load_throws_error_when_base_service_does_not_exist(self): def test_load_throws_error_when_base_service_does_not_exist(self):
err_msg = r'''Cannot extend service 'foo' in .*: Service not found''' with pytest.raises(ConfigurationError) as excinfo:
with self.assertRaisesRegexp(ConfigurationError, err_msg):
load_from_filename('tests/fixtures/extends/nonexistent-service.yml') load_from_filename('tests/fixtures/extends/nonexistent-service.yml')
assert "Cannot extend service 'foo'" in excinfo.exconly()
assert "Service not found" in excinfo.exconly()
def test_partial_service_config_in_extends_is_still_valid(self): def test_partial_service_config_in_extends_is_still_valid(self):
dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml') dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml')
self.assertEqual(dicts[0]['environment'], {'FOO': '1'}) self.assertEqual(dicts[0]['environment'], {'FOO': '1'})
@ -2140,7 +2257,7 @@ class ExtendsTest(unittest.TestCase):
tmpdir = py.test.ensuretemp('test_extends_with_mixed_version') tmpdir = py.test.ensuretemp('test_extends_with_mixed_version')
self.addCleanup(tmpdir.remove) self.addCleanup(tmpdir.remove)
tmpdir.join('docker-compose.yml').write(""" tmpdir.join('docker-compose.yml').write("""
version: 2 version: "2"
services: services:
web: web:
extends: extends:
@ -2162,7 +2279,7 @@ class ExtendsTest(unittest.TestCase):
tmpdir = py.test.ensuretemp('test_extends_with_defined_version') tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
self.addCleanup(tmpdir.remove) self.addCleanup(tmpdir.remove)
tmpdir.join('docker-compose.yml').write(""" tmpdir.join('docker-compose.yml').write("""
version: 2 version: "2"
services: services:
web: web:
extends: extends:
@ -2171,7 +2288,7 @@ class ExtendsTest(unittest.TestCase):
image: busybox image: busybox
""") """)
tmpdir.join('base.yml').write(""" tmpdir.join('base.yml').write("""
version: 2 version: "2"
services: services:
base: base:
volumes: ['/foo'] volumes: ['/foo']

View File

@ -3,13 +3,13 @@ from __future__ import unicode_literals
import pytest import pytest
from compose.config.config import V1
from compose.config.config import V2_0
from compose.config.errors import ConfigurationError from compose.config.errors import ConfigurationError
from compose.config.types import parse_extra_hosts from compose.config.types import parse_extra_hosts
from compose.config.types import VolumeFromSpec from compose.config.types import VolumeFromSpec
from compose.config.types import VolumeSpec from compose.config.types import VolumeSpec
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
from tests.unit.config.config_test import V1
from tests.unit.config.config_test import V2
def test_parse_extra_hosts_list(): def test_parse_extra_hosts_list():
@ -91,26 +91,26 @@ class TestVolumesFromSpec(object):
VolumeFromSpec.parse('unknown:format:ro', self.services, V1) VolumeFromSpec.parse('unknown:format:ro', self.services, V1)
def test_parse_v2_from_service(self): def test_parse_v2_from_service(self):
volume_from = VolumeFromSpec.parse('servicea', self.services, V2) volume_from = VolumeFromSpec.parse('servicea', self.services, V2_0)
assert volume_from == VolumeFromSpec('servicea', 'rw', 'service') assert volume_from == VolumeFromSpec('servicea', 'rw', 'service')
def test_parse_v2_from_service_with_mode(self): def test_parse_v2_from_service_with_mode(self):
volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2) volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2_0)
assert volume_from == VolumeFromSpec('servicea', 'ro', 'service') assert volume_from == VolumeFromSpec('servicea', 'ro', 'service')
def test_parse_v2_from_container(self): def test_parse_v2_from_container(self):
volume_from = VolumeFromSpec.parse('container:foo', self.services, V2) volume_from = VolumeFromSpec.parse('container:foo', self.services, V2_0)
assert volume_from == VolumeFromSpec('foo', 'rw', 'container') assert volume_from == VolumeFromSpec('foo', 'rw', 'container')
def test_parse_v2_from_container_with_mode(self): def test_parse_v2_from_container_with_mode(self):
volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2) volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2_0)
assert volume_from == VolumeFromSpec('foo', 'ro', 'container') assert volume_from == VolumeFromSpec('foo', 'ro', 'container')
def test_parse_v2_invalid_type(self): def test_parse_v2_invalid_type(self):
with pytest.raises(ConfigurationError) as exc: with pytest.raises(ConfigurationError) as exc:
VolumeFromSpec.parse('bogus:foo:ro', self.services, V2) VolumeFromSpec.parse('bogus:foo:ro', self.services, V2_0)
assert "Unknown volumes_from type 'bogus'" in exc.exconly() assert "Unknown volumes_from type 'bogus'" in exc.exconly()
def test_parse_v2_invalid(self): def test_parse_v2_invalid(self):
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
VolumeFromSpec.parse('unknown:format:ro', self.services, V2) VolumeFromSpec.parse('unknown:format:ro', self.services, V2_0)