diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee45386a..0064a5cce 100644 --- a/CHANGELOG.md +++ b/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) ------------------ diff --git a/Dockerfile b/Dockerfile index acf9b6aeb..63fac3eb3 100644 --- a/Dockerfile +++ b/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 diff --git a/compose/__init__.py b/compose/__init__.py index b2062199a..6c5bb8e79 100644 --- a/compose/__init__.py +++ b/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' diff --git a/compose/cli/command.py b/compose/cli/command.py index b7160deec..8ac3aff4f 100644 --- a/compose/cli/command.py +++ b/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 ) diff --git a/compose/cli/main.py b/compose/cli/main.py index 8348b8c37..c0de17825 100644 --- a/compose/cli/main.py +++ b/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=...] [options] [COMMAND] [ARGS...] + docker-compose [-f ...] [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): """ diff --git a/compose/cli/utils.py b/compose/cli/utils.py index dd859edc4..fff4a543f 100644 --- a/compose/cli/utils.py +++ b/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): """ diff --git a/compose/config/config.py b/compose/config/config.py index dc3f56ea9..e52de4bf8 100644 --- a/compose/config/config.py +++ b/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): diff --git a/compose/config/errors.py b/compose/config/errors.py index d5df7ae55..d14cbbdd0 100644 --- a/compose/config/errors.py +++ b/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/') diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 06e0a027b..1b498c016 100644 --- a/compose/config/serialize.py +++ b/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 diff --git a/compose/config/types.py b/compose/config/types.py index fc3347c86..e6a3dea05 100644 --- a/compose/config/types.py +++ b/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 {} diff --git a/compose/config/validation.py b/compose/config/validation.py index 088bec3fc..7452e9849 100644 --- a/compose/config/validation.py +++ b/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)) diff --git a/compose/parallel.py b/compose/parallel.py index 63417dcb0..50b2dbeaf 100644 --- a/compose/parallel.py +++ b/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): diff --git a/compose/project.py b/compose/project.py index 0d891e455..d965c4a39 100644 --- a/compose/project.py +++ b/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 diff --git a/compose/service.py b/compose/service.py index e0f238882..e8624fa66 100644 --- a/compose/service.py +++ b/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 {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): 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)""" diff --git a/docs/env-file.md b/docs/env-file.md index a285a7908..be2625f88 100644 --- a/docs/env-file.md +++ b/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. diff --git a/docs/install.md b/docs/install.md index e8fede82a..76e4a8687 100644 --- a/docs/install.md +++ b/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 diff --git a/requirements.txt b/requirements.txt index b9b0f4036..eb5275f4e 100644 --- a/requirements.txt +++ b/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 diff --git a/script/run/run.sh b/script/run/run.sh index 98d32c5f8..c0ecc3dd4 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.7.0" +VERSION="1.7.1" IMAGE="docker/compose:$VERSION" diff --git a/setup.py b/setup.py index 7caae97d2..0b37c1dd4 100644 --- a/setup.py +++ b/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', diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 707c24926..1a4f9f53b 100644 --- a/tests/acceptance/cli_test.py +++ b/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' diff --git a/tests/fixtures/extends/invalid-links.yml b/tests/fixtures/extends/invalid-links.yml index edfeb8b23..cea740cb7 100644 --- a/tests/fixtures/extends/invalid-links.yml +++ b/tests/fixtures/extends/invalid-links.yml @@ -1,3 +1,5 @@ +mydb: + build: '.' myweb: build: '.' extends: diff --git a/tests/fixtures/restart/docker-compose.yml b/tests/fixtures/restart/docker-compose.yml new file mode 100644 index 000000000..2d10aa397 --- /dev/null +++ b/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" diff --git a/tests/fixtures/v1-config/docker-compose.yml b/tests/fixtures/v1-config/docker-compose.yml new file mode 100644 index 000000000..8646c4edb --- /dev/null +++ b/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"] diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d1732d1e4..7ef492a56 100644 --- a/tests/integration/project_test.py +++ b/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): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index bd35dc06f..2c90b29b7 100644 --- a/tests/unit/cli_test.py +++ b/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): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2bbbe6145..488305586 100644 --- a/tests/unit/config/config_test.py +++ b/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', diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 45b0db1db..479c0f1d3 100644 --- a/tests/unit/parallel_test.py +++ b/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(): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index fe3794daf..1994993c6 100644 --- a/tests/unit/service_test.py +++ b/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'])