Merge pull request #3418 from shin-/bump-1.7.1

Bump 1.7.1
This commit is contained in:
Joffrey F 2016-05-04 13:03:11 -07:00
commit 6c29830127
28 changed files with 342 additions and 67 deletions

View File

@ -1,6 +1,47 @@
Change log Change log
========== ==========
1.7.1 (2016-05-04)
-----------------
Bug Fixes
- Fixed a bug where the output of `docker-compose config` for v1 files
would be an invalid configuration file.
- Fixed a bug where `docker-compose config` would not check the validity
of links.
- Fixed an issue where `docker-compose help` would not output a list of
available commands and generic options as expected.
- Fixed an issue where filtering by service when using `docker-compose logs`
would not apply for newly created services.
- Fixed a bug where unchanged services would sometimes be recreated in
in the up phase when using Compose with Python 3.
- Fixed an issue where API errors encountered during the up phase would
not be recognized as a failure state by Compose.
- Fixed a bug where Compose would raise a NameError because of an undefined
exception name on non-Windows platforms.
- Fixed a bug where the wrong version of `docker-py` would sometimes be
installed alongside Compose.
- Fixed a bug where the host value output by `docker-machine config default`
would not be recognized as valid options by the `docker-compose`
command line.
- Fixed an issue where Compose would sometimes exit unexpectedly while
reading events broadcasted by a Swarm cluster.
- Corrected a statement in the docs about the location of the `.env` file,
which is indeed read from the current directory, instead of in the same
location as the Compose file.
1.7.0 (2016-04-13) 1.7.0 (2016-04-13)
------------------ ------------------

View File

@ -49,11 +49,11 @@ RUN set -ex; \
# Install pip # Install pip
RUN set -ex; \ RUN set -ex; \
curl -L https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz | tar -xz; \ curl -L https://pypi.python.org/packages/source/p/pip/pip-8.1.1.tar.gz | tar -xz; \
cd pip-7.0.1; \ cd pip-8.1.1; \
python setup.py install; \ python setup.py install; \
cd ..; \ cd ..; \
rm -rf pip-7.0.1 rm -rf pip-8.1.1
# Python3 requires a valid locale # Python3 requires a valid locale
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen

View File

@ -1,4 +1,4 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
__version__ = '1.7.0' __version__ = '1.7.1'

View File

@ -21,12 +21,15 @@ log = logging.getLogger(__name__)
def project_from_options(project_dir, options): def project_from_options(project_dir, options):
environment = Environment.from_env_file(project_dir) environment = Environment.from_env_file(project_dir)
host = options.get('--host')
if host is not None:
host = host.lstrip('=')
return get_project( return get_project(
project_dir, project_dir,
get_config_path_from_options(project_dir, options, environment), get_config_path_from_options(project_dir, options, environment),
project_name=options.get('--project-name'), project_name=options.get('--project-name'),
verbose=options.get('--verbose'), verbose=options.get('--verbose'),
host=options.get('--host'), host=host,
tls_config=tls_config_from_options(options), tls_config=tls_config_from_options(options),
environment=environment environment=environment
) )

View File

@ -24,6 +24,7 @@ from ..const import IS_WINDOWS_PLATFORM
from ..progress_stream import StreamOutputError from ..progress_stream import StreamOutputError
from ..project import NoSuchService from ..project import NoSuchService
from ..project import OneOffFilter from ..project import OneOffFilter
from ..project import ProjectError
from ..service import BuildAction from ..service import BuildAction
from ..service import BuildError from ..service import BuildError
from ..service import ConvergenceStrategy from ..service import ConvergenceStrategy
@ -58,7 +59,7 @@ def main():
except (KeyboardInterrupt, signals.ShutdownException): except (KeyboardInterrupt, signals.ShutdownException):
log.error("Aborting.") log.error("Aborting.")
sys.exit(1) sys.exit(1)
except (UserError, NoSuchService, ConfigurationError) as e: except (UserError, NoSuchService, ConfigurationError, ProjectError) as e:
log.error(e.msg) log.error(e.msg)
sys.exit(1) sys.exit(1)
except BuildError as e: except BuildError as e:
@ -142,7 +143,7 @@ class TopLevelCommand(object):
"""Define and run multi-container applications with Docker. """Define and run multi-container applications with Docker.
Usage: Usage:
docker-compose [-f=<arg>...] [options] [COMMAND] [ARGS...] docker-compose [-f <arg>...] [options] [COMMAND] [ARGS...]
docker-compose -h|--help docker-compose -h|--help
Options: Options:
@ -361,10 +362,14 @@ class TopLevelCommand(object):
""" """
Get help on a command. Get help on a command.
Usage: help COMMAND Usage: help [COMMAND]
""" """
handler = get_handler(cls, options['COMMAND']) if options['COMMAND']:
raise SystemExit(getdoc(handler)) subject = get_handler(cls, options['COMMAND'])
else:
subject = cls
print(getdoc(subject))
def kill(self, options): def kill(self, options):
""" """
@ -411,7 +416,8 @@ class TopLevelCommand(object):
self.project, self.project,
containers, containers,
options['--no-color'], options['--no-color'],
log_args).run() log_args,
event_stream=self.project.events(service_names=options['SERVICE'])).run()
def pause(self, options): def pause(self, options):
""" """

