Merge pull request #5449 from docker/bump-1.18.0-rc1

Bump 1.18.0 RC1
This commit is contained in:
Joffrey F 2017-12-06 18:50:27 -08:00 committed by GitHub
commit 8fc6ac1899
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1649 additions and 279 deletions

View File

@ -8,6 +8,7 @@ matrix:
services:
- docker
- os: osx
osx_image: xcode7.3
language: generic
install: ./script/travis/install

View File

@ -1,6 +1,86 @@
Change log
==========
1.18.0 (2017-12-15)
-------------------
### New features
#### Compose file version 3.5
- Introduced version 3.5 of the `docker-compose.yml` specification.
This version requires to be used with Docker Engine 17.06.0 or above
- Added support for the `shm_size` parameter in build configurations
- Added support for the `isolation` parameter in service definitions
- Added support for custom names for network, secret and config definitions
#### Compose file version 2.3
- Added support for `extra_hosts` in build configuration
- Added support for the
[long syntax](https://docs.docker.com/compose/compose-file/#long-syntax-3)
for volume entries, as previously introduced in the 3.2 format.
Note that using this syntax will create
[mounts](https://docs.docker.com/engine/admin/volumes/bind-mounts/)
instead of volumes.
#### Compose file version 2.1 and up
- Added support for the `oom_kill_disable` parameter in service definitions
(2.x only)
- Added support for custom names for network, secret and config definitions
(2.x only)
#### All formats
- Values interpolated from the environment will now be converted to the
proper type when used in non-string fields.
- Added support for `--labels` in `docker-compose run`
- Added support for `--timeout` in `docker-compose down`
- Added support for `--memory` in `docker-compose build`
- Setting `stop_grace_period` in service definitions now also sets the
container's `stop_timeout`
### Bugfixes
- Fixed an issue where Compose was still handling service hostname according
to legacy engine behavior, causing hostnames containing dots to be cut up
- Fixed a bug where the `X-Y:Z` syntax for ports was considered invalid
by Compose
- Fixed an issue with CLI logging causing duplicate messages and inelegant
output to occur
- Fixed a bug where the valid `${VAR:-}` syntax would cause Compose to
error out
- Fixed a bug where `env_file` entries using an UTF-8 BOM were being read
incorrectly
- Fixed a bug where missing secret files would generate an empty directory
in their place
- Added validation for the `test` field in healthchecks
- Added validation for the `subnet` field in IPAM configurations
- Added validation for `volumes` properties when using the long syntax in
service definitions
- The CLI now explicit prevents using `-d` and `--timeout` together
in `docker-compose up`
1.17.1 (2017-11-08)
------------------

View File

@ -64,8 +64,8 @@ you can specify a test directory, file, module, class or method:
$ script/test/default tests/unit
$ script/test/default tests/unit/cli_test.py
$ script/test/default tests/unit/config_test.py::ConfigTest
$ script/test/default tests/unit/config_test.py::ConfigTest::test_load
$ script/test/default tests/unit/config/config_test.py::ConfigTest
$ script/test/default tests/unit/config/config_test.py::ConfigTest::test_load
## Finding things to work on

View File

@ -1,4 +1,4 @@
from __future__ import absolute_import
from __future__ import unicode_literals
__version__ = '1.17.1'
__version__ = '1.18.0-rc1'

View File

@ -14,6 +14,8 @@ from distutils.spawn import find_executable
from inspect import getdoc
from operator import attrgetter
import docker
from . import errors
from . import signals
from .. import __version__
@ -22,6 +24,7 @@ from ..bundle import MissingDigests
from ..bundle import serialize_bundle
from ..config import ConfigurationError
from ..config import parse_environment
from ..config import parse_labels
from ..config import resolve_build_args
from ..config.environment import Environment
from ..config.serialize import serialize_config
@ -230,6 +233,7 @@ class TopLevelCommand(object):
--force-rm Always remove intermediate containers.
--no-cache Do not use cache when building the image.
--pull Always attempt to pull a newer version of the image.
-m, --memory MEM Sets memory limit for the bulid container.
--build-arg key=val Set build-time variables for one service.
"""
service_names = options['SERVICE']
@ -246,6 +250,7 @@ class TopLevelCommand(object):
no_cache=bool(options.get('--no-cache', False)),
pull=bool(options.get('--pull', False)),
force_rm=bool(options.get('--force-rm', False)),
memory=options.get('--memory'),
build_args=build_args)
def bundle(self, config_options, options):
@ -369,9 +374,12 @@ class TopLevelCommand(object):
attached to containers.
--remove-orphans Remove containers for services not defined in the
Compose file
-t, --timeout TIMEOUT Specify a shutdown timeout in seconds.
(default: 10)
"""
image_type = image_type_from_opt('--rmi', options['--rmi'])
self.project.down(image_type, options['--volumes'], options['--remove-orphans'])
timeout = timeout_from_opts(options)
self.project.down(image_type, options['--volumes'], options['--remove-orphans'], timeout=timeout)
def events(self, options):
"""
@ -402,7 +410,7 @@ class TopLevelCommand(object):
"""
Execute a command in a running container
Usage: exec [options] SERVICE COMMAND [ARGS...]
Usage: exec [options] [-e KEY=VAL...] SERVICE COMMAND [ARGS...]
Options:
-d Detached mode: Run command in the background.
@ -412,11 +420,16 @@ class TopLevelCommand(object):
allocates a TTY.
--index=index index of the container if there are multiple
instances of a service [default: 1]
-e, --env KEY=VAL Set environment variables (can be used multiple times,
not supported in API < 1.25)
"""
index = int(options.get('--index'))
service = self.project.get_service(options['SERVICE'])
detach = options['-d']
if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'):
raise UserError("Setting environment for exec is not supported in API < 1.25'")
try:
container = service.get_container(number=index)
except ValueError as e:
@ -425,26 +438,7 @@ class TopLevelCommand(object):
tty = not options["-T"]
if IS_WINDOWS_PLATFORM and not detach:
args = ["exec"]
if options["-d"]:
args += ["--detach"]
else:
args += ["--interactive"]
if not options["-T"]:
args += ["--tty"]
if options["--privileged"]:
args += ["--privileged"]
if options["--user"]:
args += ["--user", options["--user"]]
args += [container.id]
args += command
sys.exit(call_docker(args))
sys.exit(call_docker(build_exec_command(options, container.id, command)))
create_exec_options = {
"privileged": options["--privileged"],
@ -453,6 +447,9 @@ class TopLevelCommand(object):
"stdin": tty,
}
if docker.utils.version_gte(self.project.client.api_version, '1.25'):
create_exec_options["environment"] = options["--env"]
exec_id = container.create_exec(command, **create_exec_options)
if detach:
@ -729,7 +726,9 @@ class TopLevelCommand(object):
running. If you do not want to start linked services, use
`docker-compose run --no-deps SERVICE COMMAND [ARGS...]`.
Usage: run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]
Usage:
run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...]
SERVICE [COMMAND] [ARGS...]
Options:
-d Detached mode: Run container in the background, print
@ -737,6 +736,7 @@ class TopLevelCommand(object):
--name NAME Assign a name to the container
--entrypoint CMD Override the entrypoint of the image.
-e KEY=VAL Set an environment variable (can be used multiple times)
-l, --label KEY=VAL Add or override a label (can be used multiple times)
-u, --user="" Run as specified username or uid
--no-deps Don't start linked services.
--rm Remove container after run. Ignored in detached mode.
@ -898,8 +898,8 @@ class TopLevelCommand(object):
Options:
-d Detached mode: Run containers in the background,
print new container names.
Incompatible with --abort-on-container-exit.
print new container names. Incompatible with
--abort-on-container-exit and --timeout.
--no-color Produce monochrome output.
--no-deps Don't start linked services.
--force-recreate Recreate containers even if their configuration
@ -913,7 +913,8 @@ class TopLevelCommand(object):
--abort-on-container-exit Stops all containers if any container was stopped.
Incompatible with -d.
-t, --timeout TIMEOUT Use this timeout in seconds for container shutdown
when attached or when containers are already
when attached or when containers are already.
Incompatible with -d.
running. (default: 10)
--remove-orphans Remove containers for services not
defined in the Compose file
@ -934,6 +935,9 @@ class TopLevelCommand(object):
if detached and (cascade_stop or exit_value_from):
raise UserError("--abort-on-container-exit and -d cannot be combined.")
if detached and timeout:
raise UserError("-d and --timeout cannot be combined.")
if no_start:
for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']:
if options.get(excluded):
@ -1131,6 +1135,9 @@ def build_container_options(options, detach, command):
parse_environment(options['-e'])
)
if options['--label']:
container_options['labels'] = parse_labels(options['--label'])
if options['--entrypoint']:
container_options['entrypoint'] = options.get('--entrypoint')
@ -1295,3 +1302,29 @@ def parse_scale_args(options):
)
res[service_name] = num
return res
def build_exec_command(options, container_id, command):
args = ["exec"]
if options["-d"]:
args += ["--detach"]
else:
args += ["--interactive"]
if not options["-T"]:
args += ["--tty"]
if options["--privileged"]:
args += ["--privileged"]
if options["--user"]:
args += ["--user", options["--user"]]
if options["--env"]:
for env_variable in options["--env"]:
args += ["--env", env_variable]
args += [container_id]
args += command
return args

View File

@ -8,5 +8,7 @@ from .config import DOCKER_CONFIG_KEYS
from .config import find
from .config import load
from .config import merge_environment
from .config import merge_labels
from .config import parse_environment
from .config import parse_labels
from .config import resolve_build_args

View File

@ -35,6 +35,7 @@ from .interpolation import interpolate_environment_variables
from .sort_services import get_container_name_from_network_mode
from .sort_services import get_service_name_from_network_mode
from .sort_services import sort_service_dicts
from .types import MountSpec
from .types import parse_extra_hosts
from .types import parse_restart_spec
from .types import ServiceLink
@ -47,6 +48,7 @@ from .validation import validate_config_section
from .validation import validate_cpu
from .validation import validate_depends_on
from .validation import validate_extends_file_path
from .validation import validate_healthcheck
from .validation import validate_links
from .validation import validate_network_mode
from .validation import validate_pid_mode
@ -90,6 +92,7 @@ DOCKER_CONFIG_KEYS = [
'mem_swappiness',
'net',
'oom_score_adj',
'oom_kill_disable',
'pid',
'ports',
'privileged',
@ -407,12 +410,11 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None):
external = config.get('external')
if external:
name_field = 'name' if entity_type == 'Volume' else 'external_name'
validate_external(entity_type, name, config, config_file.version)
if isinstance(external, dict):
config[name_field] = external.get('name')
config['name'] = external.get('name')
elif not config.get('name'):
config[name_field] = name
config['name'] = name
if 'driver_opts' in config:
config['driver_opts'] = build_string_dict(
@ -519,13 +521,13 @@ def process_config_file(config_file, environment, service_name=None):
processed_config['secrets'] = interpolate_config_section(
config_file,
config_file.get_secrets(),
'secrets',
'secret',
environment)
if config_file.version >= const.COMPOSEFILE_V3_3:
processed_config['configs'] = interpolate_config_section(
config_file,
config_file.get_configs(),
'configs',
'config',
environment
)
else:
@ -686,6 +688,7 @@ def validate_service(service_config, service_names, config_file):
validate_pid_mode(service_config, service_names)
validate_depends_on(service_config, service_names)
validate_links(service_config, service_names)
validate_healthcheck(service_config)
if not service_dict.get('image') and has_uppercase(service_name):
raise ConfigurationError(
@ -724,7 +727,7 @@ def process_service(service_config):
service_dict[field] = to_list(service_dict[field])
service_dict = process_blkio_config(process_ports(
process_healthcheck(service_dict, service_config.name)
process_healthcheck(service_dict)
))
return service_dict
@ -788,33 +791,35 @@ def process_blkio_config(service_dict):
return service_dict
def process_healthcheck(service_dict, service_name):
def process_healthcheck(service_dict):
if 'healthcheck' not in service_dict:
return service_dict
hc = {}
raw = service_dict['healthcheck']
hc = service_dict['healthcheck']
if raw.get('disable'):
if len(raw) > 1:
raise ConfigurationError(
'Service "{}" defines an invalid healthcheck: '
'"disable: true" cannot be combined with other options'
.format(service_name))
if 'disable' in hc:
del hc['disable']
hc['test'] = ['NONE']
elif 'test' in raw:
hc['test'] = raw['test']
for field in ['interval', 'timeout', 'start_period']:
if field in raw:
if not isinstance(raw[field], six.integer_types):
hc[field] = parse_nanoseconds_int(raw[field])
else: # Conversion has been done previously
hc[field] = raw[field]
if 'retries' in raw:
hc['retries'] = raw['retries']
if field not in hc or isinstance(hc[field], six.integer_types):
continue
hc[field] = parse_nanoseconds_int(hc[field])
return service_dict
def finalize_service_volumes(service_dict, environment):
if 'volumes' in service_dict:
finalized_volumes = []
normalize = environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS')
for v in service_dict['volumes']:
if isinstance(v, dict):
finalized_volumes.append(MountSpec.parse(v, normalize))
else:
finalized_volumes.append(VolumeSpec.parse(v, normalize))
service_dict['volumes'] = finalized_volumes
service_dict['healthcheck'] = hc
return service_dict
@ -831,12 +836,7 @@ def finalize_service(service_config, service_names, version, environment):
for vf in service_dict['volumes_from']
]
if 'volumes' in service_dict:
service_dict['volumes'] = [
VolumeSpec.parse(
v, environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS')
) for v in service_dict['volumes']
]
service_dict = finalize_service_volumes(service_dict, environment)
if 'net' in service_dict:
network_mode = service_dict.pop('net')
@ -1032,6 +1032,7 @@ def merge_build(output, base, override):
md.merge_mapping('args', parse_build_arguments)
md.merge_field('cache_from', merge_unique_items_lists, default=[])
md.merge_mapping('labels', parse_labels)
md.merge_mapping('extra_hosts', parse_extra_hosts)
return dict(md)
@ -1084,6 +1085,12 @@ def merge_environment(base, override):
return env
def merge_labels(base, override):
labels = parse_labels(base)
labels.update(parse_labels(override))
return labels
def split_kv(kvpair):
if '=' in kvpair:
return kvpair.split('=', 1)
@ -1145,19 +1152,13 @@ def resolve_volume_paths(working_dir, service_dict):
def resolve_volume_path(working_dir, volume):
mount_params = None
if isinstance(volume, dict):
container_path = volume.get('target')
host_path = volume.get('source')
mode = None
if host_path:
if volume.get('read_only'):
mode = 'ro'
if volume.get('volume', {}).get('nocopy'):
mode = 'nocopy'
mount_params = (host_path, mode)
else:
container_path, mount_params = split_path_mapping(volume)
if volume.get('source', '').startswith('.') and volume['type'] == 'mount':
volume['source'] = expand_path(working_dir, volume['source'])
return volume
mount_params = None
container_path, mount_params = split_path_mapping(volume)
if mount_params is not None:
host_path, mode = mount_params

View File

@ -229,6 +229,7 @@
}
]
},
"oom_kill_disable": {"type": "boolean"},
"oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000},
"group_add": {
"type": "array",
@ -349,7 +350,8 @@
},
"internal": {"type": "boolean"},
"enable_ipv6": {"type": "boolean"},
"labels": {"$ref": "#/definitions/list_or_dict"}
"labels": {"$ref": "#/definitions/list_or_dict"},
"name": {"type": "string"}
},
"additionalProperties": false
},

View File

@ -235,6 +235,7 @@
}
]
},
"oom_kill_disable": {"type": "boolean"},
"oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000},
"group_add": {
"type": "array",
@ -356,7 +357,8 @@
},
"internal": {"type": "boolean"},
"enable_ipv6": {"type": "boolean"},
"labels": {"$ref": "#/definitions/list_or_dict"}
"labels": {"$ref": "#/definitions/list_or_dict"},
"name": {"type": "string"}
},
"additionalProperties": false
},

View File

@ -92,7 +92,8 @@
"cache_from": {"$ref": "#/definitions/list_of_strings"},
"network": {"type": "string"},
"target": {"type": "string"},
"shm_size": {"type": ["integer", "string"]}
"shm_size": {"type": ["integer", "string"]},
"extra_hosts": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false
}
@ -237,6 +238,7 @@
}
]
},
"oom_kill_disable": {"type": "boolean"},
"oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000},
"group_add": {
"type": "array",
@ -291,7 +293,39 @@
},
"user": {"type": "string"},
"userns_mode": {"type": "string"},
"volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"volumes": {
"type": "array",
"items": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {"type": "string"},
"source": {"type": "string"},
"target": {"type": "string"},
"read_only": {"type": "boolean"},
"consistency": {"type": "string"},
"bind": {
"type": "object",
"properties": {
"propagation": {"type": "string"}
}
},
"volume": {
"type": "object",
"properties": {
"nocopy": {"type": "boolean"}
}
}
}
}
],
"uniqueItems": true
}
},
"volume_driver": {"type": "string"},
"volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"working_dir": {"type": "string"}
@ -359,7 +393,8 @@
},
"internal": {"type": "boolean"},
"enable_ipv6": {"type": "boolean"},
"labels": {"$ref": "#/definitions/list_or_dict"}
"labels": {"$ref": "#/definitions/list_or_dict"},
"name": {"type": "string"}
},
"additionalProperties": false
},

View File

@ -294,7 +294,7 @@
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
"subnet": {"type": "string", "format": "subnet_ip_address"}
},
"additionalProperties": false
}

View File

@ -323,7 +323,7 @@
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
"subnet": {"type": "string", "format": "subnet_ip_address"}
},
"additionalProperties": false
}

View File

@ -245,6 +245,7 @@
{
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {"type": "string"},
"source": {"type": "string"},
@ -369,7 +370,7 @@
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
"subnet": {"type": "string", "format": "subnet_ip_address"}
},
"additionalProperties": false
}

View File

@ -278,6 +278,7 @@
{
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {"type": "string"},
"source": {"type": "string"},
@ -412,7 +413,7 @@
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
"subnet": {"type": "string", "format": "subnet_ip_address"}
},
"additionalProperties": false
}

View File

@ -282,6 +282,7 @@
{
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {"type": "string"},
"source": {"type": "string"},
@ -420,7 +421,7 @@
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
"subnet": {"type": "string", "format": "subnet_ip_address"}
},
"additionalProperties": false
}

View File

@ -64,6 +64,7 @@
}
},
"patternProperties": {"^x-": {}},
"additionalProperties": false,
"definitions": {
@ -154,6 +155,7 @@
"hostname": {"type": "string"},
"image": {"type": "string"},
"ipc": {"type": "string"},
"isolation": {"type": "string"},
"labels": {"$ref": "#/definitions/list_or_dict"},
"links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
@ -299,7 +301,8 @@
"nocopy": {"type": "boolean"}
}
}
}
},
"additionalProperties": false
}
],
"uniqueItems": true
@ -316,7 +319,7 @@
"additionalProperties": false,
"properties": {
"disable": {"type": "boolean"},
"interval": {"type": "string"},
"interval": {"type": "string", "format": "duration"},
"retries": {"type": "number"},
"test": {
"oneOf": [
@ -324,7 +327,8 @@
{"type": "array", "items": {"type": "string"}}
]
},
"timeout": {"type": "string"}
"timeout": {"type": "string", "format": "duration"},
"start_period": {"type": "string", "format": "duration"}
}
},
"deployment": {
@ -352,8 +356,23 @@
"resources": {
"type": "object",
"properties": {
"limits": {"$ref": "#/definitions/resource"},
"reservations": {"$ref": "#/definitions/resource"}
"limits": {
"type": "object",
"properties": {
"cpus": {"type": "string"},
"memory": {"type": "string"}
},
"additionalProperties": false
},
"reservations": {
"type": "object",
"properties": {
"cpus": {"type": "string"},
"memory": {"type": "string"},
"generic_resources": {"$ref": "#/definitions/generic_resources"}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
@ -388,20 +407,30 @@
"additionalProperties": false
},
"resource": {
"id": "#/definitions/resource",
"type": "object",
"properties": {
"cpus": {"type": "string"},
"memory": {"type": "string"}
},
"additionalProperties": false
"generic_resources": {
"id": "#/definitions/generic_resources",
"type": "array",
"items": {
"type": "object",
"properties": {
"discrete_resource_spec": {
"type": "object",
"properties": {
"kind": {"type": "string"},
"value": {"type": "number"}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"network": {
"id": "#/definitions/network",
"type": ["object", "null"],
"properties": {
"name": {"type": "string"},
"driver": {"type": "string"},
"driver_opts": {
"type": "object",
@ -418,7 +447,7 @@
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
"subnet": {"type": "string", "format": "subnet_ip_address"}
},
"additionalProperties": false
}
@ -468,6 +497,7 @@
"id": "#/definitions/secret",
"type": "object",
"properties": {
"name": {"type": "string"},
"file": {"type": "string"},
"external": {
"type": ["boolean", "object"],
@ -484,6 +514,7 @@
"id": "#/definitions/config",
"type": "object",
"properties": {
"name": {"type": "string"},
"file": {"type": "string"},
"external": {
"type": ["boolean", "object"],

View File

@ -32,7 +32,7 @@ def env_vars_from_file(filename):
elif not os.path.isfile(filename):
raise ConfigurationError("%s is not a file." % (filename))
env = {}
with contextlib.closing(codecs.open(filename, 'r', 'utf-8')) as fileobj:
with contextlib.closing(codecs.open(filename, 'r', 'utf-8-sig')) as fileobj:
for line in fileobj:
line = line.strip()
if line and not line.startswith('#'):

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import
from __future__ import unicode_literals
import logging
import re
from string import Template
import six
@ -44,9 +45,13 @@ def interpolate_environment_variables(version, config, section, environment):
)
def get_config_path(config_key, section, name):
return '{}.{}.{}'.format(section, name, config_key)
def interpolate_value(name, config_key, value, section, interpolator):
try:
return recursive_interpolate(value, interpolator)
return recursive_interpolate(value, interpolator, get_config_path(config_key, section, name))
except InvalidInterpolation as e:
raise ConfigurationError(
'Invalid interpolation format for "{config_key}" option '
@ -57,21 +62,24 @@ def interpolate_value(name, config_key, value, section, interpolator):
string=e.string))
def recursive_interpolate(obj, interpolator):
def recursive_interpolate(obj, interpolator, config_path):
def append(config_path, key):
return '{}.{}'.format(config_path, key)
if isinstance(obj, six.string_types):
return interpolator.interpolate(obj)
return converter.convert(config_path, interpolator.interpolate(obj))
if isinstance(obj, dict):
return dict(
(key, recursive_interpolate(val, interpolator))
(key, recursive_interpolate(val, interpolator, append(config_path, key)))
for (key, val) in obj.items()
)
if isinstance(obj, list):
return [recursive_interpolate(val, interpolator) for val in obj]
return [recursive_interpolate(val, interpolator, config_path) for val in obj]
return obj
class TemplateWithDefaults(Template):
idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]+)?'
idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]*)?'
# Modified from python2.7/string.py
def substitute(self, mapping):
@ -100,3 +108,81 @@ class TemplateWithDefaults(Template):
class InvalidInterpolation(Exception):
def __init__(self, string):
self.string = string
PATH_JOKER = '[^.]+'
def re_path(*args):
return re.compile('^{}$'.format('.'.join(args)))
def re_path_basic(section, name):
return re_path(section, PATH_JOKER, name)
def service_path(*args):
return re_path('service', PATH_JOKER, *args)
def to_boolean(s):
s = s.lower()
if s in ['y', 'yes', 'true', 'on']:
return True
elif s in ['n', 'no', 'false', 'off']:
return False
raise ValueError('"{}" is not a valid boolean value'.format(s))
def to_int(s):
# We must be able to handle octal representation for `mode` values notably
if six.PY3 and re.match('^0[0-9]+$', s.strip()):
s = '0o' + s[1:]
return int(s, base=0)
class ConversionMap(object):
map = {
service_path('blkio_config', 'weight'): to_int,
service_path('blkio_config', 'weight_device', 'weight'): to_int,
service_path('cpus'): float,
service_path('cpu_count'): to_int,
service_path('configs', 'mode'): to_int,
service_path('secrets', 'mode'): to_int,
service_path('healthcheck', 'retries'): to_int,
service_path('healthcheck', 'disable'): to_boolean,
service_path('deploy', 'replicas'): to_int,
service_path('deploy', 'update_config', 'parallelism'): to_int,
service_path('deploy', 'update_config', 'max_failure_ratio'): float,
service_path('deploy', 'restart_policy', 'max_attempts'): to_int,
service_path('mem_swappiness'): to_int,
service_path('oom_kill_disable'): to_boolean,
service_path('oom_score_adj'): to_int,
service_path('ports', 'target'): to_int,
service_path('ports', 'published'): to_int,
service_path('scale'): to_int,
service_path('ulimits', PATH_JOKER): to_int,
service_path('ulimits', PATH_JOKER, 'soft'): to_int,
service_path('ulimits', PATH_JOKER, 'hard'): to_int,
service_path('privileged'): to_boolean,
service_path('read_only'): to_boolean,
service_path('stdin_open'): to_boolean,
service_path('tty'): to_boolean,
service_path('volumes', 'read_only'): to_boolean,
service_path('volumes', 'volume', 'nocopy'): to_boolean,
re_path_basic('network', 'attachable'): to_boolean,
re_path_basic('network', 'external'): to_boolean,
re_path_basic('network', 'internal'): to_boolean,
re_path_basic('volume', 'external'): to_boolean,
re_path_basic('secret', 'external'): to_boolean,
re_path_basic('config', 'external'): to_boolean,
}
def convert(self, path, value):
for rexp in self.map.keys():
if rexp.match(path):
return self.map[rexp](value)
return value
converter = ConversionMap()

View File

@ -7,9 +7,11 @@ import yaml
from compose.config import types
from compose.const import COMPOSEFILE_V1 as V1
from compose.const import COMPOSEFILE_V2_1 as V2_1
from compose.const import COMPOSEFILE_V2_3 as V2_3
from compose.const import COMPOSEFILE_V3_0 as V3_0
from compose.const import COMPOSEFILE_V3_2 as V3_2
from compose.const import COMPOSEFILE_V3_4 as V3_4
from compose.const import COMPOSEFILE_V3_5 as V3_5
def serialize_config_type(dumper, data):
@ -34,6 +36,7 @@ def serialize_string(dumper, data):
return representer(data)
yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type)
yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type)
yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type)
yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type)
@ -67,7 +70,8 @@ def denormalize_config(config, image_digests=None):
del conf['external_name']
if 'name' in conf:
if config.version < V2_1 or (config.version >= V3_0 and config.version < V3_4):
if config.version < V2_1 or (
config.version >= V3_0 and config.version < v3_introduced_name_key(key)):
del conf['name']
elif 'external' in conf:
conf['external'] = True
@ -75,6 +79,12 @@ def denormalize_config(config, image_digests=None):
return result
def v3_introduced_name_key(key):
if key == 'volumes':
return V3_4
return V3_5
def serialize_config(config, image_digests=None):
return yaml.safe_dump(
denormalize_config(config, image_digests),
@ -141,5 +151,9 @@ def denormalize_service_dict(service_dict, version, image_digest=None):
p.legacy_repr() if isinstance(p, types.ServicePort) else p
for p in service_dict['ports']
]
if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)):
service_dict['volumes'] = [
v.legacy_repr() if isinstance(v, types.MountSpec) else v for v in service_dict['volumes']
]
return service_dict

View File

@ -133,6 +133,61 @@ def normalize_path_for_engine(path):
return path.replace('\\', '/')
class MountSpec(object):
options_map = {
'volume': {
'nocopy': 'no_copy'
},
'bind': {
'propagation': 'propagation'
}
}
_fields = ['type', 'source', 'target', 'read_only', 'consistency']
@classmethod
def parse(cls, mount_dict, normalize=False):
if mount_dict.get('source'):
mount_dict['source'] = os.path.normpath(mount_dict['source'])
if normalize:
mount_dict['source'] = normalize_path_for_engine(mount_dict['source'])
return cls(**mount_dict)
def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs):
self.type = type
self.source = source
self.target = target
self.read_only = read_only
self.consistency = consistency
self.options = None
if self.type in kwargs:
self.options = kwargs[self.type]
def as_volume_spec(self):
mode = 'ro' if self.read_only else 'rw'
return VolumeSpec(external=self.source, internal=self.target, mode=mode)
def legacy_repr(self):
return self.as_volume_spec().repr()
def repr(self):
res = {}
for field in self._fields:
if getattr(self, field, None):
res[field] = getattr(self, field)
if self.options:
res[self.type] = self.options
return res
@property
def is_named_volume(self):
return self.type == 'volume' and self.source
@property
def external(self):
return self.source
class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
@classmethod
@ -238,17 +293,18 @@ class ServiceLink(namedtuple('_ServiceLink', 'target alias')):
return self.alias
class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode')):
class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')):
@classmethod
def parse(cls, spec):
if isinstance(spec, six.string_types):
return cls(spec, None, None, None, None)
return cls(spec, None, None, None, None, None)
return cls(
spec.get('source'),
spec.get('target'),
spec.get('uid'),
spec.get('gid'),
spec.get('mode'),
spec.get('name')
)
@property
@ -277,11 +333,19 @@ class ServicePort(namedtuple('_ServicePort', 'target published protocol mode ext
except ValueError:
raise ConfigurationError('Invalid target port: {}'.format(target))
try:
if published:
published = int(published)
except ValueError:
raise ConfigurationError('Invalid published port: {}'.format(published))
if published:
if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format
a, b = published.split('-', 1)
try:
int(a)
int(b)
except ValueError:
raise ConfigurationError('Invalid published port: {}'.format(published))
else:
try:
published = int(published)
except ValueError:
raise ConfigurationError('Invalid published port: {}'.format(published))
return super(ServicePort, cls).__new__(
cls, target, published, *args, **kwargs

View File

@ -44,6 +44,31 @@ DOCKER_CONFIG_HINTS = {
VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]'
VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$'
VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])'
VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG)
VALID_REGEX_IPV4_CIDR = "^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR)
VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}'
VALID_REGEX_IPV6_CIDR = "".join("""
^
(
(({IPV6_SEG}:){{7}}{IPV6_SEG})|
(({IPV6_SEG}:){{1,7}}:)|
(({IPV6_SEG}:){{1,6}}(:{IPV6_SEG}){{1,1}})|
(({IPV6_SEG}:){{1,5}}(:{IPV6_SEG}){{1,2}})|
(({IPV6_SEG}:){{1,4}}(:{IPV6_SEG}){{1,3}})|
(({IPV6_SEG}:){{1,3}}(:{IPV6_SEG}){{1,4}})|
(({IPV6_SEG}:){{1,2}}(:{IPV6_SEG}){{1,5}})|
(({IPV6_SEG}:){{1,1}}(:{IPV6_SEG}){{1,6}})|
(:((:{IPV6_SEG}){{1,7}}|:))|
(fe80:(:{IPV6_SEG}){{0,4}}%[0-9a-zA-Z]{{1,}})|
(::(ffff(:0{{1,4}}){{0,1}}:){{0,1}}{IPV4_ADDR})|
(({IPV6_SEG}:){{1,4}}:{IPV4_ADDR})
)
/(\d|[1-9]\d|1[0-1]\d|12[0-8])
$
""".format(IPV6_SEG=VALID_IPV6_SEG, IPV4_ADDR=VALID_IPV4_ADDR).split())
@FormatChecker.cls_checks(format="ports", raises=ValidationError)
def format_ports(instance):
@ -64,6 +89,16 @@ def format_expose(instance):
return True
@FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError)
def format_subnet_ip_address(instance):
if isinstance(instance, six.string_types):
if not re.match(VALID_REGEX_IPV4_CIDR, instance) and \
not re.match(VALID_REGEX_IPV6_CIDR, instance):
raise ValidationError("should use the CIDR format")
return True
def match_named_volumes(service_dict, project_volumes):
service_volumes = service_dict.get('volumes', [])
for volume_spec in service_volumes:
@ -391,7 +426,7 @@ def process_config_schema_errors(error):
def validate_against_config_schema(config_file):
schema = load_jsonschema(config_file)
format_checker = FormatChecker(["ports", "expose"])
format_checker = FormatChecker(["ports", "expose", "subnet_ip_address"])
validator = Draft4Validator(
schema,
resolver=RefResolver(get_resolver_path(), schema),
@ -465,3 +500,27 @@ def handle_errors(errors, format_error_func, filename):
"The Compose file{file_msg} is invalid because:\n{error_msg}".format(
file_msg=" '{}'".format(filename) if filename else "",
error_msg=error_msg))
def validate_healthcheck(service_config):
healthcheck = service_config.config.get('healthcheck', {})
if 'test' in healthcheck and isinstance(healthcheck['test'], list):
if len(healthcheck['test']) == 0:
raise ConfigurationError(
'Service "{}" defines an invalid healthcheck: '
'"test" is an empty list'
.format(service_config.name))
# when disable is true config.py::process_healthcheck adds "test: ['NONE']" to service_config
elif healthcheck['test'][0] == 'NONE' and len(healthcheck) > 1:
raise ConfigurationError(
'Service "{}" defines an invalid healthcheck: '
'"disable: true" cannot be combined with other options'
.format(service_config.name))
elif healthcheck['test'][0] not in ('NONE', 'CMD', 'CMD-SHELL'):
raise ConfigurationError(
'Service "{}" defines an invalid healthcheck: '
'when "test" is a list the first item must be either NONE, CMD or CMD-SHELL'
.format(service_config.name))

View File

@ -25,21 +25,22 @@ OPTS_EXCEPTIONS = [
class Network(object):
def __init__(self, client, project, name, driver=None, driver_opts=None,
ipam=None, external_name=None, internal=False, enable_ipv6=False,
labels=None):
ipam=None, external=False, internal=False, enable_ipv6=False,
labels=None, custom_name=False):
self.client = client
self.project = project
self.name = name
self.driver = driver
self.driver_opts = driver_opts
self.ipam = create_ipam_config_from_dict(ipam)
self.external_name = external_name
self.external = external
self.internal = internal
self.enable_ipv6 = enable_ipv6
self.labels = labels
self.custom_name = custom_name
def ensure(self):
if self.external_name:
if self.external:
try:
self.inspect()
log.debug(
@ -51,7 +52,7 @@ class Network(object):
'Network {name} declared as external, but could'
' not be found. Please create the network manually'
' using `{command} {name}` and try again.'.format(
name=self.external_name,
name=self.full_name,
command='docker network create'
)
)
@ -83,7 +84,7 @@ class Network(object):
)
def remove(self):
if self.external_name:
if self.external:
log.info("Network %s is external, skipping", self.full_name)
return
@ -95,8 +96,8 @@ class Network(object):
@property
def full_name(self):
if self.external_name:
return self.external_name
if self.custom_name:
return self.name
return '{0}_{1}'.format(self.project, self.name)
@property
@ -116,7 +117,7 @@ def create_ipam_config_from_dict(ipam_dict):
return None
return IPAMConfig(
driver=ipam_dict.get('driver'),
driver=ipam_dict.get('driver') or 'default',
pool_configs=[
IPAMPool(
subnet=config.get('subnet'),
@ -203,14 +204,16 @@ def build_networks(name, config_data, client):
network_config = config_data.networks or {}
networks = {
network_name: Network(
client=client, project=name, name=network_name,
client=client, project=name,
name=data.get('name', network_name),
driver=data.get('driver'),
driver_opts=data.get('driver_opts'),
ipam=data.get('ipam'),
external_name=data.get('external_name'),
external=bool(data.get('external', False)),
internal=data.get('internal'),
enable_ipv6=data.get('enable_ipv6'),
labels=data.get('labels'),
custom_name=data.get('name') is not None,
)
for network_name, data in network_config.items()
}

View File

@ -26,7 +26,7 @@ log = logging.getLogger(__name__)
STOP = object()
def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None):
def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None):
"""Runs func on objects in parallel while ensuring that func is
ran on object only after it is ran on all its dependencies.
@ -37,9 +37,19 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None):
stream = get_output_stream(sys.stderr)
writer = ParallelStreamWriter(stream, msg)
for obj in objects:
if parent_objects:
display_objects = list(parent_objects)
else:
display_objects = objects
for obj in display_objects:
writer.add_object(get_name(obj))
writer.write_initial()
# write data in a second loop to consider all objects for width alignment
# and avoid duplicates when parent_objects exists
for obj in objects:
writer.write_initial(get_name(obj))
events = parallel_execute_iter(objects, func, get_deps, limit)
@ -237,12 +247,11 @@ class ParallelStreamWriter(object):
self.lines.append(obj_index)
self.width = max(self.width, len(obj_index))
def write_initial(self):
def write_initial(self, obj_index):
if self.msg is None:
return
for line in self.lines:
self.stream.write("{} {:<{width}} ... \r\n".format(self.msg, line,
width=self.width))
self.stream.write("{} {:<{width}} ... \r\n".format(
self.msg, self.lines[self.lines.index(obj_index)], width=self.width))
self.stream.flush()
def _write_ansi(self, obj_index, status):

View File

@ -29,6 +29,7 @@ from .service import ConvergenceStrategy
from .service import NetworkMode
from .service import PidMode
from .service import Service
from .service import ServiceName
from .service import ServiceNetworkMode
from .service import ServicePidMode
from .utils import microseconds_from_time_nano
@ -190,6 +191,25 @@ class Project(object):
service.remove_duplicate_containers()
return services
def get_scaled_services(self, services, scale_override):
"""
Returns a list of this project's services as scaled ServiceName objects.
services: a list of Service objects
scale_override: a dict with the scale to apply to each service (k: service_name, v: scale)
"""
service_names = []
for service in services:
if service.name in scale_override:
scale = scale_override[service.name]
else:
scale = service.scale_num
for i in range(1, scale + 1):
service_names.append(ServiceName(self.name, service.name, i))
return service_names
def get_links(self, service_dict):
links = []
if 'links' in service_dict:
@ -310,8 +330,8 @@ class Project(object):
service_names, stopped=True, one_off=one_off
), options)
def down(self, remove_image_type, include_volumes, remove_orphans=False):
self.stop(one_off=OneOffFilter.include)
def down(self, remove_image_type, include_volumes, remove_orphans=False, timeout=None):
self.stop(one_off=OneOffFilter.include, timeout=timeout)
self.find_orphan_containers(remove_orphans)
self.remove_stopped(v=include_volumes, one_off=OneOffFilter.include)
@ -337,10 +357,11 @@ class Project(object):
)
return containers
def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None):
def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None,
build_args=None):
for service in self.get_services(service_names):
if service.can_be_built():
service.build(no_cache, pull, force_rm, build_args)
service.build(no_cache, pull, force_rm, memory, build_args)
else:
log.info('%s uses an image, skipping' % service.name)
@ -430,15 +451,18 @@ class Project(object):
for svc in services:
svc.ensure_image_exists(do_build=do_build)
plans = self._get_convergence_plans(services, strategy)
scaled_services = self.get_scaled_services(services, scale_override)
def do(service):
return service.execute_convergence_plan(
plans[service.name],
timeout=timeout,
detached=detached,
scale_override=scale_override.get(service.name),
rescale=rescale,
start=start
start=start,
project_services=scaled_services
)
def get_deps(service):
@ -624,7 +648,7 @@ def get_secrets(service, service_secrets, secret_defs):
"Service \"{service}\" uses an undefined secret \"{secret}\" "
.format(service=service, secret=secret.source))
if secret_def.get('external_name'):
if secret_def.get('external'):
log.warn("Service \"{service}\" uses secret \"{secret}\" which is external. "
"External secrets are not available to containers created by "
"docker-compose.".format(service=service, secret=secret.source))

