소스 검색

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

Bump 1.7.1
Joffrey F 9 년 전
부모
커밋
6c29830127

+ 41 - 0
CHANGELOG.md

@@ -1,6 +1,47 @@
 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)
 ------------------
 

+ 3 - 3
Dockerfile

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

+ 1 - 1
compose/__init__.py

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

+ 4 - 1
compose/cli/command.py

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

+ 12 - 6
compose/cli/main.py

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

+ 7 - 0
compose/cli/utils.py

@@ -12,6 +12,13 @@ from six.moves import input
 
 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):
     """

+ 4 - 2
compose/config/config.py

@@ -37,6 +37,7 @@ from .validation import validate_against_config_schema
 from .validation import validate_config_section
 from .validation import validate_depends_on
 from .validation import validate_extends_file_path
+from .validation import validate_links
 from .validation import validate_network_mode
 from .validation import validate_service_constraints
 from .validation import validate_top_level_object
@@ -580,6 +581,7 @@ def validate_service(service_config, service_names, version):
     validate_ulimits(service_config)
     validate_network_mode(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):
         raise ConfigurationError(
@@ -726,7 +728,7 @@ class MergeDict(dict):
 
         merged = parse_sequence_func(self.base.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):
         if self.needs_merge(field):
@@ -928,7 +930,7 @@ def dict_from_path_mappings(path_mappings):
 
 
 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):

+ 5 - 4
compose/config/errors.py

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

+ 26 - 2
compose/config/serialize.py

@@ -5,6 +5,8 @@ import six
 import yaml
 
 from compose.config import types
+from compose.config.config import V1
+from compose.config.config import V2_0
 
 
 def serialize_config_type(dumper, data):
@@ -17,14 +19,36 @@ yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type)
 
 
 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 = {
-        'version': config.version,
-        'services': {service.pop('name'): service for service in config.services},
+        'version': V2_0,
+        'services': services,
         'networks': config.networks,
         'volumes': config.volumes,
     }
+
     return yaml.safe_dump(
         output,
         default_flow_style=False,
         indent=2,
         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

+ 9 - 0
compose/config/types.py

@@ -7,6 +7,8 @@ from __future__ import unicode_literals
 import os
 from collections import namedtuple
 
+import six
+
 from compose.config.config import V1
 from compose.config.errors import ConfigurationError
 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)}
 
 
+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):
     if not extra_hosts_config:
         return {}

+ 11 - 3
compose/config/validation.py

@@ -171,6 +171,14 @@ def validate_network_mode(service_config, service_names):
             "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):
     for dependency in service_config.config.get('depends_on', []):
         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)
 
         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):
@@ -408,6 +416,6 @@ def handle_errors(errors, format_error_func, filename):
 
     error_msg = '\n'.join(format_error_func(error) for error in errors)
     raise ConfigurationError(
-        "Validation failed{file_msg}, reason(s):\n{error_msg}".format(
-            file_msg=" in file '{}'".format(filename) if filename else "",
+        "The Compose file{file_msg} is invalid because:\n{error_msg}".format(
+            file_msg=" '{}'".format(filename) if filename else "",
             error_msg=error_msg))

+ 1 - 1
compose/parallel.py

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

+ 14 - 2
compose/project.py

@@ -342,7 +342,10 @@ class Project(object):
             filters={'label': self.labels()},
             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
                 # to images
                 continue
@@ -387,13 +390,18 @@ class Project(object):
         def get_deps(service):
             return {self.get_service(dep) for dep in service.get_dependency_names()}
 
-        results = parallel.parallel_execute(
+        results, errors = parallel.parallel_execute(
             services,
             do,
             operator.attrgetter('name'),
             None,
             get_deps
         )
+        if errors:
+            raise ProjectError(
+                'Encountered errors while bringing up the project.'
+            )
+
         return [
             container
             for svc_containers in results
@@ -528,3 +536,7 @@ class NoSuchService(Exception):
 
     def __str__(self):
         return self.msg
+
+
+class ProjectError(Exception):
+    pass

+ 44 - 13
compose/service.py

@@ -453,20 +453,20 @@ class Service(object):
         connected_networks = container.get('NetworkSettings.Networks')
 
         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 short_id_alias_exists(container, network):
+                    continue
+
                 self.client.disconnect_container_from_network(
-                    container.id, network)
+                    container.id,
+                    network)
 
             self.client.connect_container_to_network(
                 container.id, network,
-                aliases=list(self._get_aliases(container).union(aliases)),
-                ipv4_address=ipv4_address,
-                ipv6_address=ipv6_address,
-                links=self._get_links(False)
-            )
+                aliases=self._get_aliases(netdefs, container),
+                ipv4_address=netdefs.get('ipv4_address', None),
+                ipv6_address=netdefs.get('ipv6_address', None),
+                links=self._get_links(False))
 
     def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT):
         for c in self.duplicate_containers():
@@ -533,11 +533,32 @@ class Service(object):
         numbers = [c.number for c in containers]
         return 1 if not numbers else max(numbers) + 1
 
-    def _get_aliases(self, container):
-        if container.labels.get(LABEL_ONE_OFF) == "True":
-            return set()
+    def _get_aliases(self, network, container=None):
+        if container and container.labels.get(LABEL_ONE_OFF) == "True":
+            return []
+
+        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 {}
 
-        return {self.name, container.short_id}
+        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):
         links = {}
@@ -633,6 +654,10 @@ class Service(object):
             override_options,
             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'])
         return container_options
@@ -796,6 +821,12 @@ class Service(object):
                 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):
     """A `standard` network mode (ex: host, bridge)"""
 

+ 2 - 2
docs/env-file.md

@@ -13,8 +13,8 @@ weight=10
 # Environment file
 
 Compose supports declaring default environment variables in an environment
-file named `.env` and placed in the same folder as your
-[compose file](compose-file.md).
+file named `.env` placed in the folder `docker-compose` command is executed from
+*(current working directory)*.
 
 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.

+ 3 - 3
docs/install.md

@@ -39,7 +39,7 @@ which the release page specifies, in your terminal.
 
      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
      [Alternative Install Options](#alternative-install-options).
@@ -54,7 +54,7 @@ which the release page specifies, in your terminal.
 7. Test the installation.
 
         $ docker-compose --version
-        docker-compose version: 1.7.0
+        docker-compose version: 1.7.1
 
 
 ## Alternative install options
@@ -77,7 +77,7 @@ to get started.
 Compose can also be run inside a container, from a small bash script wrapper.
 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
 
 ## Master builds

+ 1 - 1
requirements.txt

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

+ 1 - 1
script/run/run.sh

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

+ 1 - 1
setup.py

@@ -34,7 +34,7 @@ install_requires = [
     'requests >= 2.6.1, < 2.8',
     'texttable >= 0.8.1, < 0.9',
     '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',
     'six >= 1.3.0, < 2',
     'jsonschema >= 2.5.1, < 3',

+ 62 - 13
tests/acceptance/cli_test.py

@@ -140,20 +140,23 @@ class CLITestCase(DockerClientTestCase):
 
     def test_help(self):
         self.base_dir = 'tests/fixtures/no-composefile'
-        result = self.dispatch(['help', 'up'], returncode=1)
-        assert 'Usage: up [options] [SERVICE...]' in result.stderr
+        result = self.dispatch(['help', 'up'], returncode=0)
+        assert 'Usage: up [options] [SERVICE...]' in result.stdout
         # Prevent tearDown from trying to create a project
         self.base_dir = None
 
-    # TODO: this shouldn't be v2-dependent
-    @v2_only()
+    def test_shorthand_host_opt(self):
+        self.dispatch(
+            ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')),
+             'up', '-d'],
+            returncode=0
+        )
+
     def test_config_list_services(self):
         self.base_dir = 'tests/fixtures/v2-full'
         result = self.dispatch(['config', '--services'])
         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):
         self.base_dir = None
         result = self.dispatch([
@@ -162,14 +165,10 @@ class CLITestCase(DockerClientTestCase):
         ], returncode=1)
         assert "'notaservice' must be a mapping" in result.stderr
 
-    # TODO: this shouldn't be v2-dependent
-    @v2_only()
     def test_config_quiet(self):
         self.base_dir = 'tests/fixtures/v2-full'
         assert self.dispatch(['config', '-q']).stdout == ''
 
-    # TODO: this shouldn't be v2-dependent
-    @v2_only()
     def test_config_default(self):
         self.base_dir = 'tests/fixtures/v2-full'
         result = self.dispatch(['config'])
@@ -198,6 +197,58 @@ class CLITestCase(DockerClientTestCase):
         }
         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):
         self.project.get_service('simple').create_container()
         result = self.dispatch(['ps'])
@@ -683,9 +734,7 @@ class CLITestCase(DockerClientTestCase):
             ['-f', 'v2-invalid.yml', 'up', '-d'],
             returncode=1)
 
-        # TODO: fix validation error messages for v2 files
-        # assert "Unsupported config option for service 'web': 'net'" in exc.exconly()
-        assert "Unsupported config option" in result.stderr
+        assert "Unsupported config option for services.bar: 'net'" in result.stderr
 
     def test_up_with_net_v1(self):
         self.base_dir = 'tests/fixtures/net-container'

+ 2 - 0
tests/fixtures/extends/invalid-links.yml

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

+ 14 - 0
tests/fixtures/restart/docker-compose.yml

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

+ 10 - 0
tests/fixtures/v1-config/docker-compose.yml

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

+ 19 - 5
tests/integration/project_test.py

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

+ 3 - 2
tests/unit/cli_test.py

@@ -5,6 +5,7 @@ from __future__ import unicode_literals
 import os
 import shutil
 import tempfile
+from io import StringIO
 
 import docker
 import py
@@ -83,10 +84,10 @@ class CLITestCase(unittest.TestCase):
         self.assertTrue(project.services)
 
     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'})
 
-        assert 'Usage: up' in exc.exconly()
+        assert "Usage: up" in fake_stdout.getvalue()
 
     def test_command_help_nonexistent(self):
         with pytest.raises(NoSuchCommand):

+ 11 - 0
tests/unit/config/config_test.py

@@ -1360,6 +1360,17 @@ class ConfigTest(unittest.TestCase):
             config.load(config_details)
         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):
         config_details = build_config_details({
             'version': '2',

+ 2 - 1
tests/unit/parallel_test.py

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

+ 29 - 0
tests/unit/service_test.py

@@ -643,6 +643,35 @@ class ServiceTest(unittest.TestCase):
         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):
     return sorted(dictionary_list, key=lambda k: k['name'])