View File

@ -12,6 +12,13 @@ from six.moves import input
import compose import compose
# WindowsError is not defined on non-win32 platforms. Avoid runtime errors by
# defining it as OSError (its parent class) if missing.
try:
WindowsError
except NameError:
WindowsError = OSError
def yesno(prompt, default=None): def yesno(prompt, default=None):
""" """

View File

@ -37,6 +37,7 @@ from .validation import validate_against_config_schema
from .validation import validate_config_section from .validation import validate_config_section
from .validation import validate_depends_on from .validation import validate_depends_on
from .validation import validate_extends_file_path from .validation import validate_extends_file_path
from .validation import validate_links
from .validation import validate_network_mode from .validation import validate_network_mode
from .validation import validate_service_constraints from .validation import validate_service_constraints
from .validation import validate_top_level_object from .validation import validate_top_level_object
@ -580,6 +581,7 @@ def validate_service(service_config, service_names, version):
validate_ulimits(service_config) validate_ulimits(service_config)
validate_network_mode(service_config, service_names) validate_network_mode(service_config, service_names)
validate_depends_on(service_config, service_names) validate_depends_on(service_config, service_names)
validate_links(service_config, service_names)
if not service_dict.get('image') and has_uppercase(service_name): if not service_dict.get('image') and has_uppercase(service_name):
raise ConfigurationError( raise ConfigurationError(
@ -726,7 +728,7 @@ class MergeDict(dict):
merged = parse_sequence_func(self.base.get(field, [])) merged = parse_sequence_func(self.base.get(field, []))
merged.update(parse_sequence_func(self.override.get(field, []))) merged.update(parse_sequence_func(self.override.get(field, [])))
self[field] = [item.repr() for item in merged.values()] self[field] = [item.repr() for item in sorted(merged.values())]
def merge_scalar(self, field): def merge_scalar(self, field):
if self.needs_merge(field): if self.needs_merge(field):
@ -928,7 +930,7 @@ def dict_from_path_mappings(path_mappings):
def path_mappings_from_dict(d): def path_mappings_from_dict(d):
return [join_path_mapping(v) for v in d.items()] return [join_path_mapping(v) for v in sorted(d.items())]
def split_path_mapping(volume_path): def split_path_mapping(volume_path):

View File

@ -3,10 +3,11 @@ from __future__ import unicode_literals
VERSION_EXPLANATION = ( VERSION_EXPLANATION = (
'Either specify a version of "2" (or "2.0") and place your service ' 'You might be seeing this error because you\'re using the wrong Compose '
'definitions under the `services` key, or omit the `version` key and place ' 'file version. Either specify a version of "2" (or "2.0") and place your '
'your service definitions at the root of the file to use version 1.\n' 'service definitions under the `services` key, or omit the `version` key '
'For more on the Compose file format versions, see ' 'and place your service definitions at the root of the file to use '
'version 1.\nFor more on the Compose file format versions, see '
'https://docs.docker.com/compose/compose-file/') 'https://docs.docker.com/compose/compose-file/')

View File

@ -5,6 +5,8 @@ import six
import yaml import yaml
from compose.config import types from compose.config import types
from compose.config.config import V1
from compose.config.config import V2_0
def serialize_config_type(dumper, data): def serialize_config_type(dumper, data):
@ -17,14 +19,36 @@ yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type)
def serialize_config(config): def serialize_config(config):
denormalized_services = [
denormalize_service_dict(service_dict, config.version)
for service_dict in config.services
]
services = {
service_dict.pop('name'): service_dict
for service_dict in denormalized_services
}
output = { output = {
'version': config.version, 'version': V2_0,
'services': {service.pop('name'): service for service in config.services}, 'services': services,
'networks': config.networks, 'networks': config.networks,
'volumes': config.volumes, 'volumes': config.volumes,
} }
return yaml.safe_dump( return yaml.safe_dump(
output, output,
default_flow_style=False, default_flow_style=False,
indent=2, indent=2,
width=80) width=80)
def denormalize_service_dict(service_dict, version):
service_dict = service_dict.copy()
if 'restart' in service_dict:
service_dict['restart'] = types.serialize_restart_spec(service_dict['restart'])
if version == V1 and 'network_mode' not in service_dict:
service_dict['network_mode'] = 'bridge'
return service_dict

View File

@ -7,6 +7,8 @@ from __future__ import unicode_literals
import os import os
from collections import namedtuple from collections import namedtuple
import six
from compose.config.config import V1 from compose.config.config import V1
from compose.config.errors import ConfigurationError from compose.config.errors import ConfigurationError
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
@ -89,6 +91,13 @@ def parse_restart_spec(restart_config):
return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} return {'Name': name, 'MaximumRetryCount': int(max_retry_count)}
def serialize_restart_spec(restart_spec):
parts = [restart_spec['Name']]
if restart_spec['MaximumRetryCount']:
parts.append(six.text_type(restart_spec['MaximumRetryCount']))
return ':'.join(parts)
def parse_extra_hosts(extra_hosts_config): def parse_extra_hosts(extra_hosts_config):
if not extra_hosts_config: if not extra_hosts_config:
return {} return {}

