mirror of
https://github.com/docker/compose.git
synced 2025-07-25 14:44:29 +02:00
Merge pull request #2405 from dnephin/move_all_validation_to_config
Move all validation to config
This commit is contained in:
commit
cfb1b37da2
@ -14,7 +14,6 @@ from . import errors
|
|||||||
from . import verbose_proxy
|
from . import verbose_proxy
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..project import Project
|
from ..project import Project
|
||||||
from ..service import ConfigError
|
|
||||||
from .docker_client import docker_client
|
from .docker_client import docker_client
|
||||||
from .utils import call_silently
|
from .utils import call_silently
|
||||||
from .utils import get_version_info
|
from .utils import get_version_info
|
||||||
@ -84,16 +83,12 @@ def get_project(base_dir, config_path=None, project_name=None, verbose=False,
|
|||||||
config_details = config.find(base_dir, config_path)
|
config_details = config.find(base_dir, config_path)
|
||||||
|
|
||||||
api_version = '1.21' if use_networking else None
|
api_version = '1.21' if use_networking else None
|
||||||
try:
|
|
||||||
return Project.from_dicts(
|
return Project.from_dicts(
|
||||||
get_project_name(config_details.working_dir, project_name),
|
get_project_name(config_details.working_dir, project_name),
|
||||||
config.load(config_details),
|
config.load(config_details),
|
||||||
get_client(verbose=verbose, version=api_version),
|
get_client(verbose=verbose, version=api_version),
|
||||||
use_networking=use_networking,
|
use_networking=use_networking,
|
||||||
network_driver=network_driver,
|
network_driver=network_driver)
|
||||||
)
|
|
||||||
except ConfigError as e:
|
|
||||||
raise errors.UserError(six.text_type(e))
|
|
||||||
|
|
||||||
|
|
||||||
def get_project_name(working_dir, project_name=None):
|
def get_project_name(working_dir, project_name=None):
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
from .config import ConfigurationError
|
from .config import ConfigurationError
|
||||||
from .config import DOCKER_CONFIG_KEYS
|
from .config import DOCKER_CONFIG_KEYS
|
||||||
from .config import find
|
from .config import find
|
||||||
from .config import get_service_name_from_net
|
|
||||||
from .config import load
|
from .config import load
|
||||||
from .config import merge_environment
|
from .config import merge_environment
|
||||||
from .config import parse_environment
|
from .config import parse_environment
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
import codecs
|
import codecs
|
||||||
import logging
|
import logging
|
||||||
import operator
|
import operator
|
||||||
@ -12,6 +14,12 @@ from .errors import CircularReference
|
|||||||
from .errors import ComposeFileNotFound
|
from .errors import ComposeFileNotFound
|
||||||
from .errors import ConfigurationError
|
from .errors import ConfigurationError
|
||||||
from .interpolation import interpolate_environment_variables
|
from .interpolation import interpolate_environment_variables
|
||||||
|
from .sort_services import get_service_name_from_net
|
||||||
|
from .sort_services import sort_service_dicts
|
||||||
|
from .types import parse_extra_hosts
|
||||||
|
from .types import parse_restart_spec
|
||||||
|
from .types import VolumeFromSpec
|
||||||
|
from .types import VolumeSpec
|
||||||
from .validation import validate_against_fields_schema
|
from .validation import validate_against_fields_schema
|
||||||
from .validation import validate_against_service_schema
|
from .validation import validate_against_service_schema
|
||||||
from .validation import validate_extends_file_path
|
from .validation import validate_extends_file_path
|
||||||
@ -198,16 +206,20 @@ def load(config_details):
|
|||||||
service_dict)
|
service_dict)
|
||||||
resolver = ServiceExtendsResolver(service_config)
|
resolver = ServiceExtendsResolver(service_config)
|
||||||
service_dict = process_service(resolver.run())
|
service_dict = process_service(resolver.run())
|
||||||
|
|
||||||
|
# TODO: move to validate_service()
|
||||||
validate_against_service_schema(service_dict, service_config.name)
|
validate_against_service_schema(service_dict, service_config.name)
|
||||||
validate_paths(service_dict)
|
validate_paths(service_dict)
|
||||||
|
|
||||||
|
service_dict = finalize_service(service_config._replace(config=service_dict))
|
||||||
service_dict['name'] = service_config.name
|
service_dict['name'] = service_config.name
|
||||||
return service_dict
|
return service_dict
|
||||||
|
|
||||||
def build_services(config_file):
|
def build_services(config_file):
|
||||||
return [
|
return sort_service_dicts([
|
||||||
build_service(config_file.filename, name, service_dict)
|
build_service(config_file.filename, name, service_dict)
|
||||||
for name, service_dict in config_file.config.items()
|
for name, service_dict in config_file.config.items()
|
||||||
]
|
])
|
||||||
|
|
||||||
def merge_services(base, override):
|
def merge_services(base, override):
|
||||||
all_service_names = set(base) | set(override)
|
all_service_names = set(base) | set(override)
|
||||||
@ -353,6 +365,7 @@ def validate_ulimits(ulimit_config):
|
|||||||
"than 'hard' value".format(ulimit_config))
|
"than 'hard' value".format(ulimit_config))
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: rename to normalize_service
|
||||||
def process_service(service_config):
|
def process_service(service_config):
|
||||||
working_dir = service_config.working_dir
|
working_dir = service_config.working_dir
|
||||||
service_dict = dict(service_config.config)
|
service_dict = dict(service_config.config)
|
||||||
@ -370,12 +383,33 @@ def process_service(service_config):
|
|||||||
if 'labels' in service_dict:
|
if 'labels' in service_dict:
|
||||||
service_dict['labels'] = parse_labels(service_dict['labels'])
|
service_dict['labels'] = parse_labels(service_dict['labels'])
|
||||||
|
|
||||||
|
if 'extra_hosts' in service_dict:
|
||||||
|
service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts'])
|
||||||
|
|
||||||
|
# TODO: move to a validate_service()
|
||||||
if 'ulimits' in service_dict:
|
if 'ulimits' in service_dict:
|
||||||
validate_ulimits(service_dict['ulimits'])
|
validate_ulimits(service_dict['ulimits'])
|
||||||
|
|
||||||
return service_dict
|
return service_dict
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_service(service_config):
|
||||||
|
service_dict = dict(service_config.config)
|
||||||
|
|
||||||
|
if 'volumes_from' in service_dict:
|
||||||
|
service_dict['volumes_from'] = [
|
||||||
|
VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']]
|
||||||
|
|
||||||
|
if 'volumes' in service_dict:
|
||||||
|
service_dict['volumes'] = [
|
||||||
|
VolumeSpec.parse(v) for v in service_dict['volumes']]
|
||||||
|
|
||||||
|
if 'restart' in service_dict:
|
||||||
|
service_dict['restart'] = parse_restart_spec(service_dict['restart'])
|
||||||
|
|
||||||
|
return service_dict
|
||||||
|
|
||||||
|
|
||||||
def merge_service_dicts_from_files(base, override):
|
def merge_service_dicts_from_files(base, override):
|
||||||
"""When merging services from multiple files we need to merge the `extends`
|
"""When merging services from multiple files we need to merge the `extends`
|
||||||
field. This is not handled by `merge_service_dicts()` which is used to
|
field. This is not handled by `merge_service_dicts()` which is used to
|
||||||
@ -606,17 +640,6 @@ def to_list(value):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def get_service_name_from_net(net_config):
|
|
||||||
if not net_config:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not net_config.startswith('container:'):
|
|
||||||
return
|
|
||||||
|
|
||||||
_, net_name = net_config.split(':', 1)
|
|
||||||
return net_name
|
|
||||||
|
|
||||||
|
|
||||||
def load_yaml(filename):
|
def load_yaml(filename):
|
||||||
try:
|
try:
|
||||||
with open(filename, 'r') as fh:
|
with open(filename, 'r') as fh:
|
||||||
|
@ -6,6 +6,10 @@ class ConfigurationError(Exception):
|
|||||||
return self.msg
|
return self.msg
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyError(ConfigurationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CircularReference(ConfigurationError):
|
class CircularReference(ConfigurationError):
|
||||||
def __init__(self, trail):
|
def __init__(self, trail):
|
||||||
self.trail = trail
|
self.trail = trail
|
||||||
|
@ -37,22 +37,7 @@
|
|||||||
"domainname": {"type": "string"},
|
"domainname": {"type": "string"},
|
||||||
"entrypoint": {"$ref": "#/definitions/string_or_list"},
|
"entrypoint": {"$ref": "#/definitions/string_or_list"},
|
||||||
"env_file": {"$ref": "#/definitions/string_or_list"},
|
"env_file": {"$ref": "#/definitions/string_or_list"},
|
||||||
|
"environment": {"$ref": "#/definitions/list_or_dict"},
|
||||||
"environment": {
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"patternProperties": {
|
|
||||||
".+": {
|
|
||||||
"type": ["string", "number", "boolean", "null"],
|
|
||||||
"format": "environment"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
{"type": "array", "items": {"type": "string"}, "uniqueItems": true}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"expose": {
|
"expose": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@ -165,10 +150,18 @@
|
|||||||
|
|
||||||
"list_or_dict": {
|
"list_or_dict": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{"type": "array", "items": {"type": "string"}, "uniqueItems": true},
|
{
|
||||||
{"type": "object"}
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
".+": {
|
||||||
|
"type": ["string", "number", "boolean", "null"],
|
||||||
|
"format": "bool-value-in-mapping"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{"type": "array", "items": {"type": "string"}, "uniqueItems": true}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
55
compose/config/sort_services.py
Normal file
55
compose/config/sort_services.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from compose.config.errors import DependencyError
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_name_from_net(net_config):
|
||||||
|
if not net_config:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not net_config.startswith('container:'):
|
||||||
|
return
|
||||||
|
|
||||||
|
_, net_name = net_config.split(':', 1)
|
||||||
|
return net_name
|
||||||
|
|
||||||
|
|
||||||
|
def sort_service_dicts(services):
|
||||||
|
# Topological sort (Cormen/Tarjan algorithm).
|
||||||
|
unmarked = services[:]
|
||||||
|
temporary_marked = set()
|
||||||
|
sorted_services = []
|
||||||
|
|
||||||
|
def get_service_names(links):
|
||||||
|
return [link.split(':')[0] for link in links]
|
||||||
|
|
||||||
|
def get_service_names_from_volumes_from(volumes_from):
|
||||||
|
return [volume_from.source for volume_from in volumes_from]
|
||||||
|
|
||||||
|
def get_service_dependents(service_dict, services):
|
||||||
|
name = service_dict['name']
|
||||||
|
return [
|
||||||
|
service for service in services
|
||||||
|
if (name in get_service_names(service.get('links', [])) or
|
||||||
|
name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or
|
||||||
|
name == get_service_name_from_net(service.get('net')))
|
||||||
|
]
|
||||||
|
|
||||||
|
def visit(n):
|
||||||
|
if n['name'] in temporary_marked:
|
||||||
|
if n['name'] in get_service_names(n.get('links', [])):
|
||||||
|
raise DependencyError('A service can not link to itself: %s' % n['name'])
|
||||||
|
if n['name'] in n.get('volumes_from', []):
|
||||||
|
raise DependencyError('A service can not mount itself as volume: %s' % n['name'])
|
||||||
|
else:
|
||||||
|
raise DependencyError('Circular import between %s' % ' and '.join(temporary_marked))
|
||||||
|
if n in unmarked:
|
||||||
|
temporary_marked.add(n['name'])
|
||||||
|
for m in get_service_dependents(n, services):
|
||||||
|
visit(m)
|
||||||
|
temporary_marked.remove(n['name'])
|
||||||
|
unmarked.remove(n)
|
||||||
|
sorted_services.insert(0, n)
|
||||||
|
|
||||||
|
while unmarked:
|
||||||
|
visit(unmarked[-1])
|
||||||
|
|
||||||
|
return sorted_services
|
120
compose/config/types.py
Normal file
120
compose/config/types.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
Types for objects parsed from the configuration.
|
||||||
|
"""
|
||||||
|
from __future__ import absolute_import
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from compose.config.errors import ConfigurationError
|
||||||
|
from compose.const import IS_WINDOWS_PLATFORM
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode')):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, volume_from_config):
|
||||||
|
parts = volume_from_config.split(':')
|
||||||
|
if len(parts) > 2:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"volume_from {} has incorrect format, should be "
|
||||||
|
"service[:mode]".format(volume_from_config))
|
||||||
|
|
||||||
|
if len(parts) == 1:
|
||||||
|
source = parts[0]
|
||||||
|
mode = 'rw'
|
||||||
|
else:
|
||||||
|
source, mode = parts
|
||||||
|
|
||||||
|
return cls(source, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_restart_spec(restart_config):
|
||||||
|
if not restart_config:
|
||||||
|
return None
|
||||||
|
parts = restart_config.split(':')
|
||||||
|
if len(parts) > 2:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Restart %s has incorrect format, should be "
|
||||||
|
"mode[:max_retry]" % restart_config)
|
||||||
|
if len(parts) == 2:
|
||||||
|
name, max_retry_count = parts
|
||||||
|
else:
|
||||||
|
name, = parts
|
||||||
|
max_retry_count = 0
|
||||||
|
|
||||||
|
return {'Name': name, 'MaximumRetryCount': int(max_retry_count)}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_extra_hosts(extra_hosts_config):
|
||||||
|
if not extra_hosts_config:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if isinstance(extra_hosts_config, dict):
|
||||||
|
return dict(extra_hosts_config)
|
||||||
|
|
||||||
|
if isinstance(extra_hosts_config, list):
|
||||||
|
extra_hosts_dict = {}
|
||||||
|
for extra_hosts_line in extra_hosts_config:
|
||||||
|
# TODO: validate string contains ':' ?
|
||||||
|
host, ip = extra_hosts_line.split(':')
|
||||||
|
extra_hosts_dict[host.strip()] = ip.strip()
|
||||||
|
return extra_hosts_dict
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_paths_for_engine(external_path, internal_path):
|
||||||
|
"""Windows paths, c:\my\path\shiny, need to be changed to be compatible with
|
||||||
|
the Engine. Volume paths are expected to be linux style /c/my/path/shiny/
|
||||||
|
"""
|
||||||
|
if not IS_WINDOWS_PLATFORM:
|
||||||
|
return external_path, internal_path
|
||||||
|
|
||||||
|
if external_path:
|
||||||
|
drive, tail = os.path.splitdrive(external_path)
|
||||||
|
|
||||||
|
if drive:
|
||||||
|
external_path = '/' + drive.lower().rstrip(':') + tail
|
||||||
|
|
||||||
|
external_path = external_path.replace('\\', '/')
|
||||||
|
|
||||||
|
return external_path, internal_path.replace('\\', '/')
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, volume_config):
|
||||||
|
"""Parse a volume_config path and split it into external:internal[:mode]
|
||||||
|
parts to be returned as a valid VolumeSpec.
|
||||||
|
"""
|
||||||
|
if IS_WINDOWS_PLATFORM:
|
||||||
|
# relative paths in windows expand to include the drive, eg C:\
|
||||||
|
# so we join the first 2 parts back together to count as one
|
||||||
|
drive, tail = os.path.splitdrive(volume_config)
|
||||||
|
parts = tail.split(":")
|
||||||
|
|
||||||
|
if drive:
|
||||||
|
parts[0] = drive + parts[0]
|
||||||
|
else:
|
||||||
|
parts = volume_config.split(':')
|
||||||
|
|
||||||
|
if len(parts) > 3:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Volume %s has incorrect format, should be "
|
||||||
|
"external:internal[:mode]" % volume_config)
|
||||||
|
|
||||||
|
if len(parts) == 1:
|
||||||
|
external, internal = normalize_paths_for_engine(
|
||||||
|
None,
|
||||||
|
os.path.normpath(parts[0]))
|
||||||
|
else:
|
||||||
|
external, internal = normalize_paths_for_engine(
|
||||||
|
os.path.normpath(parts[0]),
|
||||||
|
os.path.normpath(parts[1]))
|
||||||
|
|
||||||
|
mode = 'rw'
|
||||||
|
if len(parts) == 3:
|
||||||
|
mode = parts[2]
|
||||||
|
|
||||||
|
return cls(external, internal, mode)
|
@ -49,7 +49,7 @@ def format_ports(instance):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@FormatChecker.cls_checks(format="environment")
|
@FormatChecker.cls_checks(format="bool-value-in-mapping")
|
||||||
def format_boolean_in_environment(instance):
|
def format_boolean_in_environment(instance):
|
||||||
"""
|
"""
|
||||||
Check if there is a boolean in the environment and display a warning.
|
Check if there is a boolean in the environment and display a warning.
|
||||||
@ -273,7 +273,7 @@ def validate_against_fields_schema(config, filename):
|
|||||||
_validate_against_schema(
|
_validate_against_schema(
|
||||||
config,
|
config,
|
||||||
"fields_schema.json",
|
"fields_schema.json",
|
||||||
format_checker=["ports", "environment"],
|
format_checker=["ports", "bool-value-in-mapping"],
|
||||||
filename=filename)
|
filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ from docker.errors import NotFound
|
|||||||
|
|
||||||
from . import parallel
|
from . import parallel
|
||||||
from .config import ConfigurationError
|
from .config import ConfigurationError
|
||||||
from .config import get_service_name_from_net
|
from .config.sort_services import get_service_name_from_net
|
||||||
from .const import DEFAULT_TIMEOUT
|
from .const import DEFAULT_TIMEOUT
|
||||||
from .const import LABEL_ONE_OFF
|
from .const import LABEL_ONE_OFF
|
||||||
from .const import LABEL_PROJECT
|
from .const import LABEL_PROJECT
|
||||||
@ -19,61 +19,13 @@ from .legacy import check_for_legacy_containers
|
|||||||
from .service import ContainerNet
|
from .service import ContainerNet
|
||||||
from .service import ConvergenceStrategy
|
from .service import ConvergenceStrategy
|
||||||
from .service import Net
|
from .service import Net
|
||||||
from .service import parse_volume_from_spec
|
|
||||||
from .service import Service
|
from .service import Service
|
||||||
from .service import ServiceNet
|
from .service import ServiceNet
|
||||||
from .service import VolumeFromSpec
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def sort_service_dicts(services):
|
|
||||||
# Topological sort (Cormen/Tarjan algorithm).
|
|
||||||
unmarked = services[:]
|
|
||||||
temporary_marked = set()
|
|
||||||
sorted_services = []
|
|
||||||
|
|
||||||
def get_service_names(links):
|
|
||||||
return [link.split(':')[0] for link in links]
|
|
||||||
|
|
||||||
def get_service_names_from_volumes_from(volumes_from):
|
|
||||||
return [
|
|
||||||
parse_volume_from_spec(volume_from).source
|
|
||||||
for volume_from in volumes_from
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_service_dependents(service_dict, services):
|
|
||||||
name = service_dict['name']
|
|
||||||
return [
|
|
||||||
service for service in services
|
|
||||||
if (name in get_service_names(service.get('links', [])) or
|
|
||||||
name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or
|
|
||||||
name == get_service_name_from_net(service.get('net')))
|
|
||||||
]
|
|
||||||
|
|
||||||
def visit(n):
|
|
||||||
if n['name'] in temporary_marked:
|
|
||||||
if n['name'] in get_service_names(n.get('links', [])):
|
|
||||||
raise DependencyError('A service can not link to itself: %s' % n['name'])
|
|
||||||
if n['name'] in n.get('volumes_from', []):
|
|
||||||
raise DependencyError('A service can not mount itself as volume: %s' % n['name'])
|
|
||||||
else:
|
|
||||||
raise DependencyError('Circular import between %s' % ' and '.join(temporary_marked))
|
|
||||||
if n in unmarked:
|
|
||||||
temporary_marked.add(n['name'])
|
|
||||||
for m in get_service_dependents(n, services):
|
|
||||||
visit(m)
|
|
||||||
temporary_marked.remove(n['name'])
|
|
||||||
unmarked.remove(n)
|
|
||||||
sorted_services.insert(0, n)
|
|
||||||
|
|
||||||
while unmarked:
|
|
||||||
visit(unmarked[-1])
|
|
||||||
|
|
||||||
return sorted_services
|
|
||||||
|
|
||||||
|
|
||||||
class Project(object):
|
class Project(object):
|
||||||
"""
|
"""
|
||||||
A collection of services.
|
A collection of services.
|
||||||
@ -101,7 +53,7 @@ class Project(object):
|
|||||||
if use_networking:
|
if use_networking:
|
||||||
remove_links(service_dicts)
|
remove_links(service_dicts)
|
||||||
|
|
||||||
for service_dict in sort_service_dicts(service_dicts):
|
for service_dict in service_dicts:
|
||||||
links = project.get_links(service_dict)
|
links = project.get_links(service_dict)
|
||||||
volumes_from = project.get_volumes_from(service_dict)
|
volumes_from = project.get_volumes_from(service_dict)
|
||||||
net = project.get_net(service_dict)
|
net = project.get_net(service_dict)
|
||||||
@ -192,16 +144,15 @@ class Project(object):
|
|||||||
def get_volumes_from(self, service_dict):
|
def get_volumes_from(self, service_dict):
|
||||||
volumes_from = []
|
volumes_from = []
|
||||||
if 'volumes_from' in service_dict:
|
if 'volumes_from' in service_dict:
|
||||||
for volume_from_config in service_dict.get('volumes_from', []):
|
for volume_from_spec in service_dict.get('volumes_from', []):
|
||||||
volume_from_spec = parse_volume_from_spec(volume_from_config)
|
|
||||||
# Get service
|
# Get service
|
||||||
try:
|
try:
|
||||||
service_name = self.get_service(volume_from_spec.source)
|
service = self.get_service(volume_from_spec.source)
|
||||||
volume_from_spec = VolumeFromSpec(service_name, volume_from_spec.mode)
|
volume_from_spec = volume_from_spec._replace(source=service)
|
||||||
except NoSuchService:
|
except NoSuchService:
|
||||||
try:
|
try:
|
||||||
container_name = Container.from_id(self.client, volume_from_spec.source)
|
container = Container.from_id(self.client, volume_from_spec.source)
|
||||||
volume_from_spec = VolumeFromSpec(container_name, volume_from_spec.mode)
|
volume_from_spec = volume_from_spec._replace(source=container)
|
||||||
except APIError:
|
except APIError:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
'Service "%s" mounts volumes from "%s", which is '
|
'Service "%s" mounts volumes from "%s", which is '
|
||||||
@ -410,7 +361,3 @@ class NoSuchService(Exception):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.msg
|
return self.msg
|
||||||
|
|
||||||
|
|
||||||
class DependencyError(ConfigurationError):
|
|
||||||
pass
|
|
||||||
|
@ -2,7 +2,6 @@ from __future__ import absolute_import
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
@ -18,9 +17,8 @@ from docker.utils.ports import split_port
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .config import DOCKER_CONFIG_KEYS
|
from .config import DOCKER_CONFIG_KEYS
|
||||||
from .config import merge_environment
|
from .config import merge_environment
|
||||||
from .config.validation import VALID_NAME_CHARS
|
from .config.types import VolumeSpec
|
||||||
from .const import DEFAULT_TIMEOUT
|
from .const import DEFAULT_TIMEOUT
|
||||||
from .const import IS_WINDOWS_PLATFORM
|
|
||||||
from .const import LABEL_CONFIG_HASH
|
from .const import LABEL_CONFIG_HASH
|
||||||
from .const import LABEL_CONTAINER_NUMBER
|
from .const import LABEL_CONTAINER_NUMBER
|
||||||
from .const import LABEL_ONE_OFF
|
from .const import LABEL_ONE_OFF
|
||||||
@ -71,10 +69,6 @@ class BuildError(Exception):
|
|||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NeedsBuildError(Exception):
|
class NeedsBuildError(Exception):
|
||||||
def __init__(self, service):
|
def __init__(self, service):
|
||||||
self.service = service
|
self.service = service
|
||||||
@ -84,12 +78,6 @@ class NoSuchImageError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
VolumeSpec = namedtuple('VolumeSpec', 'external internal mode')
|
|
||||||
|
|
||||||
|
|
||||||
VolumeFromSpec = namedtuple('VolumeFromSpec', 'source mode')
|
|
||||||
|
|
||||||
|
|
||||||
ServiceName = namedtuple('ServiceName', 'project service number')
|
ServiceName = namedtuple('ServiceName', 'project service number')
|
||||||
|
|
||||||
|
|
||||||
@ -122,9 +110,6 @@ class Service(object):
|
|||||||
net=None,
|
net=None,
|
||||||
**options
|
**options
|
||||||
):
|
):
|
||||||
if not re.match('^%s+$' % VALID_NAME_CHARS, project):
|
|
||||||
raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS))
|
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.client = client
|
self.client = client
|
||||||
self.project = project
|
self.project = project
|
||||||
@ -511,7 +496,7 @@ class Service(object):
|
|||||||
# TODO: Implement issue #652 here
|
# TODO: Implement issue #652 here
|
||||||
return build_container_name(self.project, self.name, number, one_off)
|
return build_container_name(self.project, self.name, number, one_off)
|
||||||
|
|
||||||
# TODO: this would benefit from github.com/docker/docker/pull/11943
|
# TODO: this would benefit from github.com/docker/docker/pull/14699
|
||||||
# to remove the need to inspect every container
|
# to remove the need to inspect every container
|
||||||
def _next_container_number(self, one_off=False):
|
def _next_container_number(self, one_off=False):
|
||||||
containers = filter(None, [
|
containers = filter(None, [
|
||||||
@ -604,8 +589,7 @@ class Service(object):
|
|||||||
|
|
||||||
if 'volumes' in container_options:
|
if 'volumes' in container_options:
|
||||||
container_options['volumes'] = dict(
|
container_options['volumes'] = dict(
|
||||||
(parse_volume_spec(v).internal, {})
|
(v.internal, {}) for v in container_options['volumes'])
|
||||||
for v in container_options['volumes'])
|
|
||||||
|
|
||||||
container_options['environment'] = merge_environment(
|
container_options['environment'] = merge_environment(
|
||||||
self.options.get('environment'),
|
self.options.get('environment'),
|
||||||
@ -634,58 +618,34 @@ class Service(object):
|
|||||||
|
|
||||||
def _get_container_host_config(self, override_options, one_off=False):
|
def _get_container_host_config(self, override_options, one_off=False):
|
||||||
options = dict(self.options, **override_options)
|
options = dict(self.options, **override_options)
|
||||||
port_bindings = build_port_bindings(options.get('ports') or [])
|
|
||||||
|
|
||||||
privileged = options.get('privileged', False)
|
|
||||||
cap_add = options.get('cap_add', None)
|
|
||||||
cap_drop = options.get('cap_drop', None)
|
|
||||||
log_config = LogConfig(
|
log_config = LogConfig(
|
||||||
type=options.get('log_driver', ""),
|
type=options.get('log_driver', ""),
|
||||||
config=options.get('log_opt', None)
|
config=options.get('log_opt', None)
|
||||||
)
|
)
|
||||||
pid = options.get('pid', None)
|
|
||||||
security_opt = options.get('security_opt', None)
|
|
||||||
|
|
||||||
dns = options.get('dns', None)
|
|
||||||
if isinstance(dns, six.string_types):
|
|
||||||
dns = [dns]
|
|
||||||
|
|
||||||
dns_search = options.get('dns_search', None)
|
|
||||||
if isinstance(dns_search, six.string_types):
|
|
||||||
dns_search = [dns_search]
|
|
||||||
|
|
||||||
restart = parse_restart_spec(options.get('restart', None))
|
|
||||||
|
|
||||||
extra_hosts = build_extra_hosts(options.get('extra_hosts', None))
|
|
||||||
read_only = options.get('read_only', None)
|
|
||||||
|
|
||||||
devices = options.get('devices', None)
|
|
||||||
cgroup_parent = options.get('cgroup_parent', None)
|
|
||||||
ulimits = build_ulimits(options.get('ulimits', None))
|
|
||||||
|
|
||||||
return self.client.create_host_config(
|
return self.client.create_host_config(
|
||||||
links=self._get_links(link_to_self=one_off),
|
links=self._get_links(link_to_self=one_off),
|
||||||
port_bindings=port_bindings,
|
port_bindings=build_port_bindings(options.get('ports') or []),
|
||||||
binds=options.get('binds'),
|
binds=options.get('binds'),
|
||||||
volumes_from=self._get_volumes_from(),
|
volumes_from=self._get_volumes_from(),
|
||||||
privileged=privileged,
|
privileged=options.get('privileged', False),
|
||||||
network_mode=self.net.mode,
|
network_mode=self.net.mode,
|
||||||
devices=devices,
|
devices=options.get('devices'),
|
||||||
dns=dns,
|
dns=options.get('dns'),
|
||||||
dns_search=dns_search,
|
dns_search=options.get('dns_search'),
|
||||||
restart_policy=restart,
|
restart_policy=options.get('restart'),
|
||||||
cap_add=cap_add,
|
cap_add=options.get('cap_add'),
|
||||||
cap_drop=cap_drop,
|
cap_drop=options.get('cap_drop'),
|
||||||
mem_limit=options.get('mem_limit'),
|
mem_limit=options.get('mem_limit'),
|
||||||
memswap_limit=options.get('memswap_limit'),
|
memswap_limit=options.get('memswap_limit'),
|
||||||
ulimits=ulimits,
|
ulimits=build_ulimits(options.get('ulimits')),
|
||||||
log_config=log_config,
|
log_config=log_config,
|
||||||
extra_hosts=extra_hosts,
|
extra_hosts=options.get('extra_hosts'),
|
||||||
read_only=read_only,
|
read_only=options.get('read_only'),
|
||||||
pid_mode=pid,
|
pid_mode=options.get('pid'),
|
||||||
security_opt=security_opt,
|
security_opt=options.get('security_opt'),
|
||||||
ipc_mode=options.get('ipc'),
|
ipc_mode=options.get('ipc'),
|
||||||
cgroup_parent=cgroup_parent
|
cgroup_parent=options.get('cgroup_parent'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def build(self, no_cache=False, pull=False, force_rm=False):
|
def build(self, no_cache=False, pull=False, force_rm=False):
|
||||||
@ -894,11 +854,10 @@ def parse_repository_tag(repo_path):
|
|||||||
# Volumes
|
# Volumes
|
||||||
|
|
||||||
|
|
||||||
def merge_volume_bindings(volumes_option, previous_container):
|
def merge_volume_bindings(volumes, previous_container):
|
||||||
"""Return a list of volume bindings for a container. Container data volumes
|
"""Return a list of volume bindings for a container. Container data volumes
|
||||||
are replaced by those from the previous container.
|
are replaced by those from the previous container.
|
||||||
"""
|
"""
|
||||||
volumes = [parse_volume_spec(volume) for volume in volumes_option or []]
|
|
||||||
volume_bindings = dict(
|
volume_bindings = dict(
|
||||||
build_volume_binding(volume)
|
build_volume_binding(volume)
|
||||||
for volume in volumes
|
for volume in volumes
|
||||||
@ -920,7 +879,7 @@ def get_container_data_volumes(container, volumes_option):
|
|||||||
volumes = []
|
volumes = []
|
||||||
container_volumes = container.get('Volumes') or {}
|
container_volumes = container.get('Volumes') or {}
|
||||||
image_volumes = [
|
image_volumes = [
|
||||||
parse_volume_spec(volume)
|
VolumeSpec.parse(volume)
|
||||||
for volume in
|
for volume in
|
||||||
container.image_config['ContainerConfig'].get('Volumes') or {}
|
container.image_config['ContainerConfig'].get('Volumes') or {}
|
||||||
]
|
]
|
||||||
@ -967,56 +926,6 @@ def build_volume_binding(volume_spec):
|
|||||||
return volume_spec.internal, "{}:{}:{}".format(*volume_spec)
|
return volume_spec.internal, "{}:{}:{}".format(*volume_spec)
|
||||||
|
|
||||||
|
|
||||||
def normalize_paths_for_engine(external_path, internal_path):
|
|
||||||
"""Windows paths, c:\my\path\shiny, need to be changed to be compatible with
|
|
||||||
the Engine. Volume paths are expected to be linux style /c/my/path/shiny/
|
|
||||||
"""
|
|
||||||
if not IS_WINDOWS_PLATFORM:
|
|
||||||
return external_path, internal_path
|
|
||||||
|
|
||||||
if external_path:
|
|
||||||
drive, tail = os.path.splitdrive(external_path)
|
|
||||||
|
|
||||||
if drive:
|
|
||||||
external_path = '/' + drive.lower().rstrip(':') + tail
|
|
||||||
|
|
||||||
external_path = external_path.replace('\\', '/')
|
|
||||||
|
|
||||||
return external_path, internal_path.replace('\\', '/')
|
|
||||||
|
|
||||||
|
|
||||||
def parse_volume_spec(volume_config):
|
|
||||||
"""
|
|
||||||
Parse a volume_config path and split it into external:internal[:mode]
|
|
||||||
parts to be returned as a valid VolumeSpec.
|
|
||||||
"""
|
|
||||||
if IS_WINDOWS_PLATFORM:
|
|
||||||
# relative paths in windows expand to include the drive, eg C:\
|
|
||||||
# so we join the first 2 parts back together to count as one
|
|
||||||
drive, tail = os.path.splitdrive(volume_config)
|
|
||||||
parts = tail.split(":")
|
|
||||||
|
|
||||||
if drive:
|
|
||||||
parts[0] = drive + parts[0]
|
|
||||||
else:
|
|
||||||
parts = volume_config.split(':')
|
|
||||||
|
|
||||||
if len(parts) > 3:
|
|
||||||
raise ConfigError("Volume %s has incorrect format, should be "
|
|
||||||
"external:internal[:mode]" % volume_config)
|
|
||||||
|
|
||||||
if len(parts) == 1:
|
|
||||||
external, internal = normalize_paths_for_engine(None, os.path.normpath(parts[0]))
|
|
||||||
else:
|
|
||||||
external, internal = normalize_paths_for_engine(os.path.normpath(parts[0]), os.path.normpath(parts[1]))
|
|
||||||
|
|
||||||
mode = 'rw'
|
|
||||||
if len(parts) == 3:
|
|
||||||
mode = parts[2]
|
|
||||||
|
|
||||||
return VolumeSpec(external, internal, mode)
|
|
||||||
|
|
||||||
|
|
||||||
def build_volume_from(volume_from_spec):
|
def build_volume_from(volume_from_spec):
|
||||||
"""
|
"""
|
||||||
volume_from can be either a service or a container. We want to return the
|
volume_from can be either a service or a container. We want to return the
|
||||||
@ -1033,21 +942,6 @@ def build_volume_from(volume_from_spec):
|
|||||||
return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)]
|
return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)]
|
||||||
|
|
||||||
|
|
||||||
def parse_volume_from_spec(volume_from_config):
|
|
||||||
parts = volume_from_config.split(':')
|
|
||||||
if len(parts) > 2:
|
|
||||||
raise ConfigError("Volume %s has incorrect format, should be "
|
|
||||||
"external:internal[:mode]" % volume_from_config)
|
|
||||||
|
|
||||||
if len(parts) == 1:
|
|
||||||
source = parts[0]
|
|
||||||
mode = 'rw'
|
|
||||||
else:
|
|
||||||
source, mode = parts
|
|
||||||
|
|
||||||
return VolumeFromSpec(source, mode)
|
|
||||||
|
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
|
|
||||||
|
|
||||||
@ -1064,24 +958,6 @@ def build_container_labels(label_options, service_labels, number, config_hash):
|
|||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
# Restart policy
|
|
||||||
|
|
||||||
|
|
||||||
def parse_restart_spec(restart_config):
|
|
||||||
if not restart_config:
|
|
||||||
return None
|
|
||||||
parts = restart_config.split(':')
|
|
||||||
if len(parts) > 2:
|
|
||||||
raise ConfigError("Restart %s has incorrect format, should be "
|
|
||||||
"mode[:max_retry]" % restart_config)
|
|
||||||
if len(parts) == 2:
|
|
||||||
name, max_retry_count = parts
|
|
||||||
else:
|
|
||||||
name, = parts
|
|
||||||
max_retry_count = 0
|
|
||||||
|
|
||||||
return {'Name': name, 'MaximumRetryCount': int(max_retry_count)}
|
|
||||||
|
|
||||||
# Ulimits
|
# Ulimits
|
||||||
|
|
||||||
|
|
||||||
@ -1098,31 +974,3 @@ def build_ulimits(ulimit_config):
|
|||||||
ulimits.append(ulimit_dict)
|
ulimits.append(ulimit_dict)
|
||||||
|
|
||||||
return ulimits
|
return ulimits
|
||||||
|
|
||||||
|
|
||||||
# Extra hosts
|
|
||||||
|
|
||||||
|
|
||||||
def build_extra_hosts(extra_hosts_config):
|
|
||||||
if not extra_hosts_config:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if isinstance(extra_hosts_config, list):
|
|
||||||
extra_hosts_dict = {}
|
|
||||||
for extra_hosts_line in extra_hosts_config:
|
|
||||||
if not isinstance(extra_hosts_line, six.string_types):
|
|
||||||
raise ConfigError(
|
|
||||||
"extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," %
|
|
||||||
extra_hosts_config
|
|
||||||
)
|
|
||||||
host, ip = extra_hosts_line.split(':')
|
|
||||||
extra_hosts_dict.update({host.strip(): ip.strip()})
|
|
||||||
extra_hosts_config = extra_hosts_dict
|
|
||||||
|
|
||||||
if isinstance(extra_hosts_config, dict):
|
|
||||||
return extra_hosts_config
|
|
||||||
|
|
||||||
raise ConfigError(
|
|
||||||
"extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," %
|
|
||||||
extra_hosts_config
|
|
||||||
)
|
|
||||||
|
@ -38,7 +38,7 @@ def start_process(base_dir, options):
|
|||||||
def wait_on_process(proc, returncode=0):
|
def wait_on_process(proc, returncode=0):
|
||||||
stdout, stderr = proc.communicate()
|
stdout, stderr = proc.communicate()
|
||||||
if proc.returncode != returncode:
|
if proc.returncode != returncode:
|
||||||
print(stderr)
|
print(stderr.decode('utf-8'))
|
||||||
assert proc.returncode == returncode
|
assert proc.returncode == returncode
|
||||||
return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8'))
|
return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8'))
|
||||||
|
|
||||||
|
@ -3,12 +3,13 @@ from __future__ import unicode_literals
|
|||||||
from .testcases import DockerClientTestCase
|
from .testcases import DockerClientTestCase
|
||||||
from compose.cli.docker_client import docker_client
|
from compose.cli.docker_client import docker_client
|
||||||
from compose.config import config
|
from compose.config import config
|
||||||
|
from compose.config.types import VolumeFromSpec
|
||||||
|
from compose.config.types import VolumeSpec
|
||||||
from compose.const import LABEL_PROJECT
|
from compose.const import LABEL_PROJECT
|
||||||
from compose.container import Container
|
from compose.container import Container
|
||||||
from compose.project import Project
|
from compose.project import Project
|
||||||
from compose.service import ConvergenceStrategy
|
from compose.service import ConvergenceStrategy
|
||||||
from compose.service import Net
|
from compose.service import Net
|
||||||
from compose.service import VolumeFromSpec
|
|
||||||
|
|
||||||
|
|
||||||
def build_service_dicts(service_config):
|
def build_service_dicts(service_config):
|
||||||
@ -214,7 +215,7 @@ class ProjectTest(DockerClientTestCase):
|
|||||||
|
|
||||||
def test_project_up(self):
|
def test_project_up(self):
|
||||||
web = self.create_service('web')
|
web = self.create_service('web')
|
||||||
db = self.create_service('db', volumes=['/var/db'])
|
db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')])
|
||||||
project = Project('composetest', [web, db], self.client)
|
project = Project('composetest', [web, db], self.client)
|
||||||
project.start()
|
project.start()
|
||||||
self.assertEqual(len(project.containers()), 0)
|
self.assertEqual(len(project.containers()), 0)
|
||||||
@ -238,7 +239,7 @@ class ProjectTest(DockerClientTestCase):
|
|||||||
|
|
||||||
def test_recreate_preserves_volumes(self):
|
def test_recreate_preserves_volumes(self):
|
||||||
web = self.create_service('web')
|
web = self.create_service('web')
|
||||||
db = self.create_service('db', volumes=['/etc'])
|
db = self.create_service('db', volumes=[VolumeSpec.parse('/etc')])
|
||||||
project = Project('composetest', [web, db], self.client)
|
project = Project('composetest', [web, db], self.client)
|
||||||
project.start()
|
project.start()
|
||||||
self.assertEqual(len(project.containers()), 0)
|
self.assertEqual(len(project.containers()), 0)
|
||||||
@ -257,7 +258,7 @@ class ProjectTest(DockerClientTestCase):
|
|||||||
|
|
||||||
def test_project_up_with_no_recreate_running(self):
|
def test_project_up_with_no_recreate_running(self):
|
||||||
web = self.create_service('web')
|
web = self.create_service('web')
|
||||||
db = self.create_service('db', volumes=['/var/db'])
|
db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')])
|
||||||
project = Project('composetest', [web, db], self.client)
|
project = Project('composetest', [web, db], self.client)
|
||||||
project.start()
|
project.start()
|
||||||
self.assertEqual(len(project.containers()), 0)
|
self.assertEqual(len(project.containers()), 0)
|
||||||
@ -277,7 +278,7 @@ class ProjectTest(DockerClientTestCase):
|
|||||||
|
|
||||||
def test_project_up_with_no_recreate_stopped(self):
|
def test_project_up_with_no_recreate_stopped(self):
|
||||||
web = self.create_service('web')
|
web = self.create_service('web')
|
||||||
db = self.create_service('db', volumes=['/var/db'])
|
db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')])
|
||||||
project = Project('composetest', [web, db], self.client)
|
project = Project('composetest', [web, db], self.client)
|
||||||
project.start()
|
project.start()
|
||||||
self.assertEqual(len(project.containers()), 0)
|
self.assertEqual(len(project.containers()), 0)
|
||||||
@ -316,7 +317,7 @@ class ProjectTest(DockerClientTestCase):
|
|||||||
|
|
||||||
def test_project_up_starts_links(self):
|
def test_project_up_starts_links(self):
|
||||||
console = self.create_service('console')
|
console = self.create_service('console')
|
||||||
db = self.create_service('db', volumes=['/var/db'])
|
db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')])
|
||||||
web = self.create_service('web', links=[(db, 'db')])
|
web = self.create_service('web', links=[(db, 'db')])
|
||||||
|
|
||||||
project = Project('composetest', [web, db, console], self.client)
|
project = Project('composetest', [web, db, console], self.client)
|
||||||
|
@ -3,13 +3,17 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from .. import mock
|
from .. import mock
|
||||||
from .testcases import DockerClientTestCase
|
from .testcases import DockerClientTestCase
|
||||||
|
from compose.config.types import VolumeSpec
|
||||||
from compose.project import Project
|
from compose.project import Project
|
||||||
from compose.service import ConvergenceStrategy
|
from compose.service import ConvergenceStrategy
|
||||||
|
|
||||||
|
|
||||||
class ResilienceTest(DockerClientTestCase):
|
class ResilienceTest(DockerClientTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.db = self.create_service('db', volumes=['/var/db'], command='top')
|
self.db = self.create_service(
|
||||||
|
'db',
|
||||||
|
volumes=[VolumeSpec.parse('/var/db')],
|
||||||
|
command='top')
|
||||||
self.project = Project('composetest', [self.db], self.client)
|
self.project = Project('composetest', [self.db], self.client)
|
||||||
|
|
||||||
container = self.db.create_container()
|
container = self.db.create_container()
|
||||||
|
@ -14,6 +14,8 @@ from .. import mock
|
|||||||
from .testcases import DockerClientTestCase
|
from .testcases import DockerClientTestCase
|
||||||
from .testcases import pull_busybox
|
from .testcases import pull_busybox
|
||||||
from compose import __version__
|
from compose import __version__
|
||||||
|
from compose.config.types import VolumeFromSpec
|
||||||
|
from compose.config.types import VolumeSpec
|
||||||
from compose.const import LABEL_CONFIG_HASH
|
from compose.const import LABEL_CONFIG_HASH
|
||||||
from compose.const import LABEL_CONTAINER_NUMBER
|
from compose.const import LABEL_CONTAINER_NUMBER
|
||||||
from compose.const import LABEL_ONE_OFF
|
from compose.const import LABEL_ONE_OFF
|
||||||
@ -21,13 +23,10 @@ from compose.const import LABEL_PROJECT
|
|||||||
from compose.const import LABEL_SERVICE
|
from compose.const import LABEL_SERVICE
|
||||||
from compose.const import LABEL_VERSION
|
from compose.const import LABEL_VERSION
|
||||||
from compose.container import Container
|
from compose.container import Container
|
||||||
from compose.service import build_extra_hosts
|
|
||||||
from compose.service import ConfigError
|
|
||||||
from compose.service import ConvergencePlan
|
from compose.service import ConvergencePlan
|
||||||
from compose.service import ConvergenceStrategy
|
from compose.service import ConvergenceStrategy
|
||||||
from compose.service import Net
|
from compose.service import Net
|
||||||
from compose.service import Service
|
from compose.service import Service
|
||||||
from compose.service import VolumeFromSpec
|
|
||||||
|
|
||||||
|
|
||||||
def create_and_start_container(service, **override_options):
|
def create_and_start_container(service, **override_options):
|
||||||
@ -122,7 +121,7 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
self.assertEqual(container.name, 'composetest_db_run_1')
|
self.assertEqual(container.name, 'composetest_db_run_1')
|
||||||
|
|
||||||
def test_create_container_with_unspecified_volume(self):
|
def test_create_container_with_unspecified_volume(self):
|
||||||
service = self.create_service('db', volumes=['/var/db'])
|
service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')])
|
||||||
container = service.create_container()
|
container = service.create_container()
|
||||||
container.start()
|
container.start()
|
||||||
self.assertIn('/var/db', container.get('Volumes'))
|
self.assertIn('/var/db', container.get('Volumes'))
|
||||||
@ -139,37 +138,6 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
container.start()
|
container.start()
|
||||||
self.assertEqual(container.get('HostConfig.CpuShares'), 73)
|
self.assertEqual(container.get('HostConfig.CpuShares'), 73)
|
||||||
|
|
||||||
def test_build_extra_hosts(self):
|
|
||||||
# string
|
|
||||||
self.assertRaises(ConfigError, lambda: build_extra_hosts("www.example.com: 192.168.0.17"))
|
|
||||||
|
|
||||||
# list of strings
|
|
||||||
self.assertEqual(build_extra_hosts(
|
|
||||||
["www.example.com:192.168.0.17"]),
|
|
||||||
{'www.example.com': '192.168.0.17'})
|
|
||||||
self.assertEqual(build_extra_hosts(
|
|
||||||
["www.example.com: 192.168.0.17"]),
|
|
||||||
{'www.example.com': '192.168.0.17'})
|
|
||||||
self.assertEqual(build_extra_hosts(
|
|
||||||
["www.example.com: 192.168.0.17",
|
|
||||||
"static.example.com:192.168.0.19",
|
|
||||||
"api.example.com: 192.168.0.18"]),
|
|
||||||
{'www.example.com': '192.168.0.17',
|
|
||||||
'static.example.com': '192.168.0.19',
|
|
||||||
'api.example.com': '192.168.0.18'})
|
|
||||||
|
|
||||||
# list of dictionaries
|
|
||||||
self.assertRaises(ConfigError, lambda: build_extra_hosts(
|
|
||||||
[{'www.example.com': '192.168.0.17'},
|
|
||||||
{'api.example.com': '192.168.0.18'}]))
|
|
||||||
|
|
||||||
# dictionaries
|
|
||||||
self.assertEqual(build_extra_hosts(
|
|
||||||
{'www.example.com': '192.168.0.17',
|
|
||||||
'api.example.com': '192.168.0.18'}),
|
|
||||||
{'www.example.com': '192.168.0.17',
|
|
||||||
'api.example.com': '192.168.0.18'})
|
|
||||||
|
|
||||||
def test_create_container_with_extra_hosts_list(self):
|
def test_create_container_with_extra_hosts_list(self):
|
||||||
extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229']
|
extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229']
|
||||||
service = self.create_service('db', extra_hosts=extra_hosts)
|
service = self.create_service('db', extra_hosts=extra_hosts)
|
||||||
@ -215,7 +183,9 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
host_path = '/tmp/host-path'
|
host_path = '/tmp/host-path'
|
||||||
container_path = '/container-path'
|
container_path = '/container-path'
|
||||||
|
|
||||||
service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)])
|
service = self.create_service(
|
||||||
|
'db',
|
||||||
|
volumes=[VolumeSpec(host_path, container_path, 'rw')])
|
||||||
container = service.create_container()
|
container = service.create_container()
|
||||||
container.start()
|
container.start()
|
||||||
|
|
||||||
@ -228,11 +198,10 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
msg=("Last component differs: %s, %s" % (actual_host_path, host_path)))
|
msg=("Last component differs: %s, %s" % (actual_host_path, host_path)))
|
||||||
|
|
||||||
def test_recreate_preserves_volume_with_trailing_slash(self):
|
def test_recreate_preserves_volume_with_trailing_slash(self):
|
||||||
"""
|
"""When the Compose file specifies a trailing slash in the container path, make
|
||||||
When the Compose file specifies a trailing slash in the container path, make
|
|
||||||
sure we copy the volume over when recreating.
|
sure we copy the volume over when recreating.
|
||||||
"""
|
"""
|
||||||
service = self.create_service('data', volumes=['/data/'])
|
service = self.create_service('data', volumes=[VolumeSpec.parse('/data/')])
|
||||||
old_container = create_and_start_container(service)
|
old_container = create_and_start_container(service)
|
||||||
volume_path = old_container.get('Volumes')['/data']
|
volume_path = old_container.get('Volumes')['/data']
|
||||||
|
|
||||||
@ -246,7 +215,7 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
"""
|
"""
|
||||||
host_path = '/tmp/data'
|
host_path = '/tmp/data'
|
||||||
container_path = '/data'
|
container_path = '/data'
|
||||||
volumes = ['{}:{}/'.format(host_path, container_path)]
|
volumes = [VolumeSpec.parse('{}:{}/'.format(host_path, container_path))]
|
||||||
|
|
||||||
tmp_container = self.client.create_container(
|
tmp_container = self.client.create_container(
|
||||||
'busybox', 'true',
|
'busybox', 'true',
|
||||||
@ -300,7 +269,7 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
service = self.create_service(
|
service = self.create_service(
|
||||||
'db',
|
'db',
|
||||||
environment={'FOO': '1'},
|
environment={'FOO': '1'},
|
||||||
volumes=['/etc'],
|
volumes=[VolumeSpec.parse('/etc')],
|
||||||
entrypoint=['top'],
|
entrypoint=['top'],
|
||||||
command=['-d', '1']
|
command=['-d', '1']
|
||||||
)
|
)
|
||||||
@ -338,7 +307,7 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
service = self.create_service(
|
service = self.create_service(
|
||||||
'db',
|
'db',
|
||||||
environment={'FOO': '1'},
|
environment={'FOO': '1'},
|
||||||
volumes=['/var/db'],
|
volumes=[VolumeSpec.parse('/var/db')],
|
||||||
entrypoint=['top'],
|
entrypoint=['top'],
|
||||||
command=['-d', '1']
|
command=['-d', '1']
|
||||||
)
|
)
|
||||||
@ -376,10 +345,8 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
self.assertEqual(new_container.get('Volumes')['/data'], volume_path)
|
self.assertEqual(new_container.get('Volumes')['/data'], volume_path)
|
||||||
|
|
||||||
def test_execute_convergence_plan_when_image_volume_masks_config(self):
|
def test_execute_convergence_plan_when_image_volume_masks_config(self):
|
||||||
service = Service(
|
service = self.create_service(
|
||||||
project='composetest',
|
'db',
|
||||||
name='db',
|
|
||||||
client=self.client,
|
|
||||||
build='tests/fixtures/dockerfile-with-volume',
|
build='tests/fixtures/dockerfile-with-volume',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -387,7 +354,7 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
self.assertEqual(list(old_container.get('Volumes').keys()), ['/data'])
|
self.assertEqual(list(old_container.get('Volumes').keys()), ['/data'])
|
||||||
volume_path = old_container.get('Volumes')['/data']
|
volume_path = old_container.get('Volumes')['/data']
|
||||||
|
|
||||||
service.options['volumes'] = ['/tmp:/data']
|
service.options['volumes'] = [VolumeSpec.parse('/tmp:/data')]
|
||||||
|
|
||||||
with mock.patch('compose.service.log') as mock_log:
|
with mock.patch('compose.service.log') as mock_log:
|
||||||
new_container, = service.execute_convergence_plan(
|
new_container, = service.execute_convergence_plan(
|
||||||
@ -786,23 +753,21 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
container = create_and_start_container(service)
|
container = create_and_start_container(service)
|
||||||
self.assertIsNone(container.get('HostConfig.Dns'))
|
self.assertIsNone(container.get('HostConfig.Dns'))
|
||||||
|
|
||||||
def test_dns_single_value(self):
|
|
||||||
service = self.create_service('web', dns='8.8.8.8')
|
|
||||||
container = create_and_start_container(service)
|
|
||||||
self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8'])
|
|
||||||
|
|
||||||
def test_dns_list(self):
|
def test_dns_list(self):
|
||||||
service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9'])
|
service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9'])
|
||||||
container = create_and_start_container(service)
|
container = create_and_start_container(service)
|
||||||
self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9'])
|
self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9'])
|
||||||
|
|
||||||
def test_restart_always_value(self):
|
def test_restart_always_value(self):
|
||||||
service = self.create_service('web', restart='always')
|
service = self.create_service('web', restart={'Name': 'always'})
|
||||||
container = create_and_start_container(service)
|
container = create_and_start_container(service)
|
||||||
self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always')
|
self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always')
|
||||||
|
|
||||||
def test_restart_on_failure_value(self):
|
def test_restart_on_failure_value(self):
|
||||||
service = self.create_service('web', restart='on-failure:5')
|
service = self.create_service('web', restart={
|
||||||
|
'Name': 'on-failure',
|
||||||
|
'MaximumRetryCount': 5
|
||||||
|
})
|
||||||
container = create_and_start_container(service)
|
container = create_and_start_container(service)
|
||||||
self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure')
|
self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure')
|
||||||
self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5)
|
self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5)
|
||||||
@ -817,17 +782,7 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
container = create_and_start_container(service)
|
container = create_and_start_container(service)
|
||||||
self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN'])
|
self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN'])
|
||||||
|
|
||||||
def test_dns_search_no_value(self):
|
def test_dns_search(self):
|
||||||
service = self.create_service('web')
|
|
||||||
container = create_and_start_container(service)
|
|
||||||
self.assertIsNone(container.get('HostConfig.DnsSearch'))
|
|
||||||
|
|
||||||
def test_dns_search_single_value(self):
|
|
||||||
service = self.create_service('web', dns_search='example.com')
|
|
||||||
container = create_and_start_container(service)
|
|
||||||
self.assertEqual(container.get('HostConfig.DnsSearch'), ['example.com'])
|
|
||||||
|
|
||||||
def test_dns_search_list(self):
|
|
||||||
service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com'])
|
service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com'])
|
||||||
container = create_and_start_container(service)
|
container = create_and_start_container(service)
|
||||||
self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com'])
|
self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com'])
|
||||||
@ -909,22 +864,11 @@ class ServiceTest(DockerClientTestCase):
|
|||||||
for pair in expected.items():
|
for pair in expected.items():
|
||||||
self.assertIn(pair, labels)
|
self.assertIn(pair, labels)
|
||||||
|
|
||||||
service.kill()
|
|
||||||
remove_stopped(service)
|
|
||||||
|
|
||||||
labels_list = ["%s=%s" % pair for pair in labels_dict.items()]
|
|
||||||
|
|
||||||
service = self.create_service('web', labels=labels_list)
|
|
||||||
labels = create_and_start_container(service).labels.items()
|
|
||||||
for pair in expected.items():
|
|
||||||
self.assertIn(pair, labels)
|
|
||||||
|
|
||||||
def test_empty_labels(self):
|
def test_empty_labels(self):
|
||||||
labels_list = ['foo', 'bar']
|
labels_dict = {'foo': '', 'bar': ''}
|
||||||
|
service = self.create_service('web', labels=labels_dict)
|
||||||
service = self.create_service('web', labels=labels_list)
|
|
||||||
labels = create_and_start_container(service).labels.items()
|
labels = create_and_start_container(service).labels.items()
|
||||||
for name in labels_list:
|
for name in labels_dict:
|
||||||
self.assertIn((name, ''), labels)
|
self.assertIn((name, ''), labels)
|
||||||
|
|
||||||
def test_custom_container_name(self):
|
def test_custom_container_name(self):
|
||||||
|
@ -6,7 +6,6 @@ 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 process_service
|
|
||||||
from compose.config.config import resolve_environment
|
from compose.config.config import resolve_environment
|
||||||
from compose.config.config import ServiceConfig
|
from compose.config.config import ServiceConfig
|
||||||
from compose.const import LABEL_PROJECT
|
from compose.const import LABEL_PROJECT
|
||||||
@ -41,13 +40,12 @@ class DockerClientTestCase(unittest.TestCase):
|
|||||||
kwargs['command'] = ["top"]
|
kwargs['command'] = ["top"]
|
||||||
|
|
||||||
service_config = ServiceConfig('.', None, name, kwargs)
|
service_config = ServiceConfig('.', None, name, kwargs)
|
||||||
options = process_service(service_config)
|
kwargs['environment'] = resolve_environment(service_config)
|
||||||
options['environment'] = resolve_environment(
|
|
||||||
service_config._replace(config=options))
|
labels = dict(kwargs.setdefault('labels', {}))
|
||||||
labels = options.setdefault('labels', {})
|
|
||||||
labels['com.docker.compose.test-name'] = self.id()
|
labels['com.docker.compose.test-name'] = self.id()
|
||||||
|
|
||||||
return Service(name, client=self.client, project='composetest', **options)
|
return Service(name, client=self.client, project='composetest', **kwargs)
|
||||||
|
|
||||||
def check_build(self, *args, **kwargs):
|
def check_build(self, *args, **kwargs):
|
||||||
kwargs.setdefault('rm', True)
|
kwargs.setdefault('rm', True)
|
||||||
|
@ -124,7 +124,7 @@ class CLITestCase(unittest.TestCase):
|
|||||||
mock_project.get_service.return_value = Service(
|
mock_project.get_service.return_value = Service(
|
||||||
'service',
|
'service',
|
||||||
client=mock_client,
|
client=mock_client,
|
||||||
restart='always',
|
restart={'Name': 'always', 'MaximumRetryCount': 0},
|
||||||
image='someimage')
|
image='someimage')
|
||||||
command.run(mock_project, {
|
command.run(mock_project, {
|
||||||
'SERVICE': 'service',
|
'SERVICE': 'service',
|
||||||
|
@ -11,6 +11,7 @@ import pytest
|
|||||||
|
|
||||||
from compose.config import config
|
from compose.config import config
|
||||||
from compose.config.errors import ConfigurationError
|
from compose.config.errors import ConfigurationError
|
||||||
|
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
|
||||||
@ -32,7 +33,7 @@ def service_sort(services):
|
|||||||
return sorted(services, key=itemgetter('name'))
|
return sorted(services, key=itemgetter('name'))
|
||||||
|
|
||||||
|
|
||||||
def build_config_details(contents, working_dir, filename):
|
def build_config_details(contents, working_dir='working_dir', filename='filename.yml'):
|
||||||
return config.ConfigDetails(
|
return config.ConfigDetails(
|
||||||
working_dir,
|
working_dir,
|
||||||
[config.ConfigFile(filename, contents)])
|
[config.ConfigFile(filename, contents)])
|
||||||
@ -76,7 +77,7 @@ class ConfigTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_config_invalid_service_names(self):
|
def test_load_config_invalid_service_names(self):
|
||||||
for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
|
for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
|
||||||
with pytest.raises(ConfigurationError) as exc:
|
with pytest.raises(ConfigurationError) as exc:
|
||||||
config.load(build_config_details(
|
config.load(build_config_details(
|
||||||
@ -147,7 +148,7 @@ class ConfigTest(unittest.TestCase):
|
|||||||
'name': 'web',
|
'name': 'web',
|
||||||
'build': '/',
|
'build': '/',
|
||||||
'links': ['db'],
|
'links': ['db'],
|
||||||
'volumes': ['/home/user/project:/code'],
|
'volumes': [VolumeSpec.parse('/home/user/project:/code')],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'db',
|
'name': 'db',
|
||||||
@ -211,7 +212,7 @@ class ConfigTest(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
'name': 'web',
|
'name': 'web',
|
||||||
'image': 'example/web',
|
'image': 'example/web',
|
||||||
'volumes': ['/home/user/project:/code'],
|
'volumes': [VolumeSpec.parse('/home/user/project:/code')],
|
||||||
'labels': {'label': 'one'},
|
'labels': {'label': 'one'},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -231,6 +232,27 @@ class ConfigTest(unittest.TestCase):
|
|||||||
assert "service 'bogus' doesn't have any configuration" in exc.exconly()
|
assert "service 'bogus' doesn't have any configuration" in exc.exconly()
|
||||||
assert "In file 'override.yaml'" in exc.exconly()
|
assert "In file 'override.yaml'" in exc.exconly()
|
||||||
|
|
||||||
|
def test_load_sorts_in_dependency_order(self):
|
||||||
|
config_details = build_config_details({
|
||||||
|
'web': {
|
||||||
|
'image': 'busybox:latest',
|
||||||
|
'links': ['db'],
|
||||||
|
},
|
||||||
|
'db': {
|
||||||
|
'image': 'busybox:latest',
|
||||||
|
'volumes_from': ['volume:ro']
|
||||||
|
},
|
||||||
|
'volume': {
|
||||||
|
'image': 'busybox:latest',
|
||||||
|
'volumes': ['/tmp'],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
services = config.load(config_details)
|
||||||
|
|
||||||
|
assert services[0]['name'] == 'volume'
|
||||||
|
assert services[1]['name'] == 'db'
|
||||||
|
assert services[2]['name'] == 'web'
|
||||||
|
|
||||||
def test_config_valid_service_names(self):
|
def test_config_valid_service_names(self):
|
||||||
for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']:
|
for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']:
|
||||||
services = config.load(
|
services = config.load(
|
||||||
@ -512,6 +534,29 @@ class ConfigTest(unittest.TestCase):
|
|||||||
|
|
||||||
assert 'line 3, column 32' in exc.exconly()
|
assert 'line 3, column 32' in exc.exconly()
|
||||||
|
|
||||||
|
def test_validate_extra_hosts_invalid(self):
|
||||||
|
with pytest.raises(ConfigurationError) as exc:
|
||||||
|
config.load(build_config_details({
|
||||||
|
'web': {
|
||||||
|
'image': 'alpine',
|
||||||
|
'extra_hosts': "www.example.com: 192.168.0.17",
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
assert "'extra_hosts' contains an invalid type" in exc.exconly()
|
||||||
|
|
||||||
|
def test_validate_extra_hosts_invalid_list(self):
|
||||||
|
with pytest.raises(ConfigurationError) as exc:
|
||||||
|
config.load(build_config_details({
|
||||||
|
'web': {
|
||||||
|
'image': 'alpine',
|
||||||
|
'extra_hosts': [
|
||||||
|
{'www.example.com': '192.168.0.17'},
|
||||||
|
{'api.example.com': '192.168.0.18'}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
assert "which is an invalid type" in exc.exconly()
|
||||||
|
|
||||||
|
|
||||||
class InterpolationTest(unittest.TestCase):
|
class InterpolationTest(unittest.TestCase):
|
||||||
@mock.patch.dict(os.environ)
|
@mock.patch.dict(os.environ)
|
||||||
@ -603,14 +648,11 @@ class VolumeConfigTest(unittest.TestCase):
|
|||||||
@mock.patch.dict(os.environ)
|
@mock.patch.dict(os.environ)
|
||||||
def test_volume_binding_with_environment_variable(self):
|
def test_volume_binding_with_environment_variable(self):
|
||||||
os.environ['VOLUME_PATH'] = '/host/path'
|
os.environ['VOLUME_PATH'] = '/host/path'
|
||||||
d = config.load(
|
d = config.load(build_config_details(
|
||||||
build_config_details(
|
|
||||||
{'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}},
|
{'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}},
|
||||||
'.',
|
'.',
|
||||||
None,
|
))[0]
|
||||||
)
|
self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')])
|
||||||
)[0]
|
|
||||||
self.assertEqual(d['volumes'], ['/host/path:/container/path'])
|
|
||||||
|
|
||||||
@pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
|
@pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
|
||||||
@mock.patch.dict(os.environ)
|
@mock.patch.dict(os.environ)
|
||||||
@ -1008,19 +1050,21 @@ class EnvTest(unittest.TestCase):
|
|||||||
build_config_details(
|
build_config_details(
|
||||||
{'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}},
|
{'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}},
|
||||||
"tests/fixtures/env",
|
"tests/fixtures/env",
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
)[0]
|
)[0]
|
||||||
self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp']))
|
self.assertEqual(
|
||||||
|
set(service_dict['volumes']),
|
||||||
|
set([VolumeSpec.parse('/tmp:/host/tmp')]))
|
||||||
|
|
||||||
service_dict = config.load(
|
service_dict = config.load(
|
||||||
build_config_details(
|
build_config_details(
|
||||||
{'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}},
|
{'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}},
|
||||||
"tests/fixtures/env",
|
"tests/fixtures/env",
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
)[0]
|
)[0]
|
||||||
self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp']))
|
self.assertEqual(
|
||||||
|
set(service_dict['volumes']),
|
||||||
|
set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')]))
|
||||||
|
|
||||||
|
|
||||||
def load_from_filename(filename):
|
def load_from_filename(filename):
|
||||||
@ -1267,8 +1311,14 @@ class ExtendsTest(unittest.TestCase):
|
|||||||
dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml')
|
dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml')
|
||||||
|
|
||||||
paths = [
|
paths = [
|
||||||
'%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'),
|
VolumeSpec(
|
||||||
'%s:/bar' % os.path.abspath('tests/fixtures/volume-path/bar'),
|
os.path.abspath('tests/fixtures/volume-path/common/foo'),
|
||||||
|
'/foo',
|
||||||
|
'rw'),
|
||||||
|
VolumeSpec(
|
||||||
|
os.path.abspath('tests/fixtures/volume-path/bar'),
|
||||||
|
'/bar',
|
||||||
|
'rw')
|
||||||
]
|
]
|
||||||
|
|
||||||
self.assertEqual(set(dicts[0]['volumes']), set(paths))
|
self.assertEqual(set(dicts[0]['volumes']), set(paths))
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from .. import unittest
|
from compose.config.errors import DependencyError
|
||||||
from compose.project import DependencyError
|
from compose.config.sort_services import sort_service_dicts
|
||||||
from compose.project import sort_service_dicts
|
from compose.config.types import VolumeFromSpec
|
||||||
|
from tests import unittest
|
||||||
|
|
||||||
|
|
||||||
class SortServiceTest(unittest.TestCase):
|
class SortServiceTest(unittest.TestCase):
|
||||||
@ -73,7 +74,7 @@ class SortServiceTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'parent',
|
'name': 'parent',
|
||||||
'volumes_from': ['child']
|
'volumes_from': [VolumeFromSpec('child', 'rw')]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'links': ['parent'],
|
'links': ['parent'],
|
||||||
@ -116,7 +117,7 @@ class SortServiceTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'parent',
|
'name': 'parent',
|
||||||
'volumes_from': ['child']
|
'volumes_from': [VolumeFromSpec('child', 'ro')]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'child'
|
'name': 'child'
|
||||||
@ -141,7 +142,7 @@ class SortServiceTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'two',
|
'name': 'two',
|
||||||
'volumes_from': ['one']
|
'volumes_from': [VolumeFromSpec('one', 'rw')]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'one'
|
'name': 'one'
|
66
tests/unit/config/types_test.py
Normal file
66
tests/unit/config/types_test.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from compose.config.errors import ConfigurationError
|
||||||
|
from compose.config.types import parse_extra_hosts
|
||||||
|
from compose.config.types import VolumeSpec
|
||||||
|
from compose.const import IS_WINDOWS_PLATFORM
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_extra_hosts_list():
|
||||||
|
expected = {'www.example.com': '192.168.0.17'}
|
||||||
|
assert parse_extra_hosts(["www.example.com:192.168.0.17"]) == expected
|
||||||
|
|
||||||
|
expected = {'www.example.com': '192.168.0.17'}
|
||||||
|
assert parse_extra_hosts(["www.example.com: 192.168.0.17"]) == expected
|
||||||
|
|
||||||
|
assert parse_extra_hosts([
|
||||||
|
"www.example.com: 192.168.0.17",
|
||||||
|
"static.example.com:192.168.0.19",
|
||||||
|
"api.example.com: 192.168.0.18"
|
||||||
|
]) == {
|
||||||
|
'www.example.com': '192.168.0.17',
|
||||||
|
'static.example.com': '192.168.0.19',
|
||||||
|
'api.example.com': '192.168.0.18'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_extra_hosts_dict():
|
||||||
|
assert parse_extra_hosts({
|
||||||
|
'www.example.com': '192.168.0.17',
|
||||||
|
'api.example.com': '192.168.0.18'
|
||||||
|
}) == {
|
||||||
|
'www.example.com': '192.168.0.17',
|
||||||
|
'api.example.com': '192.168.0.18'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolumeSpec(object):
|
||||||
|
|
||||||
|
def test_parse_volume_spec_only_one_path(self):
|
||||||
|
spec = VolumeSpec.parse('/the/volume')
|
||||||
|
assert spec == (None, '/the/volume', 'rw')
|
||||||
|
|
||||||
|
def test_parse_volume_spec_internal_and_external(self):
|
||||||
|
spec = VolumeSpec.parse('external:interval')
|
||||||
|
assert spec == ('external', 'interval', 'rw')
|
||||||
|
|
||||||
|
def test_parse_volume_spec_with_mode(self):
|
||||||
|
spec = VolumeSpec.parse('external:interval:ro')
|
||||||
|
assert spec == ('external', 'interval', 'ro')
|
||||||
|
|
||||||
|
spec = VolumeSpec.parse('external:interval:z')
|
||||||
|
assert spec == ('external', 'interval', 'z')
|
||||||
|
|
||||||
|
def test_parse_volume_spec_too_many_parts(self):
|
||||||
|
with pytest.raises(ConfigurationError) as exc:
|
||||||
|
VolumeSpec.parse('one:two:three:four')
|
||||||
|
assert 'has incorrect format' in exc.exconly()
|
||||||
|
|
||||||
|
@pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive')
|
||||||
|
def test_parse_volume_windows_absolute_path(self):
|
||||||
|
windows_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro"
|
||||||
|
assert VolumeSpec.parse(windows_path) == (
|
||||||
|
"/c/Users/me/Documents/shiny/config",
|
||||||
|
"/opt/shiny/config",
|
||||||
|
"ro"
|
||||||
|
)
|
@ -4,6 +4,7 @@ import docker
|
|||||||
|
|
||||||
from .. import mock
|
from .. import mock
|
||||||
from .. import unittest
|
from .. import unittest
|
||||||
|
from compose.config.types import VolumeFromSpec
|
||||||
from compose.const import LABEL_SERVICE
|
from compose.const import LABEL_SERVICE
|
||||||
from compose.container import Container
|
from compose.container import Container
|
||||||
from compose.project import Project
|
from compose.project import Project
|
||||||
@ -33,29 +34,6 @@ class ProjectTest(unittest.TestCase):
|
|||||||
self.assertEqual(project.get_service('db').name, 'db')
|
self.assertEqual(project.get_service('db').name, 'db')
|
||||||
self.assertEqual(project.get_service('db').options['image'], 'busybox:latest')
|
self.assertEqual(project.get_service('db').options['image'], 'busybox:latest')
|
||||||
|
|
||||||
def test_from_dict_sorts_in_dependency_order(self):
|
|
||||||
project = Project.from_dicts('composetest', [
|
|
||||||
{
|
|
||||||
'name': 'web',
|
|
||||||
'image': 'busybox:latest',
|
|
||||||
'links': ['db'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'db',
|
|
||||||
'image': 'busybox:latest',
|
|
||||||
'volumes_from': ['volume']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'volume',
|
|
||||||
'image': 'busybox:latest',
|
|
||||||
'volumes': ['/tmp'],
|
|
||||||
}
|
|
||||||
], None)
|
|
||||||
|
|
||||||
self.assertEqual(project.services[0].name, 'volume')
|
|
||||||
self.assertEqual(project.services[1].name, 'db')
|
|
||||||
self.assertEqual(project.services[2].name, 'web')
|
|
||||||
|
|
||||||
def test_from_config(self):
|
def test_from_config(self):
|
||||||
dicts = [
|
dicts = [
|
||||||
{
|
{
|
||||||
@ -167,7 +145,7 @@ class ProjectTest(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
'name': 'test',
|
'name': 'test',
|
||||||
'image': 'busybox:latest',
|
'image': 'busybox:latest',
|
||||||
'volumes_from': ['aaa']
|
'volumes_from': [VolumeFromSpec('aaa', 'rw')]
|
||||||
}
|
}
|
||||||
], self.mock_client)
|
], self.mock_client)
|
||||||
self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"])
|
self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"])
|
||||||
@ -190,17 +168,13 @@ class ProjectTest(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
'name': 'test',
|
'name': 'test',
|
||||||
'image': 'busybox:latest',
|
'image': 'busybox:latest',
|
||||||
'volumes_from': ['vol']
|
'volumes_from': [VolumeFromSpec('vol', 'rw')]
|
||||||
}
|
}
|
||||||
], self.mock_client)
|
], self.mock_client)
|
||||||
self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"])
|
self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"])
|
||||||
|
|
||||||
@mock.patch.object(Service, 'containers')
|
def test_use_volumes_from_service_container(self):
|
||||||
def test_use_volumes_from_service_container(self, mock_return):
|
|
||||||
container_ids = ['aabbccddee', '12345']
|
container_ids = ['aabbccddee', '12345']
|
||||||
mock_return.return_value = [
|
|
||||||
mock.Mock(id=container_id, spec=Container)
|
|
||||||
for container_id in container_ids]
|
|
||||||
|
|
||||||
project = Project.from_dicts('test', [
|
project = Project.from_dicts('test', [
|
||||||
{
|
{
|
||||||
@ -210,10 +184,16 @@ class ProjectTest(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
'name': 'test',
|
'name': 'test',
|
||||||
'image': 'busybox:latest',
|
'image': 'busybox:latest',
|
||||||
'volumes_from': ['vol']
|
'volumes_from': [VolumeFromSpec('vol', 'rw')]
|
||||||
}
|
}
|
||||||
], None)
|
], None)
|
||||||
self.assertEqual(project.get_service('test')._get_volumes_from(), [container_ids[0] + ':rw'])
|
with mock.patch.object(Service, 'containers') as mock_return:
|
||||||
|
mock_return.return_value = [
|
||||||
|
mock.Mock(id=container_id, spec=Container)
|
||||||
|
for container_id in container_ids]
|
||||||
|
self.assertEqual(
|
||||||
|
project.get_service('test')._get_volumes_from(),
|
||||||
|
[container_ids[0] + ':rw'])
|
||||||
|
|
||||||
def test_net_unset(self):
|
def test_net_unset(self):
|
||||||
project = Project.from_dicts('test', [
|
project = Project.from_dicts('test', [
|
||||||
|
@ -2,11 +2,11 @@ from __future__ import absolute_import
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .. import mock
|
from .. import mock
|
||||||
from .. import unittest
|
from .. import unittest
|
||||||
from compose.const import IS_WINDOWS_PLATFORM
|
from compose.config.types import VolumeFromSpec
|
||||||
|
from compose.config.types import VolumeSpec
|
||||||
from compose.const import LABEL_CONFIG_HASH
|
from compose.const import LABEL_CONFIG_HASH
|
||||||
from compose.const import LABEL_ONE_OFF
|
from compose.const import LABEL_ONE_OFF
|
||||||
from compose.const import LABEL_PROJECT
|
from compose.const import LABEL_PROJECT
|
||||||
@ -14,7 +14,6 @@ from compose.const import LABEL_SERVICE
|
|||||||
from compose.container import Container
|
from compose.container import Container
|
||||||
from compose.service import build_ulimits
|
from compose.service import build_ulimits
|
||||||
from compose.service import build_volume_binding
|
from compose.service import build_volume_binding
|
||||||
from compose.service import ConfigError
|
|
||||||
from compose.service import ContainerNet
|
from compose.service import ContainerNet
|
||||||
from compose.service import get_container_data_volumes
|
from compose.service import get_container_data_volumes
|
||||||
from compose.service import merge_volume_bindings
|
from compose.service import merge_volume_bindings
|
||||||
@ -22,11 +21,8 @@ from compose.service import NeedsBuildError
|
|||||||
from compose.service import Net
|
from compose.service import Net
|
||||||
from compose.service import NoSuchImageError
|
from compose.service import NoSuchImageError
|
||||||
from compose.service import parse_repository_tag
|
from compose.service import parse_repository_tag
|
||||||
from compose.service import parse_volume_spec
|
|
||||||
from compose.service import Service
|
from compose.service import Service
|
||||||
from compose.service import ServiceNet
|
from compose.service import ServiceNet
|
||||||
from compose.service import VolumeFromSpec
|
|
||||||
from compose.service import VolumeSpec
|
|
||||||
from compose.service import warn_on_masked_volume
|
from compose.service import warn_on_masked_volume
|
||||||
|
|
||||||
|
|
||||||
@ -35,11 +31,6 @@ class ServiceTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.mock_client = mock.create_autospec(docker.Client)
|
self.mock_client = mock.create_autospec(docker.Client)
|
||||||
|
|
||||||
def test_project_validation(self):
|
|
||||||
self.assertRaises(ConfigError, lambda: Service(name='foo', project='>', image='foo'))
|
|
||||||
|
|
||||||
Service(name='foo', project='bar.bar__', image='foo')
|
|
||||||
|
|
||||||
def test_containers(self):
|
def test_containers(self):
|
||||||
service = Service('db', self.mock_client, 'myproject', image='foo')
|
service = Service('db', self.mock_client, 'myproject', image='foo')
|
||||||
self.mock_client.containers.return_value = []
|
self.mock_client.containers.return_value = []
|
||||||
@ -589,46 +580,12 @@ class ServiceVolumesTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.mock_client = mock.create_autospec(docker.Client)
|
self.mock_client = mock.create_autospec(docker.Client)
|
||||||
|
|
||||||
def test_parse_volume_spec_only_one_path(self):
|
|
||||||
spec = parse_volume_spec('/the/volume')
|
|
||||||
self.assertEqual(spec, (None, '/the/volume', 'rw'))
|
|
||||||
|
|
||||||
def test_parse_volume_spec_internal_and_external(self):
|
|
||||||
spec = parse_volume_spec('external:interval')
|
|
||||||
self.assertEqual(spec, ('external', 'interval', 'rw'))
|
|
||||||
|
|
||||||
def test_parse_volume_spec_with_mode(self):
|
|
||||||
spec = parse_volume_spec('external:interval:ro')
|
|
||||||
self.assertEqual(spec, ('external', 'interval', 'ro'))
|
|
||||||
|
|
||||||
spec = parse_volume_spec('external:interval:z')
|
|
||||||
self.assertEqual(spec, ('external', 'interval', 'z'))
|
|
||||||
|
|
||||||
def test_parse_volume_spec_too_many_parts(self):
|
|
||||||
with self.assertRaises(ConfigError):
|
|
||||||
parse_volume_spec('one:two:three:four')
|
|
||||||
|
|
||||||
@pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive')
|
|
||||||
def test_parse_volume_windows_absolute_path(self):
|
|
||||||
windows_absolute_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro"
|
|
||||||
|
|
||||||
spec = parse_volume_spec(windows_absolute_path)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
spec,
|
|
||||||
(
|
|
||||||
"/c/Users/me/Documents/shiny/config",
|
|
||||||
"/opt/shiny/config",
|
|
||||||
"ro"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_build_volume_binding(self):
|
def test_build_volume_binding(self):
|
||||||
binding = build_volume_binding(parse_volume_spec('/outside:/inside'))
|
binding = build_volume_binding(VolumeSpec.parse('/outside:/inside'))
|
||||||
self.assertEqual(binding, ('/inside', '/outside:/inside:rw'))
|
assert binding == ('/inside', '/outside:/inside:rw')
|
||||||
|
|
||||||
def test_get_container_data_volumes(self):
|
def test_get_container_data_volumes(self):
|
||||||
options = [parse_volume_spec(v) for v in [
|
options = [VolumeSpec.parse(v) for v in [
|
||||||
'/host/volume:/host/volume:ro',
|
'/host/volume:/host/volume:ro',
|
||||||
'/new/volume',
|
'/new/volume',
|
||||||
'/existing/volume',
|
'/existing/volume',
|
||||||
@ -652,19 +609,19 @@ class ServiceVolumesTest(unittest.TestCase):
|
|||||||
}, has_been_inspected=True)
|
}, has_been_inspected=True)
|
||||||
|
|
||||||
expected = [
|
expected = [
|
||||||
parse_volume_spec('/var/lib/docker/aaaaaaaa:/existing/volume:rw'),
|
VolumeSpec.parse('/var/lib/docker/aaaaaaaa:/existing/volume:rw'),
|
||||||
parse_volume_spec('/var/lib/docker/cccccccc:/mnt/image/data:rw'),
|
VolumeSpec.parse('/var/lib/docker/cccccccc:/mnt/image/data:rw'),
|
||||||
]
|
]
|
||||||
|
|
||||||
volumes = get_container_data_volumes(container, options)
|
volumes = get_container_data_volumes(container, options)
|
||||||
self.assertEqual(sorted(volumes), sorted(expected))
|
assert sorted(volumes) == sorted(expected)
|
||||||
|
|
||||||
def test_merge_volume_bindings(self):
|
def test_merge_volume_bindings(self):
|
||||||
options = [
|
options = [
|
||||||
'/host/volume:/host/volume:ro',
|
VolumeSpec.parse('/host/volume:/host/volume:ro'),
|
||||||
'/host/rw/volume:/host/rw/volume',
|
VolumeSpec.parse('/host/rw/volume:/host/rw/volume'),
|
||||||
'/new/volume',
|
VolumeSpec.parse('/new/volume'),
|
||||||
'/existing/volume',
|
VolumeSpec.parse('/existing/volume'),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.mock_client.inspect_image.return_value = {
|
self.mock_client.inspect_image.return_value = {
|
||||||
@ -690,8 +647,8 @@ class ServiceVolumesTest(unittest.TestCase):
|
|||||||
'web',
|
'web',
|
||||||
image='busybox',
|
image='busybox',
|
||||||
volumes=[
|
volumes=[
|
||||||
'/host/path:/data1',
|
VolumeSpec.parse('/host/path:/data1'),
|
||||||
'/host/path:/data2',
|
VolumeSpec.parse('/host/path:/data2'),
|
||||||
],
|
],
|
||||||
client=self.mock_client,
|
client=self.mock_client,
|
||||||
)
|
)
|
||||||
@ -720,7 +677,7 @@ class ServiceVolumesTest(unittest.TestCase):
|
|||||||
service = Service(
|
service = Service(
|
||||||
'web',
|
'web',
|
||||||
image='busybox',
|
image='busybox',
|
||||||
volumes=['/host/path:/data'],
|
volumes=[VolumeSpec.parse('/host/path:/data')],
|
||||||
client=self.mock_client,
|
client=self.mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -788,22 +745,17 @@ class ServiceVolumesTest(unittest.TestCase):
|
|||||||
def test_create_with_special_volume_mode(self):
|
def test_create_with_special_volume_mode(self):
|
||||||
self.mock_client.inspect_image.return_value = {'Id': 'imageid'}
|
self.mock_client.inspect_image.return_value = {'Id': 'imageid'}
|
||||||
|
|
||||||
create_calls = []
|
self.mock_client.create_container.return_value = {'Id': 'containerid'}
|
||||||
|
|
||||||
def create_container(*args, **kwargs):
|
|
||||||
create_calls.append((args, kwargs))
|
|
||||||
return {'Id': 'containerid'}
|
|
||||||
|
|
||||||
self.mock_client.create_container = create_container
|
|
||||||
|
|
||||||
volumes = ['/tmp:/foo:z']
|
|
||||||
|
|
||||||
|
volume = '/tmp:/foo:z'
|
||||||
Service(
|
Service(
|
||||||
'web',
|
'web',
|
||||||
client=self.mock_client,
|
client=self.mock_client,
|
||||||
image='busybox',
|
image='busybox',
|
||||||
volumes=volumes,
|
volumes=[VolumeSpec.parse(volume)],
|
||||||
).create_container()
|
).create_container()
|
||||||
|
|
||||||
self.assertEqual(len(create_calls), 1)
|
assert self.mock_client.create_container.call_count == 1
|
||||||
self.assertEqual(self.mock_client.create_host_config.call_args[1]['binds'], volumes)
|
self.assertEqual(
|
||||||
|
self.mock_client.create_host_config.call_args[1]['binds'],
|
||||||
|
[volume])
|
||||||
|
Loading…
x
Reference in New Issue
Block a user