diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..49d4691fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,60 @@ +--- +name: Bug report +about: Report a bug encountered while using docker-compose + +--- + + + +## Description of the issue + +## Context information (for bug reports) + +**Output of `docker-compose version`** +``` +(paste here) +``` + +**Output of `docker version`** +``` +(paste here) +``` + +**Output of `docker-compose config`** +(Make sure to add the relevant `-f` and other flags) +``` +(paste here) +``` + + +## Steps to reproduce the issue + +1. +2. +3. + +### Observed result + +### Expected result + +### Stacktrace / full error message + +``` +(paste here) +``` + +## Additional information + +OS version / distribution, `docker-compose` install method, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..d53c49a79 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature request +about: Suggest an idea to improve Compose + +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question-about-using-compose.md b/.github/ISSUE_TEMPLATE/question-about-using-compose.md new file mode 100644 index 000000000..11ef65ccf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question-about-using-compose.md @@ -0,0 +1,9 @@ +--- +name: Question about using Compose +about: This is not the appropriate channel + +--- + +Please post on our forums: https://forums.docker.com for questions about using `docker-compose`. + +Posts that are not a bug report or a feature/enhancement request will not be addressed on this issue tracker. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7bcc8466..e447294eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports - sha: v0.3.5 + sha: v1.3.4 hooks: - id: reorder-python-imports language_version: 'python2.7' diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7a2ffe9..c5eb1bb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,42 @@ Change log ========== +1.24.0 (2019-01-25) +------------------- + +### Features + +- Added support for connecting to the Docker Engine using the `ssh` protocol. + +- Added a `--all` flag to `docker-compose ps` to include stopped one-off containers + in the command's output. + +### Bugfixes + +- Fixed a bug where some valid credential helpers weren't properly handled by Compose + when attempting to pull images from private registries. + +- Fixed an issue where the output of `docker-compose start` before containers were created + was misleading + +- To match the Docker CLI behavior and to avoid confusing issues, Compose will no longer + accept whitespace in variable names sourced from environment files. + +- Compose will now report a configuration error if a service attempts to declare + duplicate mount points in the volumes section. + +- Fixed an issue with the containerized version of Compose that prevented users from + writing to stdin during interactive sessions started by `run` or `exec`. + +- One-off containers started by `run` no longer adopt the restart policy of the service, + and are instead set to never restart. + +- Fixed an issue that caused some container events to not appear in the output of + the `docker-compose events` command. + +- Missing images will no longer stop the execution of `docker-compose down` commands + (a warning will be displayed instead). + 1.23.2 (2018-11-28) ------------------- diff --git a/MANIFEST.in b/MANIFEST.in index 8c6f932ba..fca685eaa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,8 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md -exclude README.md -include README.rst +include README.md include compose/config/*.json include compose/GITSHA recursive-include contrib/completion * diff --git a/README.md b/README.md index ea07f6a7d..dd4003048 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file/compose-versioning.md) +[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file/compose-versioning.md). Compose has commands for managing the whole lifecycle of your application: @@ -48,9 +48,8 @@ Installation and documentation ------------------------------ - Full documentation is available on [Docker's website](https://docs.docker.com/compose/). -- If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) -- Code repository for Compose is on [GitHub](https://github.com/docker/compose) -- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) +- Code repository for Compose is on [GitHub](https://github.com/docker/compose). +- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new/choose). Thank you! Contributing ------------ diff --git a/compose/__init__.py b/compose/__init__.py index 1cb5be0da..bc5e6b116 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.23.2' +__version__ = '1.24.0-rc1' diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 8c89da6c5..189b67faf 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -67,7 +67,9 @@ def handle_connection_errors(client): def log_windows_pipe_error(exc): - if exc.winerror == 232: # https://github.com/docker/compose/issues/5005 + if exc.winerror == 2: + log.error("Couldn't connect to Docker daemon. You might need to start Docker for Windows.") + elif exc.winerror == 232: # https://github.com/docker/compose/issues/5005 log.error( "The current Compose file version is not compatible with your engine version. " "Please upgrade your Compose file to a more recent version, or set " diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index bd6723ef2..8aa93a844 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -236,7 +236,8 @@ def watch_events(thread_map, event_stream, presenters, thread_args): thread_map[event['id']] = build_thread( event['container'], next(presenters), - *thread_args) + *thread_args + ) def consume_queue(queue, cascade_stop): diff --git a/compose/cli/main.py b/compose/cli/main.py index afe813ee5..950e5055d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -694,6 +694,7 @@ class TopLevelCommand(object): -q, --quiet Only display IDs --services Display services --filter KEY=VAL Filter services by a property + -a, --all Show all stopped containers (including those created by the run command) """ if options['--quiet'] and options['--services']: raise UserError('--quiet and --services cannot be combined') @@ -706,10 +707,14 @@ class TopLevelCommand(object): print('\n'.join(service.name for service in services)) return - containers = sorted( - self.project.containers(service_names=options['SERVICE'], stopped=True) + - self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), - key=attrgetter('name')) + if options['--all']: + containers = sorted(self.project.containers(service_names=options['SERVICE'], + one_off=OneOffFilter.include, stopped=True)) + else: + containers = sorted( + self.project.containers(service_names=options['SERVICE'], stopped=True) + + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), + key=attrgetter('name')) if options['--quiet']: for container in containers: @@ -867,7 +872,7 @@ class TopLevelCommand(object): else: command = service.options.get('command') - container_options = build_container_options(options, detach, command) + container_options = build_one_off_container_options(options, detach, command) run_one_off_container( container_options, self.project, service, options, self.toplevel_options, self.project_dir @@ -1262,7 +1267,7 @@ def build_action_from_opts(options): return BuildAction.none -def build_container_options(options, detach, command): +def build_one_off_container_options(options, detach, command): container_options = { 'command': command, 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), @@ -1283,8 +1288,8 @@ def build_container_options(options, detach, command): [""] if options['--entrypoint'] == '' else options['--entrypoint'] ) - if options['--rm']: - container_options['restart'] = None + # Ensure that run command remains one-off (issue #6302) + container_options['restart'] = None if options['--user']: container_options['user'] = options.get('--user') diff --git a/compose/config/config.py b/compose/config/config.py index 714397eb3..3df211f73 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,6 +8,7 @@ import os import string import sys from collections import namedtuple +from operator import attrgetter import six import yaml @@ -835,6 +836,17 @@ def finalize_service_volumes(service_dict, environment): finalized_volumes.append(MountSpec.parse(v, normalize, win_host)) else: finalized_volumes.append(VolumeSpec.parse(v, normalize, win_host)) + + duplicate_mounts = [] + mounts = [v.as_volume_spec() if isinstance(v, MountSpec) else v for v in finalized_volumes] + for mount in mounts: + if list(map(attrgetter('internal'), mounts)).count(mount.internal) > 1: + duplicate_mounts.append(mount.repr()) + + if duplicate_mounts: + raise ConfigurationError("Duplicate mount points: [%s]" % ( + ', '.join(duplicate_mounts))) + service_dict['volumes'] = finalized_volumes return service_dict @@ -1040,7 +1052,6 @@ def merge_service_dicts(base, override, version): md.merge_mapping('environment', parse_environment) md.merge_mapping('labels', parse_labels) md.merge_mapping('ulimits', parse_flat_dict) - md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) md.merge_mapping('storage_opt', parse_flat_dict) @@ -1050,6 +1061,7 @@ def merge_service_dicts(base, override, version): md.merge_sequence('security_opt', types.SecurityOpt.parse) md.merge_mapping('extra_hosts', parse_extra_hosts) + md.merge_field('networks', merge_networks, default={}) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) @@ -1154,6 +1166,22 @@ def merge_deploy(base, override): return dict(md) +def merge_networks(base, override): + merged_networks = {} + all_network_names = set(base) | set(override) + base = {k: {} for k in base} if isinstance(base, list) else base + override = {k: {} for k in override} if isinstance(override, list) else override + for network_name in all_network_names: + md = MergeDict(base.get(network_name, {}), override.get(network_name, {})) + md.merge_field('aliases', merge_unique_items_lists, []) + md.merge_field('link_local_ips', merge_unique_items_lists, []) + md.merge_scalar('priority') + md.merge_scalar('ipv4_address') + md.merge_scalar('ipv6_address') + merged_networks[network_name] = dict(md) + return merged_networks + + def merge_reservations(base, override): md = MergeDict(base, override) md.merge_scalar('cpus') @@ -1283,7 +1311,7 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): if isinstance(volume, dict): - if volume.get('source', '').startswith('.') and volume['type'] == 'bind': + if volume.get('source', '').startswith(('.', '~')) and volume['type'] == 'bind': volume['source'] = expand_path(working_dir, volume['source']) return volume diff --git a/compose/config/environment.py b/compose/config/environment.py index 0087b6128..bd52758f2 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -5,11 +5,13 @@ import codecs import contextlib import logging import os +import re import six from ..const import IS_WINDOWS_PLATFORM from .errors import ConfigurationError +from .errors import EnvFileNotFound log = logging.getLogger(__name__) @@ -17,10 +19,16 @@ log = logging.getLogger(__name__) def split_env(env): if isinstance(env, six.binary_type): env = env.decode('utf-8', 'replace') + key = value = None if '=' in env: - return env.split('=', 1) + key, value = env.split('=', 1) else: - return env, None + key = env + if re.search(r'\s', key): + raise ConfigurationError( + "environment variable name '{}' may not contains whitespace.".format(key) + ) + return key, value def env_vars_from_file(filename): @@ -28,16 +36,19 @@ def env_vars_from_file(filename): Read in a line delimited file of environment variables. """ if not os.path.exists(filename): - raise ConfigurationError("Couldn't find env file: %s" % filename) + raise EnvFileNotFound("Couldn't find env file: {}".format(filename)) elif not os.path.isfile(filename): - raise ConfigurationError("%s is not a file." % (filename)) + raise EnvFileNotFound("{} is not a file.".format(filename)) env = {} 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('#'): - k, v = split_env(line) - env[k] = v + try: + k, v = split_env(line) + env[k] = v + except ConfigurationError as e: + raise ConfigurationError('In file {}: {}'.format(filename, e.msg)) return env @@ -55,9 +66,10 @@ class Environment(dict): env_file_path = os.path.join(base_dir, '.env') try: return cls(env_vars_from_file(env_file_path)) - except ConfigurationError: + except EnvFileNotFound: pass return result + instance = _initialize() instance.update(os.environ) return instance diff --git a/compose/config/errors.py b/compose/config/errors.py index f5c038088..9b2078f2c 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -19,6 +19,10 @@ class ConfigurationError(Exception): return self.msg +class EnvFileNotFound(ConfigurationError): + pass + + class DependencyError(ConfigurationError): pass diff --git a/compose/const.py b/compose/const.py index 0e66a297a..46d81ae71 100644 --- a/compose/const.py +++ b/compose/const.py @@ -7,7 +7,6 @@ from .version import ComposeVersion DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = 60 -IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag'] IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' diff --git a/compose/parallel.py b/compose/parallel.py index 34a498ca7..e242a318a 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -43,14 +43,17 @@ class GlobalLimit(object): cls.global_limiter = Semaphore(value) -def parallel_execute_watch(events, writer, errors, results, msg, get_name): +def parallel_execute_watch(events, writer, errors, results, msg, get_name, fail_check): """ Watch events from a parallel execution, update status and fill errors and results. Returns exception to re-raise. """ error_to_reraise = None for obj, result, exception in events: if exception is None: - writer.write(msg, get_name(obj), 'done', green) + if fail_check is not None and fail_check(obj): + writer.write(msg, get_name(obj), 'failed', red) + else: + writer.write(msg, get_name(obj), 'done', green) results.append(result) elif isinstance(exception, ImageNotFound): # This is to bubble up ImageNotFound exceptions to the client so we @@ -72,12 +75,14 @@ def parallel_execute_watch(events, writer, errors, results, msg, get_name): return error_to_reraise -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, fail_check=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. get_deps called on object must return a collection with its dependencies. get_name called on object must return its name. + fail_check is an additional failure check for cases that should display as a failure + in the CLI logs, but don't raise an exception (such as attempting to start 0 containers) """ objects = list(objects) stream = get_output_stream(sys.stderr) @@ -96,7 +101,9 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): errors = {} results = [] - error_to_reraise = parallel_execute_watch(events, writer, errors, results, msg, get_name) + error_to_reraise = parallel_execute_watch( + events, writer, errors, results, msg, get_name, fail_check + ) for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 4cd311432..c4281cb4c 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -98,14 +98,14 @@ def print_output_event(event, stream, is_terminal): def get_digest_from_pull(events): + digest = None for event in events: status = event.get('status') if not status or 'Digest' not in status: continue - - _, digest = status.split(':', 1) - return digest.strip() - return None + else: + digest = status.split(':', 1)[1].strip() + return digest def get_digest_from_push(events): diff --git a/compose/project.py b/compose/project.py index 92c352050..a7f2aa057 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,13 +10,13 @@ from functools import reduce import enum import six from docker.errors import APIError +from docker.utils import version_lt from . import parallel from .config import ConfigurationError from .config.config import V1 from .config.sort_services import get_container_name_from_network_mode from .config.sort_services import get_service_name_from_network_mode -from .const import IMAGE_EVENTS from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE @@ -29,6 +29,7 @@ from .service import ContainerNetworkMode from .service import ContainerPidMode from .service import ConvergenceStrategy from .service import NetworkMode +from .service import parse_repository_tag from .service import PidMode from .service import Service from .service import ServiceNetworkMode @@ -279,6 +280,7 @@ class Project(object): operator.attrgetter('name'), 'Starting', get_deps, + fail_check=lambda obj: not obj.containers(), ) return containers @@ -401,11 +403,13 @@ class Project(object): detached=True, start=False) - def events(self, service_names=None): + def _legacy_event_processor(self, service_names): + # Only for v1 files or when Compose is forced to use an older API version def build_container_event(event, container): time = datetime.datetime.fromtimestamp(event['time']) time = time.replace( - microsecond=microseconds_from_time_nano(event['timeNano'])) + microsecond=microseconds_from_time_nano(event['timeNano']) + ) return { 'time': time, 'type': 'container', @@ -424,17 +428,15 @@ class Project(object): filters={'label': self.labels()}, decode=True ): - # The first part of this condition is a guard against some events - # broadcasted by swarm that don't have a status field. + # This 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 + if 'status' not in event: continue - # TODO: get labels from the API v1.22 , see github issue 2618 try: - # this can fail if the container has been removed + # this can fail if the container has been removed or if the event + # refers to an image container = Container.from_id(self.client, event['id']) except APIError: continue @@ -442,6 +444,56 @@ class Project(object): continue yield build_container_event(event, container) + def events(self, service_names=None): + if version_lt(self.client.api_version, '1.22'): + # New, better event API was introduced in 1.22. + return self._legacy_event_processor(service_names) + + def build_container_event(event): + container_attrs = event['Actor']['Attributes'] + time = datetime.datetime.fromtimestamp(event['time']) + time = time.replace( + microsecond=microseconds_from_time_nano(event['timeNano']) + ) + + container = None + try: + container = Container.from_id(self.client, event['id']) + except APIError: + # Container may have been removed (e.g. if this is a destroy event) + pass + + return { + 'time': time, + 'type': 'container', + 'action': event['status'], + 'id': event['Actor']['ID'], + 'service': container_attrs.get(LABEL_SERVICE), + 'attributes': dict([ + (k, v) for k, v in container_attrs.items() + if not k.startswith('com.docker.compose.') + ]), + 'container': container, + } + + def yield_loop(service_names): + for event in self.client.events( + filters={'label': self.labels()}, + decode=True + ): + # TODO: support other event types + if event.get('Type') != 'container': + continue + + try: + if event['Actor']['Attributes'][LABEL_SERVICE] not in service_names: + continue + except KeyError: + continue + yield build_container_event(event) + + return yield_loop(set(service_names) if service_names else self.service_names) + def up(self, service_names=None, start_deps=True, @@ -592,8 +644,15 @@ class Project(object): service.pull(ignore_pull_failures, silent=silent) def push(self, service_names=None, ignore_push_failures=False): + unique_images = set() for service in self.get_services(service_names, include_deps=False): - service.push(ignore_push_failures) + # Considering and as the same + repo, tag, sep = parse_repository_tag(service.image_name) + service_image_name = sep.join((repo, tag)) if tag else sep.join((repo, 'latest')) + + if service_image_name not in unique_images: + service.push(ignore_push_failures) + unique_images.add(service_image_name) def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude): ctnrs = list(filter(None, [ diff --git a/compose/service.py b/compose/service.py index 12fb02325..3c5e356a3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -199,7 +199,9 @@ class Service(object): def __repr__(self): return ''.format(self.name) - def containers(self, stopped=False, one_off=False, filters={}, labels=None): + def containers(self, stopped=False, one_off=False, filters=None, labels=None): + if filters is None: + filters = {} filters.update({'label': self.labels(one_off=one_off) + (labels or [])}) result = list(filter(None, [ @@ -1146,6 +1148,9 @@ class Service(object): try: self.client.remove_image(self.image_name) return True + except ImageNotFound: + log.warning("Image %s not found.", self.image_name) + return False except APIError as e: log.error("Failed to remove image for service %s: %s", self.name, e) return False diff --git a/compose/utils.py b/compose/utils.py index 9f0441d08..a1e5e6435 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import codecs import hashlib -import json import json.decoder import logging import ntpath diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index eb6199831..e55c91964 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -354,7 +354,7 @@ _docker-compose() { '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 - local -a relevant_compose_flags relevant_docker_flags compose_options docker_options + local -a relevant_compose_flags relevant_compose_repeatable_flags relevant_docker_flags compose_options docker_options relevant_compose_flags=( "--file" "-f" @@ -368,6 +368,10 @@ _docker-compose() { "--skip-hostname-check" ) + relevant_compose_repeatable_flags=( + "--file" "-f" + ) + relevant_docker_flags=( "--host" "-H" "--tls" @@ -385,9 +389,18 @@ _docker-compose() { fi fi if [[ -n "${relevant_compose_flags[(r)$k]}" ]]; then - compose_options+=$k - if [[ -n "$opt_args[$k]" ]]; then - compose_options+=$opt_args[$k] + if [[ -n "${relevant_compose_repeatable_flags[(r)$k]}" ]]; then + values=("${(@s/:/)opt_args[$k]}") + for value in $values + do + compose_options+=$k + compose_options+=$value + done + else + compose_options+=$k + if [[ -n "$opt_args[$k]" ]]; then + compose_options+=$opt_args[$k] + fi fi fi done diff --git a/requirements.txt b/requirements.txt index 45ed9049d..9333ec60d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,8 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.6.0 -docker-pycreds==0.3.0 +docker==3.7.0 +docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' @@ -12,10 +12,11 @@ functools32==3.2.3.post2; python_version < '3.2' idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 +paramiko==2.4.2 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 -PyYAML==3.12 +PyYAML==4.2b1 requests==2.20.0 six==1.10.0 texttable==0.9.1 diff --git a/script/build/write-git-sha b/script/build/write-git-sha index d16743c6f..be87f5058 100755 --- a/script/build/write-git-sha +++ b/script/build/write-git-sha @@ -2,6 +2,11 @@ # # Write the current commit sha to the file GITSHA. This file is included in # packaging so that `docker-compose version` can include the git sha. -# -set -e -git rev-parse --short HEAD > compose/GITSHA +# sets to 'unknown' and echoes a message if the command is not successful + +DOCKER_COMPOSE_GITSHA="$(git rev-parse --short HEAD)" +if [[ "${?}" != "0" ]]; then + echo "Couldn't get revision of the git repository. Setting to 'unknown' instead" + DOCKER_COMPOSE_GITSHA="unknown" +fi +echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA diff --git a/script/release/push-release b/script/release/push-release index 0578aaff8..f28c1d4fe 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -26,12 +26,6 @@ if [ -z "$(command -v jq 2> /dev/null)" ]; then fi -if [ -z "$(command -v pandoc 2> /dev/null)" ]; then - >&2 echo "$0 requires http://pandoc.org/" - >&2 echo "Please install it and make sure it is available on your \$PATH." - exit 2 -fi - API=https://api.github.com/repos REPO=docker/compose GITHUB_REPO=git@github.com:$REPO @@ -59,8 +53,6 @@ docker push docker/compose-tests:latest docker push docker/compose-tests:$VERSION echo "Uploading package to PyPI" -pandoc -f markdown -t rst README.md -o README.rst -sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst ./script/build/write-git-sha python setup.py sdist bdist_wheel if [ "$(command -v twine 2> /dev/null)" ]; then diff --git a/script/release/release.md.tmpl b/script/release/release.md.tmpl index ee97ef104..4d0ebe926 100644 --- a/script/release/release.md.tmpl +++ b/script/release/release.md.tmpl @@ -1,6 +1,6 @@ -If you're a Mac or Windows user, the best way to install Compose and keep it up-to-date is **[Docker for Mac and Windows](https://www.docker.com/products/docker)**. +If you're a Mac or Windows user, the best way to install Compose and keep it up-to-date is **[Docker Desktop for Mac and Windows](https://www.docker.com/products/docker-desktop)**. -Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. +Docker Desktop will automatically install the latest version of Docker Engine for you. Alternatively, you can use the usual commands to install or upgrade Compose: diff --git a/script/release/release.py b/script/release/release.py index 6574bfddd..63bf863df 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -9,7 +9,6 @@ import sys import time from distutils.core import run_setup -import pypandoc from jinja2 import Template from release.bintray import BintrayAPI from release.const import BINTRAY_ORG @@ -277,9 +276,6 @@ def finalize(args): repository.checkout_branch(br_name) - pypandoc.convert_file( - os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst') - ) run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) merge_status = pr_data.merge() diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 9a5d432c0..bb8f4fbeb 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -219,6 +219,8 @@ def get_contributors(pr_data): commits = pr_data.get_commits() authors = {} for commit in commits: + if not commit.author: + continue author = commit.author.login authors[author] = authors.get(author, 0) + 1 return [x[0] for x in sorted(list(authors.items()), key=lambda x: x[1])] diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh index 780fc800f..ab419be0c 100755 --- a/script/release/setup-venv.sh +++ b/script/release/setup-venv.sh @@ -39,9 +39,9 @@ fi $VENV_PYTHONBIN -m pip install -U Jinja2==2.10 \ PyGithub==1.39 \ - pypandoc==1.4 \ GitPython==2.1.9 \ requests==2.18.4 \ + setuptools==40.6.2 \ twine==1.11.0 $VENV_PYTHONBIN setup.py develop diff --git a/script/run/run.sh b/script/run/run.sh index d3069ff78..df3f2298f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.2" +VERSION="1.24.0-rc1" IMAGE="docker/compose:$VERSION" @@ -47,14 +47,14 @@ if [ -n "$HOME" ]; then fi # Only allocate tty if we detect one -if [ -t 0 ]; then - if [ -t 1 ]; then +if [ -t 0 -a -t 1 ]; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -t" - fi -else - DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" fi +# Always set -i to support piped and terminal input in run/exec +DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" + + # Handle userns security if [ ! -z "$(docker info 2>/dev/null | grep userns)" ]; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS --userns=host" diff --git a/setup.py b/setup.py index 22dafdb22..8371cc756 100644 --- a/setup.py +++ b/setup.py @@ -32,11 +32,11 @@ def find_version(*file_paths): install_requires = [ 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', - 'PyYAML >= 3.10, < 4', + 'PyYAML >= 3.10, < 4.3', 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.6.0, < 4.0', + 'docker[ssh] >= 3.7.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', @@ -77,19 +77,26 @@ setup( name='docker-compose', version=find_version("compose", "__init__.py"), description='Multi-container orchestration for Docker', + long_description=read('README.md'), + long_description_content_type='text/markdown', url='https://www.docker.com/', + project_urls={ + 'Documentation': 'https://docs.docker.com/compose/overview', + 'Changelog': 'https://github.com/docker/compose/blob/release/CHANGELOG.md', + 'Source': 'https://github.com/docker/compose', + 'Tracker': 'https://github.com/docker/compose/issues', + }, author='Docker, Inc.', license='Apache License 2.0', packages=find_packages(exclude=['tests.*', 'tests']), include_package_data=True, - test_suite='nose.collector', install_requires=install_requires, extras_require=extras_require, tests_require=tests_require, - entry_points=""" - [console_scripts] - docker-compose=compose.cli.main:main - """, + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + entry_points={ + 'console_scripts': ['docker-compose=compose.cli.main:main'], + }, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ae01a88ef..5142f96eb 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import datetime import json -import os import os.path import re import signal @@ -599,10 +598,20 @@ class CLITestCase(DockerClientTestCase): assert 'with_build' in running.stdout assert 'with_image' in running.stdout + def test_ps_all(self): + self.project.get_service('simple').create_container(one_off='blahblah') + result = self.dispatch(['ps']) + assert 'simple-composefile_simple_run_' not in result.stdout + + result2 = self.dispatch(['ps', '--all']) + assert 'simple-composefile_simple_run_' in result2.stdout + def test_pull(self): result = self.dispatch(['pull']) assert 'Pulling simple' in result.stderr assert 'Pulling another' in result.stderr + assert 'done' in result.stderr + assert 'failed' not in result.stderr def test_pull_with_digest(self): result = self.dispatch(['-f', 'digest.yml', 'pull', '--no-parallel']) @@ -2221,6 +2230,7 @@ class CLITestCase(DockerClientTestCase): def test_start_no_containers(self): result = self.dispatch(['start'], returncode=1) + assert 'failed' in result.stderr assert 'No containers to start' in result.stderr @v2_only() diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 7c8a1423c..a7522f939 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -171,7 +171,10 @@ class CLITestCase(unittest.TestCase): '--workdir': None, }) - assert mock_client.create_host_config.call_args[1]['restart_policy']['Name'] == 'always' + # NOTE: The "run" command is supposed to be a one-off tool; therefore restart policy "no" + # (the default) is enforced despite explicit wish for "always" in the project + # configuration file + assert not mock_client.create_host_config.call_args[1].get('restart_policy') command = TopLevelCommand(project) command.run({ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a6219b200..8baf8e4ee 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1085,8 +1085,43 @@ class ConfigTest(unittest.TestCase): details = config.ConfigDetails('.', [base_file, override_file]) web_service = config.load(details).services[0] assert web_service['networks'] == { - 'foobar': {'aliases': ['foo', 'bar']}, - 'baz': None + 'foobar': {'aliases': ['bar', 'foo']}, + 'baz': {} + } + + def test_load_with_multiple_files_mismatched_networks_format_inverse_order(self): + base_file = config.ConfigFile( + 'override.yaml', + { + 'version': '2', + 'services': { + 'web': { + 'networks': ['baz'] + } + } + } + ) + override_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2', + 'services': { + 'web': { + 'image': 'example/web', + 'networks': { + 'foobar': {'aliases': ['foo', 'bar']} + } + } + }, + 'networks': {'foobar': {}, 'baz': {}} + } + ) + + details = config.ConfigDetails('.', [base_file, override_file]) + web_service = config.load(details).services[0] + assert web_service['networks'] == { + 'foobar': {'aliases': ['bar', 'foo']}, + 'baz': {} } def test_load_with_multiple_files_v2(self): @@ -1336,6 +1371,32 @@ class ConfigTest(unittest.TestCase): assert mount.type == 'bind' assert mount.source == expected_source + def test_load_bind_mount_relative_path_with_tilde(self): + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '3.4', + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': [ + {'type': 'bind', 'source': '~/web', 'target': '/web'}, + ], + }, + }, + }, + ) + + details = config.ConfigDetails('.', [base_file]) + config_data = config.load(details) + mount = config_data.services[0].get('volumes')[0] + assert mount.target == '/web' + assert mount.type == 'bind' + assert ( + not mount.source.startswith('~') and mount.source.endswith( + '{}web'.format(os.path.sep) + ) + ) + def test_config_invalid_ipam_config(self): with pytest.raises(ConfigurationError) as excinfo: config.load( @@ -3045,6 +3106,41 @@ class ConfigTest(unittest.TestCase): ) config.load(config_details) + def test_config_duplicate_mount_points(self): + config1 = build_config_details( + { + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox', + 'volumes': ['/tmp/foo:/tmp/foo', '/tmp/foo:/tmp/foo:rw'] + } + } + } + ) + + config2 = build_config_details( + { + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox', + 'volumes': ['/x:/y', '/z:/y'] + } + } + } + ) + + with self.assertRaises(ConfigurationError) as e: + config.load(config1) + self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % ( + ', '.join(['/tmp/foo:/tmp/foo:rw']*2))) + + with self.assertRaises(ConfigurationError) as e: + config.load(config2) + self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % ( + ', '.join(['/x:/y:rw', '/z:/y:rw']))) + class NetworkModeTest(unittest.TestCase): @@ -3817,8 +3913,77 @@ class MergePortsTest(unittest.TestCase, MergeListsTest): class MergeNetworksTest(unittest.TestCase, MergeListsTest): config_name = 'networks' - base_config = ['frontend', 'backend'] - override_config = ['monitoring'] + base_config = {'default': {'aliases': ['foo.bar', 'foo.baz']}} + override_config = {'default': {'ipv4_address': '123.234.123.234'}} + + def test_no_network_overrides(self): + service_dict = config.merge_service_dicts( + {self.config_name: self.base_config}, + {self.config_name: self.override_config}, + DEFAULT_VERSION) + assert service_dict[self.config_name] == { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'ipv4_address': '123.234.123.234' + } + } + + def test_all_properties(self): + service_dict = config.merge_service_dicts( + {self.config_name: { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'link_local_ips': ['192.168.1.10', '192.168.1.11'], + 'ipv4_address': '111.111.111.111', + 'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-first' + } + }}, + {self.config_name: { + 'default': { + 'aliases': ['foo.baz', 'foo.baz2'], + 'link_local_ips': ['192.168.1.11', '192.168.1.12'], + 'ipv4_address': '123.234.123.234', + 'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-second' + } + }}, + DEFAULT_VERSION) + + assert service_dict[self.config_name] == { + 'default': { + 'aliases': ['foo.bar', 'foo.baz', 'foo.baz2'], + 'link_local_ips': ['192.168.1.10', '192.168.1.11', '192.168.1.12'], + 'ipv4_address': '123.234.123.234', + 'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-second' + } + } + + def test_no_network_name_overrides(self): + service_dict = config.merge_service_dicts( + { + self.config_name: { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'ipv4_address': '123.234.123.234' + } + } + }, + { + self.config_name: { + 'another_network': { + 'ipv4_address': '123.234.123.234' + } + } + }, + DEFAULT_VERSION) + assert service_dict[self.config_name] == { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'ipv4_address': '123.234.123.234' + }, + 'another_network': { + 'ipv4_address': '123.234.123.234' + } + } class MergeStringsOrListsTest(unittest.TestCase): diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 854aee5a3..88eb0d6e1 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -9,6 +9,7 @@ import pytest from compose.config.environment import env_vars_from_file from compose.config.environment import Environment +from compose.config.errors import ConfigurationError from tests import unittest @@ -52,3 +53,12 @@ class EnvironmentTest(unittest.TestCase): assert env_vars_from_file(str(tmpdir.join('bom.env'))) == { 'PARK_BOM': '박봄' } + + def test_env_vars_from_file_whitespace(self): + tmpdir = pytest.ensuretemp('env_file') + self.addCleanup(tmpdir.remove) + with codecs.open('{}/whitespace.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: + f.write('WHITESPACE =yes\n') + with pytest.raises(ConfigurationError) as exc: + env_vars_from_file(str(tmpdir.join('whitespace.env'))) + assert 'environment variable' in exc.exconly() diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index d29227458..6fdb7d927 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -97,22 +97,24 @@ class ProgressStreamTestCase(unittest.TestCase): tf.seek(0) assert tf.read() == '???' + def test_get_digest_from_push(self): + digest = "sha256:abcd" + events = [ + {"status": "..."}, + {"status": "..."}, + {"progressDetail": {}, "aux": {"Digest": digest}}, + ] + assert progress_stream.get_digest_from_push(events) == digest -def test_get_digest_from_push(): - digest = "sha256:abcd" - events = [ - {"status": "..."}, - {"status": "..."}, - {"progressDetail": {}, "aux": {"Digest": digest}}, - ] - assert progress_stream.get_digest_from_push(events) == digest + def test_get_digest_from_pull(self): + events = list() + assert progress_stream.get_digest_from_pull(events) is None - -def test_get_digest_from_pull(): - digest = "sha256:abcd" - events = [ - {"status": "..."}, - {"status": "..."}, - {"status": "Digest: %s" % digest}, - ] - assert progress_stream.get_digest_from_pull(events) == digest + digest = "sha256:abcd" + events = [ + {"status": "..."}, + {"status": "..."}, + {"status": "Digest: %s" % digest}, + {"status": "..."}, + ] + assert progress_stream.get_digest_from_pull(events) == digest diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 1cc841814..4aea91a0d 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -254,9 +254,10 @@ class ProjectTest(unittest.TestCase): [container_ids[0] + ':rw'] ) - def test_events(self): + def test_events_legacy(self): services = [Service(name='web'), Service(name='db')] project = Project('test', services, self.mock_client) + self.mock_client.api_version = '1.21' self.mock_client.events.return_value = iter([ { 'status': 'create', @@ -362,6 +363,175 @@ class ProjectTest(unittest.TestCase): }, ] + def test_events(self): + services = [Service(name='web'), Service(name='db')] + project = Project('test', services, self.mock_client) + self.mock_client.api_version = '1.35' + self.mock_client.events.return_value = iter([ + { + 'status': 'create', + 'from': 'example/image', + 'Type': 'container', + 'Actor': { + 'ID': 'abcde', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'web', + 'image': 'example/image', + 'name': 'test_web_1', + } + }, + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000002000, + }, + { + 'status': 'attach', + 'from': 'example/image', + 'Type': 'container', + 'Actor': { + 'ID': 'abcde', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'web', + 'image': 'example/image', + 'name': 'test_web_1', + } + }, + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000003000, + }, + { + 'status': 'create', + 'from': 'example/other', + 'Type': 'container', + 'Actor': { + 'ID': 'bdbdbd', + 'Attributes': { + 'image': 'example/other', + 'name': 'shrewd_einstein', + } + }, + 'id': 'bdbdbd', + 'time': 1420092061, + 'timeNano': 14200920610000005000, + }, + { + 'status': 'create', + 'from': 'example/db', + 'Type': 'container', + 'Actor': { + 'ID': 'ababa', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'db', + 'image': 'example/db', + 'name': 'test_db_1', + } + }, + 'id': 'ababa', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, + { + 'status': 'destroy', + 'from': 'example/db', + 'Type': 'container', + 'Actor': { + 'ID': 'eeeee', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'db', + 'image': 'example/db', + 'name': 'test_db_1', + } + }, + 'id': 'eeeee', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, + ]) + + def dt_with_microseconds(dt, us): + return datetime.datetime.fromtimestamp(dt).replace(microsecond=us) + + def get_container(cid): + if cid == 'eeeee': + raise NotFound(None, None, "oops") + if cid == 'abcde': + name = 'web' + labels = {LABEL_SERVICE: name} + elif cid == 'ababa': + name = 'db' + labels = {LABEL_SERVICE: name} + else: + labels = {} + name = '' + return { + 'Id': cid, + 'Config': {'Labels': labels}, + 'Name': '/project_%s_1' % name, + } + + self.mock_client.inspect_container.side_effect = get_container + + events = project.events() + + events_list = list(events) + # Assert the return value is a generator + assert not list(events) + assert events_list == [ + { + 'type': 'container', + 'service': 'web', + 'action': 'create', + 'id': 'abcde', + 'attributes': { + 'name': 'test_web_1', + 'image': 'example/image', + }, + 'time': dt_with_microseconds(1420092061, 2), + 'container': Container(None, get_container('abcde')), + }, + { + 'type': 'container', + 'service': 'web', + 'action': 'attach', + 'id': 'abcde', + 'attributes': { + 'name': 'test_web_1', + 'image': 'example/image', + }, + 'time': dt_with_microseconds(1420092061, 3), + 'container': Container(None, get_container('abcde')), + }, + { + 'type': 'container', + 'service': 'db', + 'action': 'create', + 'id': 'ababa', + 'attributes': { + 'name': 'test_db_1', + 'image': 'example/db', + }, + 'time': dt_with_microseconds(1420092061, 4), + 'container': Container(None, get_container('ababa')), + }, + { + 'type': 'container', + 'service': 'db', + 'action': 'destroy', + 'id': 'eeeee', + 'attributes': { + 'name': 'test_db_1', + 'image': 'example/db', + }, + 'time': dt_with_microseconds(1420092061, 4), + 'container': None, + }, + ] + def test_net_unset(self): project = Project.from_config( name='test', @@ -620,3 +790,23 @@ class ProjectTest(unittest.TestCase): self.mock_client.pull.side_effect = OperationFailedError(b'pull error') with pytest.raises(ProjectError): project.pull(parallel_pull=True) + + def test_avoid_multiple_push(self): + service_config_latest = {'image': 'busybox:latest', 'build': '.'} + service_config_default = {'image': 'busybox', 'build': '.'} + service_config_sha = { + 'image': 'busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d', + 'build': '.' + } + svc1 = Service('busy1', **service_config_latest) + svc1_1 = Service('busy11', **service_config_latest) + svc2 = Service('busy2', **service_config_default) + svc2_1 = Service('busy21', **service_config_default) + svc3 = Service('busy3', **service_config_sha) + svc3_1 = Service('busy31', **service_config_sha) + project = Project( + 'composetest', [svc1, svc1_1, svc2, svc2_1, svc3, svc3_1], self.mock_client + ) + with mock.patch('compose.service.Service.push') as fake_push: + project.push() + assert fake_push.call_count == 2 diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 99adea34b..8b3352fcb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,7 @@ import docker import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError +from docker.errors import ImageNotFound from docker.errors import NotFound from .. import mock @@ -755,6 +756,13 @@ class ServiceTest(unittest.TestCase): mock_log.error.assert_called_once_with( "Failed to remove image for service %s: %s", web.name, error) + def test_remove_non_existing_image(self): + self.mock_client.remove_image.side_effect = ImageNotFound('image not found') + web = Service('web', image='example', client=self.mock_client) + with mock.patch('compose.service.log', autospec=True) as mock_log: + assert not web.remove_image(ImageType.all) + mock_log.warning.assert_called_once_with("Image %s not found.", web.image_name) + def test_specifies_host_port_with_no_ports(self): service = Service( 'foo',