View File

@ -171,6 +171,14 @@ def validate_network_mode(service_config, service_names):
"is undefined.".format(s=service_config, dep=dependency)) "is undefined.".format(s=service_config, dep=dependency))
def validate_links(service_config, service_names):
for link in service_config.config.get('links', []):
if link.split(':')[0] not in service_names:
raise ConfigurationError(
"Service '{s.name}' has a link to service '{link}' which is "
"undefined.".format(s=service_config, link=link))
def validate_depends_on(service_config, service_names): def validate_depends_on(service_config, service_names):
for dependency in service_config.config.get('depends_on', []): for dependency in service_config.config.get('depends_on', []):
if dependency not in service_names: if dependency not in service_names:
@ -211,7 +219,7 @@ def handle_error_for_schema_with_id(error, path):
return get_unsupported_config_msg(path, invalid_config_key) return get_unsupported_config_msg(path, invalid_config_key)
if not error.path: if not error.path:
return '{}\n{}'.format(error.message, VERSION_EXPLANATION) return '{}\n\n{}'.format(error.message, VERSION_EXPLANATION)
def handle_generic_error(error, path): def handle_generic_error(error, path):
@ -408,6 +416,6 @@ def handle_errors(errors, format_error_func, filename):
error_msg = '\n'.join(format_error_func(error) for error in errors) error_msg = '\n'.join(format_error_func(error) for error in errors)
raise ConfigurationError( raise ConfigurationError(
"Validation failed{file_msg}, reason(s):\n{error_msg}".format( "The Compose file{file_msg} is invalid because:\n{error_msg}".format(
file_msg=" in file '{}'".format(filename) if filename else "", file_msg=" '{}'".format(filename) if filename else "",
error_msg=error_msg)) error_msg=error_msg))

View File

@ -59,7 +59,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None):
if error_to_reraise: if error_to_reraise:
raise error_to_reraise raise error_to_reraise
return results return results, errors
def _no_deps(x): def _no_deps(x):

View File