View File

@ -14,6 +14,9 @@ from docker.errors import APIError
from docker.errors import ImageNotFound
from docker.errors import NotFound
from docker.types import LogConfig
from docker.types import Mount
from docker.utils import version_gte
from docker.utils import version_lt
from docker.utils.ports import build_port_bindings
from docker.utils.ports import split_port
from docker.utils.utils import convert_tmpfs_mounts
@ -23,7 +26,9 @@ from . import const
from . import progress_stream
from .config import DOCKER_CONFIG_KEYS
from .config import merge_environment
from .config import merge_labels
from .config.errors import DependencyError
from .config.types import MountSpec
from .config.types import ServicePort
from .config.types import VolumeSpec
from .const import DEFAULT_TIMEOUT
@ -76,6 +81,7 @@ HOST_CONFIG_KEYS = [
'mem_reservation',
'memswap_limit',
'mem_swappiness',
'oom_kill_disable',
'oom_score_adj',
'pid',
'pids_limit',
@ -378,11 +384,11 @@ class Service(object):
return has_diverged
def _execute_convergence_create(self, scale, detached, start):
def _execute_convergence_create(self, scale, detached, start, project_services=None):
i = self._next_container_number()
def create_and_start(service, n):
container = service.create_container(number=n)
container = service.create_container(number=n, quiet=True)
if not detached:
container.attach_log_stream()
if start:
@ -390,10 +396,11 @@ class Service(object):
return container
containers, errors = parallel_execute(
range(i, i + scale),
lambda n: create_and_start(self, n),
lambda n: self.get_container_name(n),
[ServiceName(self.project, self.name, index) for index in range(i, i + scale)],
lambda service_name: create_and_start(self, service_name.number),
lambda service_name: self.get_container_name(service_name.service, service_name.number),
"Creating",
parent_objects=project_services
)
for error in errors.values():
raise OperationFailedError(error)
@ -432,7 +439,7 @@ class Service(object):
if start:
_, errors = parallel_execute(
containers,
lambda c: self.start_container_if_stopped(c, attach_logs=not detached),
lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True),
lambda c: c.name,
"Starting",
)
@ -459,7 +466,7 @@ class Service(object):
)
def execute_convergence_plan(self, plan, timeout=None, detached=False,
start=True, scale_override=None, rescale=True):
start=True, scale_override=None, rescale=True, project_services=None):
(action, containers) = plan
scale = scale_override if scale_override is not None else self.scale_num
containers = sorted(containers, key=attrgetter('number'))
@ -468,7 +475,7 @@ class Service(object):
if action == 'create':
return self._execute_convergence_create(
scale, detached, start
scale, detached, start, project_services
)
# The create action needs always needs an initial scale, but otherwise,
@ -510,7 +517,6 @@ class Service(object):
volumes can be copied to the new container, before the original
container is removed.
"""
log.info("Recreating %s" % container.name)
container.stop(timeout=self.stop_timeout(timeout))
container.rename_to_tmp_name()
@ -741,21 +747,26 @@ class Service(object):
container_options.update(override_options)
if not container_options.get('name'):
container_options['name'] = self.get_container_name(number, one_off)
container_options['name'] = self.get_container_name(self.name, number, one_off)
container_options.setdefault('detach', True)
# If a qualified hostname was given, split it into an
# unqualified hostname and a domainname unless domainname
# was also given explicitly. This matches the behavior of
# the official Docker CLI in that scenario.
if ('hostname' in container_options and
# was also given explicitly. This matches behavior
# until Docker Engine 1.11.0 - Docker API 1.23.
if (version_lt(self.client.api_version, '1.23') and
'hostname' in container_options and
'domainname' not in container_options and
'.' in container_options['hostname']):
parts = container_options['hostname'].partition('.')
container_options['hostname'] = parts[0]
container_options['domainname'] = parts[2]
if (version_gte(self.client.api_version, '1.25') and
'stop_grace_period' in self.options):
container_options['stop_timeout'] = self.stop_timeout(None)
if 'ports' in container_options or 'expose' in self.options:
container_options['ports'] = build_container_ports(
formatted_ports(container_options.get('ports', [])),
@ -770,21 +781,38 @@ class Service(object):
self.options.get('environment'),
override_options.get('environment'))
container_options['labels'] = merge_labels(
self.options.get('labels'),
override_options.get('labels'))
container_volumes = []
container_mounts = []
if 'volumes' in container_options:
container_volumes = [
v for v in container_options.get('volumes') if isinstance(v, VolumeSpec)
]
container_mounts = [v for v in container_options.get('volumes') if isinstance(v, MountSpec)]
binds, affinity = merge_volume_bindings(
container_options.get('volumes') or [],
self.options.get('tmpfs') or [],
previous_container)
container_volumes, self.options.get('tmpfs') or [], previous_container,
container_mounts
)
override_options['binds'] = binds
container_options['environment'].update(affinity)
container_options['volumes'] = dict(
(v.internal, {}) for v in container_options.get('volumes') or {})
container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {})
override_options['mounts'] = [build_mount(v) for v in container_mounts] or None
secret_volumes = self.get_secret_volumes()
if secret_volumes:
override_options['binds'].extend(v.repr() for v in secret_volumes)
container_options['volumes'].update(
(v.internal, {}) for v in secret_volumes)
if version_lt(self.client.api_version, '1.30'):
override_options['binds'].extend(v.legacy_repr() for v in secret_volumes)
container_options['volumes'].update(
(v.target, {}) for v in secret_volumes
)
else:
override_options['mounts'] = override_options.get('mounts') or []
override_options['mounts'].extend([build_mount(v) for v in secret_volumes])
container_options['image'] = self.image_name
@ -857,6 +885,7 @@ class Service(object):
sysctls=options.get('sysctls'),
pids_limit=options.get('pids_limit'),
tmpfs=options.get('tmpfs'),
oom_kill_disable=options.get('oom_kill_disable'),
oom_score_adj=options.get('oom_score_adj'),
mem_swappiness=options.get('mem_swappiness'),
group_add=options.get('group_add'),
@ -877,6 +906,7 @@ class Service(object):
device_read_iops=blkio_config.get('device_read_iops'),
device_write_bps=blkio_config.get('device_write_bps'),
device_write_iops=blkio_config.get('device_write_iops'),
mounts=options.get('mounts'),
)
def get_secret_volumes(self):
@ -887,11 +917,11 @@ class Service(object):
elif not os.path.isabs(target):
target = '{}/{}'.format(const.SECRETS_PATH, target)
return VolumeSpec(secret['file'], target, 'ro')
return MountSpec('bind', secret['file'], target, read_only=True)
return [build_spec(secret) for secret in self.secrets]
def build(self, no_cache=False, pull=False, force_rm=False, build_args_override=None):
def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None):
log.info('Building %s' % self.name)
build_opts = self.options.get('build', {})
@ -921,6 +951,10 @@ class Service(object):
network_mode=build_opts.get('network', None),
target=build_opts.get('target', None),
shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None,
extra_hosts=build_opts.get('extra_hosts', None),
container_limits={
'memory': parse_bytes(memory) if memory else None
},
)
try:
@ -960,12 +994,12 @@ class Service(object):
def custom_container_name(self):
return self.options.get('container_name')
def get_container_name(self, number, one_off=False):
def get_container_name(self, service_name, number, one_off=False):
if self.custom_container_name and not one_off:
return self.custom_container_name
container_name = build_container_name(
self.project, self.name, number, one_off,
self.project, service_name, number, one_off,
)
ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])]
if container_name in ext_links_origins:
@ -1220,32 +1254,40 @@ def parse_repository_tag(repo_path):
# Volumes
def merge_volume_bindings(volumes, tmpfs, previous_container):
"""Return a list of volume bindings for a container. Container data volumes
are replaced by those from the previous container.
def merge_volume_bindings(volumes, tmpfs, previous_container, mounts):
"""
Return a list of volume bindings for a container. Container data volumes
are replaced by those from the previous container.
Anonymous mounts are updated in place.
"""
affinity = {}
volume_bindings = dict(
build_volume_binding(volume)
for volume in volumes
if volume.external)
if volume.external
)
if previous_container:
old_volumes = get_container_data_volumes(previous_container, volumes, tmpfs)
old_volumes, old_mounts = get_container_data_volumes(
previous_container, volumes, tmpfs, mounts
)
warn_on_masked_volume(volumes, old_volumes, previous_container.service)
volume_bindings.update(
build_volume_binding(volume) for volume in old_volumes)
build_volume_binding(volume) for volume in old_volumes
)
if old_volumes:
if old_volumes or old_mounts:
affinity = {'affinity:container': '=' + previous_container.id}
return list(volume_bindings.values()), affinity
def get_container_data_volumes(container, volumes_option, tmpfs_option):
"""Find the container data volumes that are in `volumes_option`, and return
a mapping of volume bindings for those volumes.
def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option):
"""
Find the container data volumes that are in `volumes_option`, and return
a mapping of volume bindings for those volumes.
Anonymous volume mounts are updated in place instead.
"""
volumes = []
volumes_option = volumes_option or []
@ -1284,7 +1326,19 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option):
volume = volume._replace(external=mount['Name'])
volumes.append(volume)
return volumes
updated_mounts = False
for mount in mounts_option:
if mount.type != 'volume':
continue
ctnr_mount = container_mounts.get(mount.target)
if not ctnr_mount.get('Name'):
continue
mount.source = ctnr_mount['Name']
updated_mounts = True
return volumes, updated_mounts
def warn_on_masked_volume(volumes_option, container_volumes, service):
@ -1331,6 +1385,18 @@ def build_volume_from(volume_from_spec):
return "{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)
def build_mount(mount_spec):
kwargs = {}
if mount_spec.options:
for option, sdk_name in mount_spec.options_map[mount_spec.type].items():
if option in mount_spec.options:
kwargs[sdk_name] = mount_spec.options[option]
return Mount(
type=mount_spec.type, target=mount_spec.target, source=mount_spec.source,
read_only=mount_spec.read_only, consistency=mount_spec.consistency, **kwargs
)
# Labels

View File

@ -101,7 +101,7 @@ def json_stream(stream):
def json_hash(obj):
dump = json.dumps(obj, sort_keys=True, separators=(',', ':'))
dump = json.dumps(obj, sort_keys=True, separators=(',', ':'), default=lambda x: x.repr())
h = hashlib.sha256()
h.update(dump.encode('utf8'))
return h.hexdigest()

View File

@ -7,6 +7,7 @@ from docker.errors import NotFound
from docker.utils import version_lt
from .config import ConfigurationError
from .config.types import VolumeSpec
from .const import LABEL_PROJECT
from .const import LABEL_VOLUME
@ -145,5 +146,9 @@ class ProjectVolumes(object):
if not volume_spec.is_named_volume:
return volume_spec
volume = self.volumes[volume_spec.external]
return volume_spec._replace(external=volume.full_name)
if isinstance(volume_spec, VolumeSpec):
volume = self.volumes[volume_spec.external]
return volume_spec._replace(external=volume.full_name)
else:
volume_spec.source = self.volumes[volume_spec.source].full_name
return volume_spec

View File

@ -120,7 +120,7 @@ _docker_compose_build() {
case "$cur" in
-*)
COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --no-cache --pull" -- "$cur" ) )
COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --memory --no-cache --pull" -- "$cur" ) )
;;
*)
__docker_compose_services_from_build
@ -403,14 +403,14 @@ _docker_compose_run() {
__docker_compose_nospace
return
;;
--entrypoint|--name|--user|-u|--volume|-v|--workdir|-w)
--entrypoint|--label|-l|--name|--user|-u|--volume|-v|--workdir|-w)
return
;;
esac
case "$cur" in
-*)
COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) )
COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) )
;;
*)
__docker_compose_services_all

View File

@ -196,6 +196,7 @@ __docker-compose_subcommand() {
$opts_help \
"*--build-arg=[Set build-time variables for one service.]:<varname>=<value>: " \
'--force-rm[Always remove intermediate containers.]' \
'--memory[Memory limit for the build container.]' \
'--no-cache[Do not use cache when building the image.]' \
'--pull[Always attempt to pull a newer version of the image.]' \
'*:services:__docker-compose_services_from_build' && ret=0

View File

@ -67,6 +67,11 @@ exe = EXE(pyz,
'compose/config/config_schema_v3.4.json',
'DATA'
),
(
'compose/config/config_schema_v3.5.json',
'compose/config/config_schema_v3.5.json',
'DATA'
),
(
'compose/GITSHA',
'compose/GITSHA',

View File

@ -89,7 +89,7 @@ When prompted build the non-linux binaries and test them.
Alternatively, you can use the usual commands to install or upgrade Compose:
```
curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```

View File

@ -2,20 +2,20 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3'
cached-property==1.3.0
certifi==2017.4.17
chardet==3.0.4
colorama==0.3.9; sys_platform == 'win32'
docker==2.5.1
docker==2.6.1
docker-pycreds==0.2.1
dockerpty==0.4.1
docopt==0.6.2
enum34==1.1.6; python_version < '3.4'
functools32==3.2.3.post2; python_version < '3.2'
git+git://github.com/tartley/colorama.git@bd378c725b45eba0b8e5cc091c3ca76a954c92ff; sys_platform == 'win32'
idna==2.5
ipaddress==1.0.18
jsonschema==2.6.0
pypiwin32==219; sys_platform == 'win32'
PySocks==1.6.7
PyYAML==3.12
requests==2.11.1
requests==2.18.4
six==1.10.0
texttable==0.9.1
urllib3==1.21.1

View File

@ -30,3 +30,8 @@ mkdir $DESTINATION
wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64
wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64
wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL
echo -e "\n\nCopy the following lines into the integrity check table in the release notes:\n\n"
cd $DESTINATION
ls | xargs sha256sum | sed 's/ / | /g' | sed -r 's/([^ |]+)/`\1`/g'
cd -

View File

@ -15,7 +15,7 @@
set -e
VERSION="1.17.1"
VERSION="1.18.0-rc1"
IMAGE="docker/compose:$VERSION"

View File

@ -33,10 +33,10 @@ install_requires = [
'cached-property >= 1.2.0, < 2',
'docopt >= 0.6.1, < 0.7',
'PyYAML >= 3.10, < 4',
'requests >= 2.6.1, != 2.11.0, < 2.12',
'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19',
'texttable >= 0.9.0, < 0.10',
'websocket-client >= 0.32.0, < 1.0',
'docker >= 2.5.1, < 3.0',
'docker >= 2.6.1, < 3.0',
'dockerpty >= 0.4.1, < 0.5',
'six >= 1.3.0, < 2',
'jsonschema >= 2.5.1, < 3',
@ -55,7 +55,7 @@ extras_require = {
':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'],
':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5'],
':python_version < "3.3"': ['ipaddress >= 1.0.16'],
':sys_platform == "win32"': ['colorama >= 0.3.7, < 0.4'],
':sys_platform == "win32"': ['colorama >= 0.3.9, < 0.4'],
'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'],
}

View File

@ -33,6 +33,7 @@ from tests.integration.testcases import no_cluster
from tests.integration.testcases import pull_busybox
from tests.integration.testcases import SWARM_SKIP_RM_VOLUMES
from tests.integration.testcases import v2_1_only
from tests.integration.testcases import v2_2_only
from tests.integration.testcases import v2_only
from tests.integration.testcases import v3_only
@ -349,6 +350,22 @@ class CLITestCase(DockerClientTestCase):
}
}
def test_config_external_network_v3_5(self):
self.base_dir = 'tests/fixtures/networks'
result = self.dispatch(['-f', 'external-networks-v3-5.yml', 'config'])
json_result = yaml.load(result.stdout)
assert 'networks' in json_result
assert json_result['networks'] == {
'foo': {
'external': True,
'name': 'some_foo',
},
'bar': {
'external': True,
'name': 'some_bar',
},
}
def test_config_v1(self):
self.base_dir = 'tests/fixtures/v1-config'
result = self.dispatch(['config'])
@ -427,13 +444,21 @@ class CLITestCase(DockerClientTestCase):
'timeout': '1s',
'retries': 5,
},
'volumes': [
'/host/path:/container/path:ro',
'foobar:/container/volumepath:rw',
'/anonymous',
'foobar:/container/volumepath2:nocopy'
],
'volumes': [{
'read_only': True,
'source': '/host/path',
'target': '/container/path',
'type': 'bind'
}, {
'source': 'foobar', 'target': '/container/volumepath', 'type': 'volume'
}, {
'target': '/anonymous', 'type': 'volume'
}, {
'source': 'foobar',
'target': '/container/volumepath2',
'type': 'volume',
'volume': {'nocopy': True}
}],
'stop_grace_period': '20s',
},
},
@ -583,6 +608,12 @@ class CLITestCase(DockerClientTestCase):
result = self.dispatch(['build', '--no-cache'], None)
assert 'shm_size: 96' in result.stdout
def test_build_memory_build_option(self):
pull_busybox(self.client)
self.base_dir = 'tests/fixtures/build-memory'
result = self.dispatch(['build', '--no-cache', '--memory', '96m', 'service'], None)
assert 'memory: 100663296' in result.stdout # 96 * 1024 * 1024
def test_bundle_with_digests(self):
self.base_dir = 'tests/fixtures/bundle-with-digests/'
tmpdir = pytest.ensuretemp('cli_test_bundle')
@ -719,12 +750,13 @@ class CLITestCase(DockerClientTestCase):
def test_run_one_off_with_volume_merge(self):
self.base_dir = 'tests/fixtures/simple-composefile-volume-ready'
volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files'))
create_host_file(self.client, os.path.join(volume_path, 'example.txt'))
node = create_host_file(self.client, os.path.join(volume_path, 'example.txt'))
self.dispatch([
'-f', 'docker-compose.merge.yml',
'run',
'-v', '{}:/data'.format(volume_path),
'-e', 'constraint:node=={}'.format(node if node is not None else '*'),
'simple',
'test', '-f', '/data/example.txt'
], returncode=0)
@ -774,6 +806,27 @@ class CLITestCase(DockerClientTestCase):
assert 'Removing network v2full_default' in result.stderr
assert 'Removing network v2full_front' in result.stderr
def test_down_timeout(self):
self.dispatch(['up', '-d'], None)
service = self.project.get_service('simple')
self.assertEqual(len(service.containers()), 1)
self.assertTrue(service.containers()[0].is_running)
""
self.dispatch(['down', '-t', '1'], None)
self.assertEqual(len(service.containers(stopped=True)), 0)
def test_down_signal(self):
self.base_dir = 'tests/fixtures/stop-signal-composefile'
self.dispatch(['up', '-d'], None)
service = self.project.get_service('simple')
self.assertEqual(len(service.containers()), 1)
self.assertTrue(service.containers()[0].is_running)
self.dispatch(['down', '-t', '1'], None)
self.assertEqual(len(service.containers(stopped=True)), 0)
def test_up_detached(self):
self.dispatch(['up', '-d'])
service = self.project.get_service('simple')
@ -1278,18 +1331,9 @@ class CLITestCase(DockerClientTestCase):
['up', '-d', '--force-recreate', '--no-recreate'],
returncode=1)
def test_up_with_timeout(self):
self.dispatch(['up', '-d', '-t', '1'])
service = self.project.get_service('simple')
another = self.project.get_service('another')
self.assertEqual(len(service.containers()), 1)
self.assertEqual(len(another.containers()), 1)
# Ensure containers don't have stdin and stdout connected in -d mode
config = service.containers()[0].inspect()['Config']
self.assertFalse(config['AttachStderr'])
self.assertFalse(config['AttachStdout'])
self.assertFalse(config['AttachStdin'])
def test_up_with_timeout_detached(self):
result = self.dispatch(['up', '-d', '-t', '1'], returncode=1)
assert "-d and --timeout cannot be combined." in result.stderr
def test_up_handles_sigint(self):
proc = start_process(self.base_dir, ['up', '-t', '2'])
@ -1374,6 +1418,31 @@ class CLITestCase(DockerClientTestCase):
self.assertEqual(stdout, "operator\n")
self.assertEqual(stderr, "")
@v2_2_only()
def test_exec_service_with_environment_overridden(self):
name = 'service'
self.base_dir = 'tests/fixtures/environment-exec'
self.dispatch(['up', '-d'])
self.assertEqual(len(self.project.containers()), 1)
stdout, stderr = self.dispatch([
'exec',
'-T',
'-e', 'foo=notbar',
'--env', 'alpha=beta',
name,
'env',
])
# env overridden
assert 'foo=notbar' in stdout
# keep environment from yaml
assert 'hello=world' in stdout
# added option from command line
assert 'alpha=beta' in stdout
self.assertEqual(stderr, '')
def test_run_service_without_links(self):
self.base_dir = 'tests/fixtures/links-composefile'
self.dispatch(['run', 'console', '/bin/true'])
@ -1803,6 +1872,17 @@ class CLITestCase(DockerClientTestCase):
assert 'FOO=bar' in environment
assert 'BAR=baz' not in environment
def test_run_label_flag(self):
self.base_dir = 'tests/fixtures/run-labels'
name = 'service'
self.dispatch(['run', '-l', 'default', '--label', 'foo=baz', name, '/bin/true'])
service = self.project.get_service(name)
container, = service.containers(stopped=True, one_off=OneOffFilter.only)
labels = container.labels
assert labels['default'] == ''
assert labels['foo'] == 'baz'
assert labels['hello'] == 'world'
def test_rm(self):
service = self.project.get_service('simple')
service.create_container()

View File

@ -0,0 +1,4 @@
FROM busybox
# Report the memory (through the size of the group memory)
RUN echo "memory:" $(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)

View File

@ -0,0 +1,6 @@
version: '3.5'
services:
service:
build:
context: .

View File

@ -0,0 +1,10 @@
version: "2.2"
services:
service:
image: busybox:latest
command: top
environment:
foo: bar
hello: world

View File

@ -0,0 +1,13 @@
version: "2.1"
services:
web:
# set value with default, default must be ignored
image: ${IMAGE:-alpine}
# unset value with default value
ports:
- "${HOST_PORT:-80}:8000"
# unset value with empty default
hostname: "host-${UNSET_VALUE:-}"

View File

@ -0,0 +1,17 @@
version: "3.5"
services:
web:
image: busybox
command: top
networks:
- foo
- bar
networks:
foo:
external: true
name: some_foo
bar:
external:
name: some_bar

View File

@ -0,0 +1,7 @@
service:
image: busybox:latest
command: top
labels:
foo: bar
hello: world

View File

@ -19,12 +19,8 @@ def build_config_details(contents, working_dir='working_dir', filename='filename
)
def create_host_file(client, filename):
def create_custom_host_file(client, filename, content):
dirname = os.path.dirname(filename)
with open(filename, 'r') as fh:
content = fh.read()
container = client.create_container(
'busybox:latest',
['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)],
@ -48,3 +44,10 @@ def create_host_file(client, filename):
return container_info['Node']['Name']
finally:
client.remove_container(container, force=True)
def create_host_file(client, filename):
with open(filename, 'r') as fh:
content = fh.read()
return create_custom_host_file(client, filename, content)

View File

@ -35,6 +35,7 @@ from tests.integration.testcases import is_cluster
from tests.integration.testcases import no_cluster
from tests.integration.testcases import v2_1_only
from tests.integration.testcases import v2_2_only
from tests.integration.testcases import v2_3_only
from tests.integration.testcases import v2_only
from tests.integration.testcases import v3_only
@ -436,6 +437,26 @@ class ProjectTest(DockerClientTestCase):
self.assertNotEqual(db_container.id, old_db_id)
self.assertEqual(db_container.get('Volumes./etc'), db_volume_path)
@v2_3_only()
def test_recreate_preserves_mounts(self):
web = self.create_service('web')
db = self.create_service('db', volumes=[types.MountSpec(type='volume', target='/etc')])
project = Project('composetest', [web, db], self.client)
project.start()
assert len(project.containers()) == 0
project.up(['db'])
assert len(project.containers()) == 1
old_db_id = project.containers()[0].id
db_volume_path = project.containers()[0].get_mount('/etc')['Source']
project.up(strategy=ConvergenceStrategy.always)
assert len(project.containers()) == 2
db_container = [c for c in project.containers() if 'db' in c.name][0]
assert db_container.id != old_db_id
assert db_container.get_mount('/etc')['Source'] == db_volume_path
def test_project_up_with_no_recreate_running(self):
web = self.create_service('web')
db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')])
@ -932,6 +953,43 @@ class ProjectTest(DockerClientTestCase):
assert 'LinkLocalIPs' in ipam_config
assert ipam_config['LinkLocalIPs'] == ['169.254.8.8']
@v2_1_only()
def test_up_with_custom_name_resources(self):
config_data = build_config(
version=V2_2,
services=[{
'name': 'web',
'volumes': [VolumeSpec.parse('foo:/container-path')],
'networks': {'foo': {}},
'image': 'busybox:latest'
}],
networks={
'foo': {
'name': 'zztop',
'labels': {'com.docker.compose.test_value': 'sharpdressedman'}
}
},
volumes={
'foo': {
'name': 'acdc',
'labels': {'com.docker.compose.test_value': 'thefuror'}
}
}
)
project = Project.from_config(
client=self.client,
name='composetest',
config_data=config_data
)
project.up(detached=True)
network = [n for n in self.client.networks() if n['Name'] == 'zztop'][0]
volume = [v for v in self.client.volumes()['Volumes'] if v['Name'] == 'acdc'][0]
assert network['Labels']['com.docker.compose.test_value'] == 'sharpdressedman'
assert volume['Labels']['com.docker.compose.test_value'] == 'thefuror'
@v2_1_only()
def test_up_with_isolation(self):
self.require_api_version('1.24')

View File

@ -19,6 +19,7 @@ from .testcases import pull_busybox
from .testcases import SWARM_SKIP_CONTAINERS_ALL
from .testcases import SWARM_SKIP_CPU_SHARES
from compose import __version__
from compose.config.types import MountSpec
from compose.config.types import VolumeFromSpec
from compose.config.types import VolumeSpec
from compose.const import IS_WINDOWS_PLATFORM
@ -37,6 +38,7 @@ from compose.service import NetworkMode
from compose.service import PidMode
from compose.service import Service
from compose.utils import parse_nanoseconds_int
from tests.helpers import create_custom_host_file
from tests.integration.testcases import is_cluster
from tests.integration.testcases import no_cluster
from tests.integration.testcases import v2_1_only
@ -239,8 +241,7 @@ class ServiceTest(DockerClientTestCase):
service.start_container(container)
self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt))
# @pytest.mark.xfail(True, reason='Not supported on most drivers')
@pytest.mark.skipif(True, reason='https://github.com/moby/moby/issues/34270')
@pytest.mark.xfail(True, reason='Not supported on most drivers')
def test_create_container_with_storage_opt(self):
storage_opt = {'size': '1G'}
service = self.create_service('db', storage_opt=storage_opt)
@ -248,6 +249,12 @@ class ServiceTest(DockerClientTestCase):
service.start_container(container)
self.assertEqual(container.get('HostConfig.StorageOpt'), storage_opt)
def test_create_container_with_oom_kill_disable(self):
self.require_api_version('1.20')
service = self.create_service('db', oom_kill_disable=True)
container = service.create_container()
assert container.get('HostConfig.OomKillDisable') is True
def test_create_container_with_mac_address(self):
service = self.create_service('db', mac_address='02:42:ac:11:65:43')
container = service.create_container()
@ -271,6 +278,54 @@ class ServiceTest(DockerClientTestCase):
self.assertTrue(path.basename(actual_host_path) == path.basename(host_path),
msg=("Last component differs: %s, %s" % (actual_host_path, host_path)))
@v2_3_only()
def test_create_container_with_host_mount(self):
host_path = '/tmp/host-path'
container_path = '/container-path'
create_custom_host_file(self.client, path.join(host_path, 'a.txt'), 'test')
service = self.create_service(
'db',
volumes=[
MountSpec(type='bind', source=host_path, target=container_path, read_only=True)
]
)
container = service.create_container()
service.start_container(container)
mount = container.get_mount(container_path)
assert mount
assert path.basename(mount['Source']) == path.basename(host_path)
assert mount['RW'] is False
@v2_3_only()
def test_create_container_with_tmpfs_mount(self):
container_path = '/container-tmpfs'
service = self.create_service(
'db',
volumes=[MountSpec(type='tmpfs', target=container_path)]
)
container = service.create_container()
service.start_container(container)
mount = container.get_mount(container_path)
assert mount
assert mount['Type'] == 'tmpfs'
@v2_3_only()
def test_create_container_with_volume_mount(self):
container_path = '/container-volume'
volume_name = 'composetest_abcde'
self.client.create_volume(volume_name)
service = self.create_service(
'db',
volumes=[MountSpec(type='volume', source=volume_name, target=container_path)]
)
container = service.create_container()
service.start_container(container)
mount = container.get_mount(container_path)
assert mount
assert mount['Name'] == volume_name
def test_create_container_with_healthcheck_config(self):
one_second = parse_nanoseconds_int('1s')
healthcheck = {
@ -434,6 +489,38 @@ class ServiceTest(DockerClientTestCase):
orig_container = new_container
@v2_3_only()
def test_execute_convergence_plan_recreate_twice_with_mount(self):
service = self.create_service(
'db',
volumes=[MountSpec(target='/etc', type='volume')],
entrypoint=['top'],
command=['-d', '1']
)
orig_container = service.create_container()
service.start_container(orig_container)
orig_container.inspect() # reload volume data
volume_path = orig_container.get_mount('/etc')['Source']
# Do this twice to reproduce the bug
for _ in range(2):
new_container, = service.execute_convergence_plan(
ConvergencePlan('recreate', [orig_container])
)
assert new_container.get_mount('/etc')['Source'] == volume_path
if not is_cluster(self.client):
assert ('affinity:container==%s' % orig_container.id in
new_container.get('Config.Env'))
else:
# In Swarm, the env marker is consumed and the container should be deployed
# on the same node.
assert orig_container.get('Node.Name') == new_container.get('Node.Name')
orig_container = new_container
def test_execute_convergence_plan_when_containers_are_stopped(self):
service = self.create_service(
'db',
@ -828,6 +915,29 @@ class ServiceTest(DockerClientTestCase):
assert service.image()
assert service.image()['Config']['Labels']['com.docker.compose.test.target'] == 'one'
@v2_3_only()
def test_build_with_extra_hosts(self):
self.require_api_version('1.27')
base_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, base_dir)
with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f:
f.write('\n'.join([
'FROM busybox',
'RUN ping -c1 foobar',
'RUN ping -c1 baz',
]))
service = self.create_service('build_extra_hosts', build={
'context': text_type(base_dir),
'extra_hosts': {
'foobar': '127.0.0.1',
'baz': '127.0.0.1'
}
})
service.build()
assert service.image()
def test_start_container_stays_unprivileged(self):
service = self.create_service('web')
container = create_and_start_container(service).inspect()

View File

@ -20,7 +20,7 @@ from compose.const import COMPOSEFILE_V2_2 as V2_2
from compose.const import COMPOSEFILE_V2_3 as V2_3
from compose.const import COMPOSEFILE_V3_0 as V3_0
from compose.const import COMPOSEFILE_V3_2 as V3_2
from compose.const import COMPOSEFILE_V3_3 as V3_3
from compose.const import COMPOSEFILE_V3_5 as V3_5
from compose.const import LABEL_PROJECT
from compose.progress_stream import stream_output
from compose.service import Service
@ -47,7 +47,7 @@ def get_links(container):
def engine_max_version():
if 'DOCKER_VERSION' not in os.environ:
return V3_3
return V3_5
version = os.environ['DOCKER_VERSION'].partition('-')[0]
if version_lt(version, '1.10'):
return V1
@ -57,7 +57,7 @@ def engine_max_version():
return V2_1
if version_lt(version, '17.06'):
return V3_2
return V3_3
return V3_5
def min_version_skip(version):

View File

@ -10,6 +10,7 @@ from io import StringIO
import docker
import py
import pytest
from docker.constants import DEFAULT_DOCKER_API_VERSION
from .. import mock
from .. import unittest
@ -98,6 +99,7 @@ class CLITestCase(unittest.TestCase):
@mock.patch('compose.cli.main.PseudoTerminal', autospec=True)
def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation):
mock_client = mock.create_autospec(docker.APIClient)
mock_client.api_version = DEFAULT_DOCKER_API_VERSION
project = Project.from_config(
name='composetest',
client=mock_client,
@ -112,6 +114,7 @@ class CLITestCase(unittest.TestCase):
'SERVICE': 'service',
'COMMAND': None,
'-e': [],
'--label': [],
'--user': None,
'--no-deps': None,
'-d': False,
@ -130,6 +133,7 @@ class CLITestCase(unittest.TestCase):
def test_run_service_with_restart_always(self):
mock_client = mock.create_autospec(docker.APIClient)
mock_client.api_version = DEFAULT_DOCKER_API_VERSION
project = Project.from_config(
name='composetest',
@ -147,6 +151,7 @@ class CLITestCase(unittest.TestCase):
'SERVICE': 'service',
'COMMAND': None,
'-e': [],
'--label': [],
'--user': None,
'--no-deps': None,
'-d': True,
@ -170,6 +175,7 @@ class CLITestCase(unittest.TestCase):
'SERVICE': 'service',
'COMMAND': None,
'-e': [],
'--label': [],
'--user': None,
'--no-deps': None,
'-d': True,
@ -202,6 +208,7 @@ class CLITestCase(unittest.TestCase):
'SERVICE': 'service',
'COMMAND': None,
'-e': [],
'--label': [],
'--user': None,
'--no-deps': None,
'-d': True,

View File

@ -34,7 +34,6 @@ from compose.const import COMPOSEFILE_V3_1 as V3_1
from compose.const import COMPOSEFILE_V3_2 as V3_2
from compose.const import COMPOSEFILE_V3_3 as V3_3
from compose.const import IS_WINDOWS_PLATFORM
from compose.utils import nanoseconds_from_time_seconds
from tests import mock
from tests import unittest
@ -433,6 +432,40 @@ class ConfigTest(unittest.TestCase):
'label_key': 'label_val'
}
def test_load_config_custom_resource_names(self):
base_file = config.ConfigFile(
'base.yaml', {
'version': '3.5',
'volumes': {
'abc': {
'name': 'xyz'
}
},
'networks': {
'abc': {
'name': 'xyz'
}
},
'secrets': {
'abc': {
'name': 'xyz'
}
},
'configs': {
'abc': {
'name': 'xyz'
}
}
}
)
details = config.ConfigDetails('.', [base_file])
loaded_config = config.load(details)
assert loaded_config.networks['abc'] == {'name': 'xyz'}
assert loaded_config.volumes['abc'] == {'name': 'xyz'}
assert loaded_config.secrets['abc']['name'] == 'xyz'
assert loaded_config.configs['abc']['name'] == 'xyz'
def test_load_config_volume_and_network_labels(self):
base_file = config.ConfigFile(
'base.yaml',
@ -1138,9 +1171,12 @@ class ConfigTest(unittest.TestCase):
details = config.ConfigDetails('.', [base_file, override_file])
service_dicts = config.load(details).services
svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes'])
assert sorted(svc_volumes) == sorted(
['/anonymous', '/c:/b:rw', 'vol:/x:ro']
)
for vol in svc_volumes:
assert vol in [
'/anonymous',
'/c:/b:rw',
{'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True}
]
@mock.patch.dict(os.environ)
def test_volume_mode_override(self):
@ -1224,6 +1260,50 @@ class ConfigTest(unittest.TestCase):
assert volume.external == 'data0028'
assert volume.is_named_volume
def test_volumes_long_syntax(self):
base_file = config.ConfigFile(
'base.yaml', {
'version': '2.3',
'services': {
'web': {
'image': 'busybox:latest',
'volumes': [
{
'target': '/anonymous', 'type': 'volume'
}, {
'source': '/abc', 'target': '/xyz', 'type': 'bind'
}, {
'source': '\\\\.\\pipe\\abcd', 'target': '/named_pipe', 'type': 'npipe'
}, {
'type': 'tmpfs', 'target': '/tmpfs'
}
]
},
},
},
)
details = config.ConfigDetails('.', [base_file])
config_data = config.load(details)
volumes = config_data.services[0].get('volumes')
anon_volume = [v for v in volumes if v.target == '/anonymous'][0]
tmpfs_mount = [v for v in volumes if v.type == 'tmpfs'][0]
host_mount = [v for v in volumes if v.type == 'bind'][0]
npipe_mount = [v for v in volumes if v.type == 'npipe'][0]
assert anon_volume.type == 'volume'
assert not anon_volume.is_named_volume
assert tmpfs_mount.target == '/tmpfs'
assert not tmpfs_mount.is_named_volume
assert host_mount.source == os.path.normpath('/abc')
assert host_mount.target == '/xyz'
assert not host_mount.is_named_volume
assert npipe_mount.source == '\\\\.\\pipe\\abcd'
assert npipe_mount.target == '/named_pipe'
assert not npipe_mount.is_named_volume
def test_config_valid_service_names(self):
for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']:
services = config.load(
@ -2493,8 +2573,8 @@ class ConfigTest(unittest.TestCase):
'name': 'web',
'image': 'example/web',
'secrets': [
types.ServiceSecret('one', None, None, None, None),
types.ServiceSecret('source', 'target', '100', '200', 0o777),
types.ServiceSecret('one', None, None, None, None, None),
types.ServiceSecret('source', 'target', '100', '200', 0o777, None),
],
},
]
@ -2540,8 +2620,8 @@ class ConfigTest(unittest.TestCase):
'name': 'web',
'image': 'example/web',
'secrets': [
types.ServiceSecret('one', None, None, None, None),
types.ServiceSecret('source', 'target', '100', '200', 0o777),
types.ServiceSecret('one', None, None, None, None, None),
types.ServiceSecret('source', 'target', '100', '200', 0o777, None),
],
},
]
@ -2578,8 +2658,8 @@ class ConfigTest(unittest.TestCase):
'name': 'web',
'image': 'example/web',
'configs': [
types.ServiceConfig('one', None, None, None, None),
types.ServiceConfig('source', 'target', '100', '200', 0o777),
types.ServiceConfig('one', None, None, None, None, None),
types.ServiceConfig('source', 'target', '100', '200', 0o777, None),
],
},
]
@ -2625,13 +2705,40 @@ class ConfigTest(unittest.TestCase):
'name': 'web',
'image': 'example/web',
'configs': [
types.ServiceConfig('one', None, None, None, None),
types.ServiceConfig('source', 'target', '100', '200', 0o777),
types.ServiceConfig('one', None, None, None, None, None),
types.ServiceConfig('source', 'target', '100', '200', 0o777, None),
],
},
]
assert service_sort(service_dicts) == service_sort(expected)
def test_service_volume_invalid_config(self):
config_details = build_config_details(
{
'version': '3.2',
'services': {
'web': {
'build': {
'context': '.',
'args': None,
},
'volumes': [
{
"type": "volume",
"source": "/data",
"garbage": {
"and": "error"
}
}
]
},
},
}
)
with pytest.raises(ConfigurationError) as exc:
config.load(config_details)
assert "services.web.volumes contains unsupported option: 'garbage'" in exc.exconly()
class NetworkModeTest(unittest.TestCase):
@ -2847,6 +2954,94 @@ class PortsTest(unittest.TestCase):
)
class SubnetTest(unittest.TestCase):
INVALID_SUBNET_TYPES = [
None,
False,
10,
]
INVALID_SUBNET_MAPPINGS = [
"",
"192.168.0.1/sdfsdfs",
"192.168.0.1/",
"192.168.0.1/33",
"192.168.0.1/01",
"192.168.0.1",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156/sdfsdfs",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156/",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156/129",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156/01",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156",
"ge80:0000:0000:0000:0204:61ff:fe9d:f156/128",
"192.168.0.1/31/31",
]
VALID_SUBNET_MAPPINGS = [
"192.168.0.1/0",
"192.168.0.1/32",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156/0",
"fe80:0000:0000:0000:0204:61ff:fe9d:f156/128",
"1:2:3:4:5:6:7:8/0",
"1::/0",
"1:2:3:4:5:6:7::/0",
"1::8/0",
"1:2:3:4:5:6::8/0",
"::/0",
"::8/0",
"::2:3:4:5:6:7:8/0",
"fe80::7:8%eth0/0",
"fe80::7:8%1/0",
"::255.255.255.255/0",
"::ffff:255.255.255.255/0",
"::ffff:0:255.255.255.255/0",
"2001:db8:3:4::192.0.2.33/0",
"64:ff9b::192.0.2.33/0",
]
def test_config_invalid_subnet_type_validation(self):
for invalid_subnet in self.INVALID_SUBNET_TYPES:
with pytest.raises(ConfigurationError) as exc:
self.check_config(invalid_subnet)
assert "contains an invalid type" in exc.value.msg
def test_config_invalid_subnet_format_validation(self):
for invalid_subnet in self.INVALID_SUBNET_MAPPINGS:
with pytest.raises(ConfigurationError) as exc:
self.check_config(invalid_subnet)
assert "should use the CIDR format" in exc.value.msg
def test_config_valid_subnet_format_validation(self):
for valid_subnet in self.VALID_SUBNET_MAPPINGS:
self.check_config(valid_subnet)
def check_config(self, subnet):
config.load(
build_config_details({
'version': '3.5',
'services': {
'web': {
'image': 'busybox'
}
},
'networks': {
'default': {
'ipam': {
'config': [
{
'subnet': subnet
}
],
'driver': 'default'
}
}
}
})
)
class InterpolationTest(unittest.TestCase):
@mock.patch.dict(os.environ)
@ -2894,6 +3089,28 @@ class InterpolationTest(unittest.TestCase):
}
])
@mock.patch.dict(os.environ)
def test_config_file_with_environment_variable_with_defaults(self):
project_dir = 'tests/fixtures/environment-interpolation-with-defaults'
os.environ.update(
IMAGE="busybox",
)
service_dicts = config.load(
config.find(
project_dir, None, Environment.from_env_file(project_dir)
)
).services
self.assertEqual(service_dicts, [
{
'name': 'web',
'image': 'busybox',
'ports': types.ServicePort.parse('80:8000'),
'hostname': 'host-',
}
])
@mock.patch.dict(os.environ)
def test_unset_variable_produces_warning(self):
os.environ.pop('FOO', None)
@ -2948,7 +3165,7 @@ class InterpolationTest(unittest.TestCase):
assert config_dict.secrets == {
'secretdata': {
'external': {'name': 'baz.bar'},
'external_name': 'baz.bar'
'name': 'baz.bar'
}
}
@ -2966,7 +3183,7 @@ class InterpolationTest(unittest.TestCase):
assert config_dict.configs == {
'configdata': {
'external': {'name': 'baz.bar'},
'external_name': 'baz.bar'
'name': 'baz.bar'
}
}
@ -4188,52 +4405,103 @@ class BuildPathTest(unittest.TestCase):
class HealthcheckTest(unittest.TestCase):
def test_healthcheck(self):
service_dict = make_service_dict(
'test',
{'healthcheck': {
'test': ['CMD', 'true'],
'interval': '1s',
'timeout': '1m',
'retries': 3,
'start_period': '10s'
}},
'.',
config_dict = config.load(
build_config_details({
'version': '2.3',
'services': {
'test': {
'image': 'busybox',
'healthcheck': {
'test': ['CMD', 'true'],
'interval': '1s',
'timeout': '1m',
'retries': 3,
'start_period': '10s',
}
}
}
})
)
assert service_dict['healthcheck'] == {
serialized_config = yaml.load(serialize_config(config_dict))
serialized_service = serialized_config['services']['test']
assert serialized_service['healthcheck'] == {
'test': ['CMD', 'true'],
'interval': nanoseconds_from_time_seconds(1),
'timeout': nanoseconds_from_time_seconds(60),
'interval': '1s',
'timeout': '1m',
'retries': 3,
'start_period': nanoseconds_from_time_seconds(10)
'start_period': '10s'
}
def test_disable(self):
service_dict = make_service_dict(
'test',
{'healthcheck': {
'disable': True,
}},
'.',
config_dict = config.load(
build_config_details({
'version': '2.3',
'services': {
'test': {
'image': 'busybox',
'healthcheck': {
'disable': True,
}
}
}
})
)
assert service_dict['healthcheck'] == {
serialized_config = yaml.load(serialize_config(config_dict))
serialized_service = serialized_config['services']['test']
assert serialized_service['healthcheck'] == {
'test': ['NONE'],
}
def test_disable_with_other_config_is_invalid(self):
with pytest.raises(ConfigurationError) as excinfo:
make_service_dict(
'invalid-healthcheck',
{'healthcheck': {
'disable': True,
'interval': '1s',
}},
'.',
config.load(
build_config_details({
'version': '2.3',
'services': {
'invalid-healthcheck': {
'image': 'busybox',
'healthcheck': {
'disable': True,
'interval': '1s',
}
}
}
})
)
assert 'invalid-healthcheck' in excinfo.exconly()
assert 'disable' in excinfo.exconly()
assert '"disable: true" cannot be combined with other options' in excinfo.exconly()
def test_healthcheck_with_invalid_test(self):
with pytest.raises(ConfigurationError) as excinfo:
config.load(
build_config_details({
'version': '2.3',
'services': {
'invalid-healthcheck': {
'image': 'busybox',
'healthcheck': {
'test': ['true'],
'interval': '1s',
'timeout': '1m',
'retries': 3,
'start_period': '10s',
}
}
}
})
)
assert 'invalid-healthcheck' in excinfo.exconly()
assert 'the first item must be either NONE, CMD or CMD-SHELL' in excinfo.exconly()
class GetDefaultConfigFilesTestCase(unittest.TestCase):

View File

@ -3,6 +3,11 @@ from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals
import codecs
import pytest
from compose.config.environment import env_vars_from_file
from compose.config.environment import Environment
from tests import unittest
@ -38,3 +43,12 @@ class EnvironmentTest(unittest.TestCase):
assert env.get_boolean('BAZ') is False
assert env.get_boolean('FOOBAR') is True
assert env.get_boolean('UNDEFINED') is False
def test_env_vars_from_file_bom(self):
tmpdir = pytest.ensuretemp('env_file')
self.addCleanup(tmpdir.remove)
with codecs.open('{}/bom.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f:
f.write('\ufeffPARK_BOM=박봄\n')
assert env_vars_from_file(str(tmpdir.join('bom.env'))) == {
'PARK_BOM': '박봄'
}

View File

@ -9,12 +9,22 @@ from compose.config.interpolation import Interpolator
from compose.config.interpolation import InvalidInterpolation
from compose.config.interpolation import TemplateWithDefaults
from compose.const import COMPOSEFILE_V2_0 as V2_0
from compose.const import COMPOSEFILE_V3_1 as V3_1
from compose.const import COMPOSEFILE_V2_3 as V2_3
from compose.const import COMPOSEFILE_V3_4 as V3_4
@pytest.fixture
def mock_env():
return Environment({'USER': 'jenny', 'FOO': 'bar'})
return Environment({
'USER': 'jenny',
'FOO': 'bar',
'TRUE': 'True',
'FALSE': 'OFF',
'POSINT': '50',
'NEGINT': '-200',
'FLOAT': '0.145',
'MODE': '0600',
})
@pytest.fixture
@ -102,7 +112,189 @@ def test_interpolate_environment_variables_in_secrets(mock_env):
},
'other': {},
}
value = interpolate_environment_variables(V3_1, secrets, 'volume', mock_env)
value = interpolate_environment_variables(V3_4, secrets, 'secret', mock_env)
assert value == expected
def test_interpolate_environment_services_convert_types_v2(mock_env):
entry = {
'service1': {
'blkio_config': {
'weight': '${POSINT}',
'weight_device': [{'file': '/dev/sda1', 'weight': '${POSINT}'}]
},
'cpus': '${FLOAT}',
'cpu_count': '$POSINT',
'healthcheck': {
'retries': '${POSINT:-3}',
'disable': '${FALSE}',
'command': 'true'
},
'mem_swappiness': '${DEFAULT:-127}',
'oom_score_adj': '${NEGINT}',
'scale': '${POSINT}',
'ulimits': {
'nproc': '${POSINT}',
'nofile': {
'soft': '${POSINT}',
'hard': '${DEFAULT:-40000}'
},
},
'privileged': '${TRUE}',
'read_only': '${DEFAULT:-no}',
'tty': '${DEFAULT:-N}',
'stdin_open': '${DEFAULT-on}',
}
}
expected = {
'service1': {
'blkio_config': {
'weight': 50,
'weight_device': [{'file': '/dev/sda1', 'weight': 50}]
},
'cpus': 0.145,
'cpu_count': 50,
'healthcheck': {
'retries': 50,
'disable': False,
'command': 'true'
},
'mem_swappiness': 127,
'oom_score_adj': -200,
'scale': 50,
'ulimits': {
'nproc': 50,
'nofile': {
'soft': 50,
'hard': 40000
},
},
'privileged': True,
'read_only': False,
'tty': False,
'stdin_open': True,
}
}
value = interpolate_environment_variables(V2_3, entry, 'service', mock_env)
assert value == expected
def test_interpolate_environment_services_convert_types_v3(mock_env):
entry = {
'service1': {
'healthcheck': {
'retries': '${POSINT:-3}',
'disable': '${FALSE}',
'command': 'true'
},
'ulimits': {
'nproc': '${POSINT}',
'nofile': {
'soft': '${POSINT}',
'hard': '${DEFAULT:-40000}'
},
},
'privileged': '${TRUE}',
'read_only': '${DEFAULT:-no}',
'tty': '${DEFAULT:-N}',
'stdin_open': '${DEFAULT-on}',
'deploy': {
'update_config': {
'parallelism': '${DEFAULT:-2}',
'max_failure_ratio': '${FLOAT}',
},
'restart_policy': {
'max_attempts': '$POSINT',
},
'replicas': '${DEFAULT-3}'
},
'ports': [{'target': '${POSINT}', 'published': '${DEFAULT:-5000}'}],
'configs': [{'mode': '${MODE}', 'source': 'config1'}],
'secrets': [{'mode': '${MODE}', 'source': 'secret1'}],
}
}
expected = {
'service1': {
'healthcheck': {
'retries': 50,
'disable': False,
'command': 'true'
},
'ulimits': {
'nproc': 50,
'nofile': {
'soft': 50,
'hard': 40000
},
},
'privileged': True,
'read_only': False,
'tty': False,
'stdin_open': True,
'deploy': {
'update_config': {
'parallelism': 2,
'max_failure_ratio': 0.145,
},
'restart_policy': {
'max_attempts': 50,
},
'replicas': 3
},
'ports': [{'target': 50, 'published': 5000}],
'configs': [{'mode': 0o600, 'source': 'config1'}],
'secrets': [{'mode': 0o600, 'source': 'secret1'}],
}
}
value = interpolate_environment_variables(V3_4, entry, 'service', mock_env)
assert value == expected
def test_interpolate_environment_network_convert_types(mock_env):
entry = {
'network1': {
'external': '${FALSE}',
'attachable': '${TRUE}',
'internal': '${DEFAULT:-false}'
}
}
expected = {
'network1': {
'external': False,
'attachable': True,
'internal': False,
}
}
value = interpolate_environment_variables(V3_4, entry, 'network', mock_env)
assert value == expected
def test_interpolate_environment_external_resource_convert_types(mock_env):
entry = {
'resource1': {
'external': '${TRUE}',
}
}
expected = {
'resource1': {
'external': True,
}
}
value = interpolate_environment_variables(V3_4, entry, 'network', mock_env)
assert value == expected
value = interpolate_environment_variables(V3_4, entry, 'volume', mock_env)
assert value == expected
value = interpolate_environment_variables(V3_4, entry, 'secret', mock_env)
assert value == expected
value = interpolate_environment_variables(V3_4, entry, 'config', mock_env)
assert value == expected

View File

@ -100,11 +100,37 @@ class TestServicePort(object):
'published': 25001
} in reprs
def test_parse_port_publish_range(self):
ports = ServicePort.parse('4440-4450:4000')
assert len(ports) == 1
reprs = [p.repr() for p in ports]
assert {
'target': 4000,
'published': '4440-4450'
} in reprs
def test_parse_invalid_port(self):
port_def = '4000p'
with pytest.raises(ConfigurationError):
ServicePort.parse(port_def)
def test_parse_invalid_publish_range(self):
port_def = '-4000:4000'
with pytest.raises(ConfigurationError):
ServicePort.parse(port_def)
port_def = 'asdf:4000'
with pytest.raises(ConfigurationError):
ServicePort.parse(port_def)
port_def = '1234-12f:4000'
with pytest.raises(ConfigurationError):
ServicePort.parse(port_def)
port_def = '1234-1235-1239:4000'
with pytest.raises(ConfigurationError):
ServicePort.parse(port_def)
class TestVolumeSpec(object):

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals
import docker
import pytest
from docker.constants import DEFAULT_DOCKER_API_VERSION
from docker.errors import APIError
from .. import mock
@ -40,6 +41,7 @@ class ServiceTest(unittest.TestCase):
def setUp(self):
self.mock_client = mock.create_autospec(docker.APIClient)
self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION
def test_containers(self):
service = Service('db', self.mock_client, 'myproject', image='foo')
@ -145,12 +147,6 @@ class ServiceTest(unittest.TestCase):
self.assertEqual(service._get_volumes_from(), [container_id + ':rw'])
from_service.create_container.assert_called_once_with()
def test_split_domainname_none(self):
service = Service('foo', image='foo', hostname='name', client=self.mock_client)
opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['hostname'], 'name', 'hostname')
self.assertFalse('domainname' in opts, 'domainname')
def test_memory_swap_limit(self):
self.mock_client.create_host_config.return_value = {}
@ -179,7 +175,7 @@ class ServiceTest(unittest.TestCase):
external_links=['default_foo_1']
)
with self.assertRaises(DependencyError):
service.get_container_name(1)
service.get_container_name('foo', 1)
def test_mem_reservation(self):
self.mock_client.create_host_config.return_value = {}
@ -232,7 +228,29 @@ class ServiceTest(unittest.TestCase):
{'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}}
)
def test_stop_grace_period(self):
self.mock_client.api_version = '1.25'
self.mock_client.create_host_config.return_value = {}
service = Service(
'foo',
image='foo',
client=self.mock_client,
stop_grace_period="1m35s")
opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['stop_timeout'], 95)
def test_split_domainname_none(self):
service = Service(
'foo',
image='foo',
hostname='name.domain.tld',
client=self.mock_client)
opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['hostname'], 'name.domain.tld', 'hostname')
self.assertFalse('domainname' in opts, 'domainname')
def test_split_domainname_fqdn(self):
self.mock_client.api_version = '1.22'
service = Service(
'foo',
hostname='name.domain.tld',
@ -243,6 +261,7 @@ class ServiceTest(unittest.TestCase):
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
def test_split_domainname_both(self):
self.mock_client.api_version = '1.22'
service = Service(
'foo',
hostname='name',
@ -254,6 +273,7 @@ class ServiceTest(unittest.TestCase):
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
def test_split_domainname_weird(self):
self.mock_client.api_version = '1.22'
service = Service(
'foo',
hostname='name.sub',
@ -478,6 +498,8 @@ class ServiceTest(unittest.TestCase):
network_mode=None,
target=None,
shmsize=None,
extra_hosts=None,
container_limits={'memory': None},
)
def test_ensure_image_exists_no_build(self):
@ -518,7 +540,9 @@ class ServiceTest(unittest.TestCase):
cache_from=None,
network_mode=None,
target=None,
shmsize=None
shmsize=None,
extra_hosts=None,
container_limits={'memory': None},
)
def test_build_does_not_pull(self):
@ -857,6 +881,7 @@ class ServiceVolumesTest(unittest.TestCase):
def setUp(self):
self.mock_client = mock.create_autospec(docker.APIClient)
self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION
def test_build_volume_binding(self):
binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True))
@ -914,7 +939,7 @@ class ServiceVolumesTest(unittest.TestCase):
VolumeSpec.parse('imagedata:/mnt/image/data:rw'),
]
volumes = get_container_data_volumes(container, options, ['/dev/tmpfs'])
volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], [])
assert sorted(volumes) == sorted(expected)
def test_merge_volume_bindings(self):
@ -950,7 +975,7 @@ class ServiceVolumesTest(unittest.TestCase):
'existingvolume:/existing/volume:rw',
]
binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container)
binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, [])
assert sorted(binds) == sorted(expected)
assert affinity == {'affinity:container': '=cdefab'}
@ -1110,8 +1135,8 @@ class ServiceSecretTest(unittest.TestCase):
)
volumes = service.get_secret_volumes()
assert volumes[0].external == secret1['file']
assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target)
assert volumes[0].source == secret1['file']
assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target)
def test_get_secret_volumes_abspath(self):
secret1 = {
@ -1126,8 +1151,8 @@ class ServiceSecretTest(unittest.TestCase):
)
volumes = service.get_secret_volumes()
assert volumes[0].external == secret1['file']
assert volumes[0].internal == secret1['secret'].target
assert volumes[0].source == secret1['file']
assert volumes[0].target == secret1['secret'].target
def test_get_secret_volumes_no_target(self):
secret1 = {
@ -1142,5 +1167,5 @@ class ServiceSecretTest(unittest.TestCase):
)
volumes = service.get_secret_volumes()
assert volumes[0].external == secret1['file']
assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source)
assert volumes[0].source == secret1['file']
assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source)

View File

@ -18,7 +18,6 @@ deps =
-rrequirements-dev.txt
commands =
py.test -v \
--full-trace \
--cov=compose \
--cov-report html \
--cov-report term \