@ -342,7 +342,10 @@ class Project(object):
filters={'label': self.labels()}, filters={'label': self.labels()},
decode=True decode=True
): ):
if event['status'] in IMAGE_EVENTS: # The first part of this condition is a guard against some events
# broadcasted by swarm that don't have a status field.
# See https://github.com/docker/compose/issues/3316
if 'status' not in event or event['status'] in IMAGE_EVENTS:
# We don't receive any image events because labels aren't applied # We don't receive any image events because labels aren't applied
# to images # to images
continue continue
@ -387,13 +390,18 @@ class Project(object):
def get_deps(service): def get_deps(service):
return {self.get_service(dep) for dep in service.get_dependency_names()} return {self.get_service(dep) for dep in service.get_dependency_names()}
results = parallel.parallel_execute( results, errors = parallel.parallel_execute(
services, services,
do, do,
operator.attrgetter('name'), operator.attrgetter('name'),
None, None,
get_deps get_deps
) )
if errors:
raise ProjectError(
'Encountered errors while bringing up the project.'
)
return [ return [
container container
for svc_containers in results for svc_containers in results
@ -528,3 +536,7 @@ class NoSuchService(Exception):
def __str__(self): def __str__(self):
return self.msg return self.msg
class ProjectError(Exception):
pass

View File

@ -453,20 +453,20 @@ class Service(object):
connected_networks = container.get('NetworkSettings.Networks') connected_networks = container.get('NetworkSettings.Networks')
for network, netdefs in self.networks.items(): for network, netdefs in self.networks.items():
aliases = netdefs.get('aliases', [])
ipv4_address = netdefs.get('ipv4_address', None)
ipv6_address = netdefs.get('ipv6_address', None)
if network in connected_networks: if network in connected_networks:
if short_id_alias_exists(container, network):
continue
self.client.disconnect_container_from_network( self.client.disconnect_container_from_network(
container.id, network) container.id,
network)
self.client.connect_container_to_network( self.client.connect_container_to_network(
container.id, network, container.id, network,
aliases=list(self._get_aliases(container).union(aliases)), aliases=self._get_aliases(netdefs, container),
ipv4_address=ipv4_address, ipv4_address=netdefs.get('ipv4_address', None),
ipv6_address=ipv6_address, ipv6_address=netdefs.get('ipv6_address', None),
links=self._get_links(False) links=self._get_links(False))
)
def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT):
for c in self.duplicate_containers(): for c in self.duplicate_containers():
@ -533,11 +533,32 @@ class Service(object):
numbers = [c.number for c in containers] numbers = [c.number for c in containers]
return 1 if not numbers else max(numbers) + 1 return 1 if not numbers else max(numbers) + 1
def _get_aliases(self, container): def _get_aliases(self, network, container=None):
if container.labels.get(LABEL_ONE_OFF) == "True": if container and container.labels.get(LABEL_ONE_OFF) == "True":
return set() return []
return {self.name, container.short_id} return list(
{self.name} |
({container.short_id} if container else set()) |
set(network.get('aliases', ()))
)
def build_default_networking_config(self):
if not self.networks:
return {}
network = self.networks[self.network_mode.id]
endpoint = {
'Aliases': self._get_aliases(network),
'IPAMConfig': {},
}
if network.get('ipv4_address'):
endpoint['IPAMConfig']['IPv4Address'] = network.get('ipv4_address')
if network.get('ipv6_address'):
endpoint['IPAMConfig']['IPv6Address'] = network.get('ipv6_address')
return {"EndpointsConfig": {self.network_mode.id: endpoint}}
def _get_links(self, link_to_self): def _get_links(self, link_to_self):
links = {} links = {}
@ -633,6 +654,10 @@ class Service(object):
override_options, override_options,
one_off=one_off) one_off=one_off)
networking_config = self.build_default_networking_config()
if networking_config:
container_options['networking_config'] = networking_config
container_options['environment'] = format_environment( container_options['environment'] = format_environment(
container_options['environment']) container_options['environment'])
return container_options return container_options
@ -796,6 +821,12 @@ class Service(object):
log.error(six.text_type(e)) log.error(six.text_type(e))
def short_id_alias_exists(container, network):
aliases = container.get(
'NetworkSettings.Networks.{net}.Aliases'.format(net=network)) or ()
return container.short_id in aliases
class NetworkMode(object): class NetworkMode(object):
"""A `standard` network mode (ex: host, bridge)""" """A `standard` network mode (ex: host, bridge)"""

View File

@ -13,8 +13,8 @@ weight=10
# Environment file # Environment file
Compose supports declaring default environment variables in an environment Compose supports declaring default environment variables in an environment
file named `.env` and placed in the same folder as your file named `.env` placed in the folder `docker-compose` command is executed from
[compose file](compose-file.md). *(current working directory)*.
Compose expects each line in an env file to be in `VAR=VAL` format. Lines Compose expects each line in an env file to be in `VAR=VAL` format. Lines
beginning with `#` (i.e. comments) are ignored, as are blank lines. beginning with `#` (i.e. comments) are ignored, as are blank lines.

View File

@ -39,7 +39,7 @@ which the release page specifies, in your terminal.
The following is an example command illustrating the format: The following is an example command illustrating the format:
curl -L https://github.com/docker/compose/releases/download/1.7.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose curl -L https://github.com/docker/compose/releases/download/1.7.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
If you have problems installing with `curl`, see If you have problems installing with `curl`, see
[Alternative Install Options](#alternative-install-options). [Alternative Install Options](#alternative-install-options).
@ -54,7 +54,7 @@ which the release page specifies, in your terminal.
7. Test the installation. 7. Test the installation.
$ docker-compose --version $ docker-compose --version
docker-compose version: 1.7.0 docker-compose version: 1.7.1
## Alternative install options ## Alternative install options
@ -77,7 +77,7 @@ to get started.
Compose can also be run inside a container, from a small bash script wrapper. Compose can also be run inside a container, from a small bash script wrapper.
To install compose as a container run: To install compose as a container run:
$ curl -L https://github.com/docker/compose/releases/download/1.7.0/run.sh > /usr/local/bin/docker-compose $ curl -L https://github.com/docker/compose/releases/download/1.7.1/run.sh > /usr/local/bin/docker-compose
$ chmod +x /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose
## Master builds ## Master builds

View File

@ -1,6 +1,6 @@
PyYAML==3.11 PyYAML==3.11
cached-property==1.2.0 cached-property==1.2.0
docker-py==1.8.0 docker-py==1.8.1
dockerpty==0.4.1 dockerpty==0.4.1
docopt==0.6.1 docopt==0.6.1
enum34==1.0.4 enum34==1.0.4

View File

@ -15,7 +15,7 @@
set -e set -e
VERSION="1.7.0" VERSION="1.7.1"
IMAGE="docker/compose:$VERSION" IMAGE="docker/compose:$VERSION"

View File

@ -34,7 +34,7 @@ install_requires = [
'requests >= 2.6.1, < 2.8', 'requests >= 2.6.1, < 2.8',
'texttable >= 0.8.1, < 0.9', 'texttable >= 0.8.1, < 0.9',
'websocket-client >= 0.32.0, < 1.0', 'websocket-client >= 0.32.0, < 1.0',
'docker-py > 1.7.2, < 2', 'docker-py >= 1.8.1, < 2',
'dockerpty >= 0.4.1, < 0.5', 'dockerpty >= 0.4.1, < 0.5',
'six >= 1.3.0, < 2', 'six >= 1.3.0, < 2',
'jsonschema >= 2.5.1, < 3', 'jsonschema >= 2.5.1, < 3',

View File

@ -140,20 +140,23 @@ class CLITestCase(DockerClientTestCase):
def test_help(self): def test_help(self):
self.base_dir = 'tests/fixtures/no-composefile' self.base_dir = 'tests/fixtures/no-composefile'
result = self.dispatch(['help', 'up'], returncode=1) result = self.dispatch(['help', 'up'], returncode=0)
assert 'Usage: up [options] [SERVICE...]' in result.stderr assert 'Usage: up [options] [SERVICE...]' in result.stdout
# Prevent tearDown from trying to create a project # Prevent tearDown from trying to create a project
self.base_dir = None self.base_dir = None
# TODO: this shouldn't be v2-dependent def test_shorthand_host_opt(self):
@v2_only() self.dispatch(
['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')),
'up', '-d'],
returncode=0
)
def test_config_list_services(self): def test_config_list_services(self):
self.base_dir = 'tests/fixtures/v2-full' self.base_dir = 'tests/fixtures/v2-full'
result = self.dispatch(['config', '--services']) result = self.dispatch(['config', '--services'])
assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'} assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'}
# TODO: this shouldn't be v2-dependent
@v2_only()
def test_config_quiet_with_error(self): def test_config_quiet_with_error(self):
self.base_dir = None self.base_dir = None
result = self.dispatch([ result = self.dispatch([
@ -162,14 +165,10 @@ class CLITestCase(DockerClientTestCase):
], returncode=1) ], returncode=1)
assert "'notaservice' must be a mapping" in result.stderr assert "'notaservice' must be a mapping" in result.stderr
# TODO: this shouldn't be v2-dependent
@v2_only()
def test_config_quiet(self): def test_config_quiet(self):
self.base_dir = 'tests/fixtures/v2-full' self.base_dir = 'tests/fixtures/v2-full'
assert self.dispatch(['config', '-q']).stdout == '' assert self.dispatch(['config', '-q']).stdout == ''
# TODO: this shouldn't be v2-dependent
@v2_only()
def test_config_default(self): def test_config_default(self):
self.base_dir = 'tests/fixtures/v2-full' self.base_dir = 'tests/fixtures/v2-full'
result = self.dispatch(['config']) result = self.dispatch(['config'])
@ -198,6 +197,58 @@ class CLITestCase(DockerClientTestCase):
} }
assert output == expected assert output == expected
def test_config_restart(self):
self.base_dir = 'tests/fixtures/restart'
result = self.dispatch(['config'])
assert yaml.load(result.stdout) == {
'version': '2.0',
'services': {
'never': {
'image': 'busybox',
'restart': 'no',
},
'always': {
'image': 'busybox',
'restart': 'always',
},
'on-failure': {
'image': 'busybox',
'restart': 'on-failure',
},
'on-failure-5': {
'image': 'busybox',
'restart': 'on-failure:5',
},
},
'networks': {},
'volumes': {},
}
def test_config_v1(self):
self.base_dir = 'tests/fixtures/v1-config'
result = self.dispatch(['config'])
assert yaml.load(result.stdout) == {
'version': '2.0',
'services': {
'net': {
'image': 'busybox',
'network_mode': 'bridge',
},
'volume': {
'image': 'busybox',
'volumes': ['/data:rw'],
'network_mode': 'bridge',
},
'app': {
'image': 'busybox',
'volumes_from': ['service:volume:rw'],
'network_mode': 'service:net',
},
},
'networks': {},
'volumes': {},
}
def test_ps(self): def test_ps(self):
self.project.get_service('simple').create_container() self.project.get_service('simple').create_container()
result = self.dispatch(['ps']) result = self.dispatch(['ps'])
@ -683,9 +734,7 @@ class CLITestCase(DockerClientTestCase):
['-f', 'v2-invalid.yml', 'up', '-d'], ['-f', 'v2-invalid.yml', 'up', '-d'],
returncode=1) returncode=1)
# TODO: fix validation error messages for v2 files assert "Unsupported config option for services.bar: 'net'" in result.stderr
# assert "Unsupported config option for service 'web': 'net'" in exc.exconly()
assert "Unsupported config option" in result.stderr
def test_up_with_net_v1(self): def test_up_with_net_v1(self):
self.base_dir = 'tests/fixtures/net-container' self.base_dir = 'tests/fixtures/net-container'

View File

@ -1,3 +1,5 @@
mydb:
build: '.'
myweb: myweb:
build: '.' build: '.'
extends: extends:

View File

@ -0,0 +1,14 @@
version: "2"
services:
never:
image: busybox
restart: "no"
always:
image: busybox
restart: always
on-failure:
image: busybox
restart: on-failure
on-failure-5:
image: busybox
restart: "on-failure:5"

View File

@ -0,0 +1,10 @@
net:
image: busybox
volume:
image: busybox
volumes:
- /data
app:
image: busybox
net: "container:net"
volumes_from: ["volume"]

View File

@ -19,6 +19,7 @@ from compose.const import LABEL_PROJECT
from compose.const import LABEL_SERVICE from compose.const import LABEL_SERVICE
from compose.container import Container from compose.container import Container
from compose.project import Project from compose.project import Project
from compose.project import ProjectError
from compose.service import ConvergenceStrategy from compose.service import ConvergenceStrategy
from tests.integration.testcases import v2_only from tests.integration.testcases import v2_only
@ -565,7 +566,11 @@ class ProjectTest(DockerClientTestCase):
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': 'top', 'command': 'top',
'networks': {'foo': None, 'bar': None, 'baz': None}, 'networks': {
'foo': None,
'bar': None,
'baz': {'aliases': ['extra']},
},
}], }],
volumes={}, volumes={},
networks={ networks={
@ -581,15 +586,23 @@ class ProjectTest(DockerClientTestCase):
config_data=config_data, config_data=config_data,
) )
project.up() project.up()
self.assertEqual(len(project.containers()), 1)
containers = project.containers()
assert len(containers) == 1
container, = containers
for net_name in ['foo', 'bar', 'baz']: for net_name in ['foo', 'bar', 'baz']:
full_net_name = 'composetest_{}'.format(net_name) full_net_name = 'composetest_{}'.format(net_name)
network_data = self.client.inspect_network(full_net_name) network_data = self.client.inspect_network(full_net_name)
self.assertEqual(network_data['Name'], full_net_name) assert network_data['Name'] == full_net_name
aliases_key = 'NetworkSettings.Networks.{net}.Aliases'
assert 'web' in container.get(aliases_key.format(net='composetest_foo'))
assert 'web' in container.get(aliases_key.format(net='composetest_baz'))
assert 'extra' in container.get(aliases_key.format(net='composetest_baz'))
foo_data = self.client.inspect_network('composetest_foo') foo_data = self.client.inspect_network('composetest_foo')
self.assertEqual(foo_data['Driver'], 'bridge') assert foo_data['Driver'] == 'bridge'
@v2_only() @v2_only()
def test_up_with_ipam_config(self): def test_up_with_ipam_config(self):
@ -740,7 +753,8 @@ class ProjectTest(DockerClientTestCase):
config_data=config_data, config_data=config_data,
) )
assert len(project.up()) == 0 with self.assertRaises(ProjectError):
project.up()
@v2_only() @v2_only()
def test_project_up_volumes(self): def test_project_up_volumes(self):

View File

@ -5,6 +5,7 @@ from __future__ import unicode_literals
import os import os
import shutil import shutil
import tempfile import tempfile
from io import StringIO
import docker import docker
import py import py
@ -83,10 +84,10 @@ class CLITestCase(unittest.TestCase):
self.assertTrue(project.services) self.assertTrue(project.services)
def test_command_help(self): def test_command_help(self):
with pytest.raises(SystemExit) as exc: with mock.patch('sys.stdout', new=StringIO()) as fake_stdout:
TopLevelCommand.help({'COMMAND': 'up'}) TopLevelCommand.help({'COMMAND': 'up'})
assert 'Usage: up' in exc.exconly() assert "Usage: up" in fake_stdout.getvalue()
def test_command_help_nonexistent(self): def test_command_help_nonexistent(self):
with pytest.raises(NoSuchCommand): with pytest.raises(NoSuchCommand):

View File

@ -1360,6 +1360,17 @@ class ConfigTest(unittest.TestCase):
config.load(config_details) config.load(config_details)
assert "Service 'one' depends on service 'three'" in exc.exconly() assert "Service 'one' depends on service 'three'" in exc.exconly()
def test_linked_service_is_undefined(self):
with self.assertRaises(ConfigurationError):
config.load(
build_config_details({
'version': '2',
'services': {
'web': {'image': 'busybox', 'links': ['db:db']},
},
})
)
def test_load_dockerfile_without_context(self): def test_load_dockerfile_without_context(self):
config_details = build_config_details({ config_details = build_config_details({
'version': '2', 'version': '2',

View File

@ -29,7 +29,7 @@ def get_deps(obj):
def test_parallel_execute(): def test_parallel_execute():
results = parallel_execute( results, errors = parallel_execute(
objects=[1, 2, 3, 4, 5], objects=[1, 2, 3, 4, 5],
func=lambda x: x * 2, func=lambda x: x * 2,
get_name=six.text_type, get_name=six.text_type,
@ -37,6 +37,7 @@ def test_parallel_execute():
) )
assert sorted(results) == [2, 4, 6, 8, 10] assert sorted(results) == [2, 4, 6, 8, 10]
assert errors == {}
def test_parallel_execute_with_deps(): def test_parallel_execute_with_deps():

View File

@ -643,6 +643,35 @@ class ServiceTest(unittest.TestCase):
assert service.image_name == 'testing_foo' assert service.image_name == 'testing_foo'
class TestServiceNetwork(object):
def test_connect_container_to_networks_short_aliase_exists(self):
mock_client = mock.create_autospec(docker.Client)
service = Service(
'db',
mock_client,
'myproject',
image='foo',
networks={'project_default': {}})
container = Container(
None,
{
'Id': 'abcdef',
'NetworkSettings': {
'Networks': {
'project_default': {
'Aliases': ['analias', 'abcdef'],
},
},
},
},
True)
service.connect_container_to_networks(container)
assert not mock_client.disconnect_container_from_network.call_count
assert not mock_client.connect_container_to_network.call_count
def sort_by_name(dictionary_list): def sort_by_name(dictionary_list):
return sorted(dictionary_list, key=lambda k: k['name']) return sorted(dictionary_list, key=lambda k: k['name'])