Browse Source

Merge pull request #4635 from docker/bump-1.12.0-rc1

Bump 1.12.0 RC1
Joffrey F 8 years ago
parent
commit
23a808e5c4
60 changed files with 2271 additions and 377 deletions
  1. 3 0
      .gitignore
  2. 92 1
      CHANGELOG.md
  3. 71 0
      Dockerfile.armhf
  4. 10 10
      Dockerfile.run
  5. 2 2
      README.md
  6. 1 1
      ROADMAP.md
  7. 1 1
      compose/__init__.py
  8. 1 1
      compose/bundle.py
  9. 6 4
      compose/cli/command.py
  10. 8 3
      compose/cli/errors.py
  11. 1 1
      compose/cli/formatter.py
  12. 12 9
      compose/cli/log_printer.py
  13. 194 61
      compose/cli/main.py
  14. 9 0
      compose/cli/signals.py
  15. 13 0
      compose/cli/utils.py
  16. 1 0
      compose/config/__init__.py
  17. 75 25
      compose/config/config.py
  18. 9 1
      compose/config/config_schema_v2.0.json
  19. 10 1
      compose/config/config_schema_v2.1.json
  20. 472 0
      compose/config/config_schema_v3.2.json
  21. 1 1
      compose/config/errors.py
  22. 35 24
      compose/config/serialize.py
  23. 61 1
      compose/config/types.py
  24. 18 9
      compose/config/validation.py
  25. 4 0
      compose/const.py
  26. 50 8
      compose/network.py
  27. 34 17
      compose/parallel.py
  28. 19 6
      compose/project.py
  29. 72 16
      compose/service.py
  30. 58 18
      contrib/completion/bash/docker-compose
  31. 24 0
      contrib/completion/fish/docker-compose.fish
  32. 3 1
      script/build/image
  33. 1 1
      script/release/utils.sh
  34. 2 2
      script/run/run.sh
  35. 5 1
      script/test/default
  36. 2 2
      script/test/versions.py
  37. 172 16
      tests/acceptance/cli_test.py
  38. 6 0
      tests/fixtures/abort-on-container-exit-0/docker-compose.yml
  39. 6 0
      tests/fixtures/abort-on-container-exit-1/docker-compose.yml
  40. 2 0
      tests/fixtures/build-path-override-dir/docker-compose.yml
  41. 6 0
      tests/fixtures/exit-code-from/docker-compose.yml
  42. 15 0
      tests/fixtures/ports-composefile/expanded-notation.yml
  43. 2 0
      tests/fixtures/simple-composefile-volume-ready/docker-compose.yml
  44. 1 0
      tests/fixtures/simple-composefile-volume-ready/files/example.txt
  45. 12 1
      tests/fixtures/v3-full/docker-compose.yml
  46. 11 0
      tests/fixtures/volume/docker-compose.yml
  47. 29 0
      tests/helpers.py
  48. 4 30
      tests/integration/project_test.py
  49. 60 4
      tests/integration/service_test.py
  50. 4 4
      tests/integration/testcases.py
  51. 9 0
      tests/unit/cli/command_test.py
  52. 16 0
      tests/unit/cli/errors_test.py
  53. 4 2
      tests/unit/cli/log_printer_test.py
  54. 11 8
      tests/unit/cli_test.py
  55. 288 73
      tests/unit/config/config_test.py
  56. 26 1
      tests/unit/config/interpolation_test.py
  57. 46 2
      tests/unit/config/types_test.py
  58. 69 5
      tests/unit/network_test.py
  59. 27 1
      tests/unit/parallel_test.py
  60. 65 2
      tests/unit/service_test.py

+ 3 - 0
.gitignore

@@ -9,3 +9,6 @@
 /venv
 /venv
 README.rst
 README.rst
 compose/GITSHA
 compose/GITSHA
+*.swo
+*.swp
+.DS_Store

+ 92 - 1
CHANGELOG.md

@@ -1,6 +1,97 @@
 Change log
 Change log
 ==========
 ==========
 
 
+1.12.0 (2017-03-21)
+-------------------
+
+### New features
+
+#### Compose file version 3.2
+
+- Introduced version 3.2 of the `docker-compose.yml` specification.
+
+- Added support for `cache_from` in the `build` section of services
+
+- Added support for the new expanded ports syntax in service definitions
+
+- Added support for the new expanded volumes syntax in service definitions
+
+#### Compose file version 2.1
+
+- Added support for `pids_limit` in service definitions
+
+#### Compose file version 2.0 and up
+
+- Added `--volumes` option to `docker-compose config` that lists named
+  volumes declared for that project
+
+- Added support for `mem_reservation` in service definitions (2.x only)
+
+- Added support for `dns_opt` in service definitions (2.x only)
+
+#### All formats
+
+- Added a new `docker-compose images` command that lists images used by
+  the current project's containers
+
+- Added a `--stop` (shorthand `-s`) option to `docker-compose rm` that stops
+  the running containers before removing them
+
+- Added a `--resolve-image-digests` option to `docker-compose config` that
+  pins the image version for each service to a permanent digest
+
+- Added a `--exit-code-from SERVICE` option to `docker-compose up`. When
+  used, `docker-compose` will exit on any container's exit with the code
+  corresponding to the specified service's exit code
+
+- Added a `--parallel` option to `docker-compose pull` that enables images
+  for multiple services to be pulled simultaneously
+
+- Added a `--build-arg` option to `docker-compose build`
+
+- Added a `--volume <volume_mapping>` (shorthand `-v`) option to
+  `docker-compose run` to declare runtime volumes to be mounted
+
+- Added a `--project-directory PATH` option to `docker-compose` that will
+  affect path resolution for the project
+
+- When using `--abort-on-container-exit` in `docker-compose up`, the exit
+  code for the container that caused the abort will be the exit code of
+  the `docker-compose up` command
+
+- Users can now configure which path separator character they want to use
+  to separate the `COMPOSE_FILE` environment value using the
+  `COMPOSE_PATH_SEPARATOR` environment variable
+
+- Added support for port range to single port in port mappings
+  (e.g. `8000-8010:80`)
+
+### Bugfixes
+
+- `docker-compose run --rm` now removes anonymous volumes after execution,
+  matching the behavior of `docker run --rm`.
+
+- Fixed a bug where override files containing port lists would cause a
+  TypeError to be raised
+
+- Fixed a bug where scaling services up or down would sometimes re-use
+  obsolete containers
+
+- Fixed a bug where the output of `docker-compose config` would be invalid
+  if the project declared anonymous volumes
+
+- Variable interpolation now properly occurs in the `secrets` section of
+  the Compose file
+
+- The `secrets` section now properly appears in the output of
+  `docker-compose config`
+
+- Fixed a bug where changes to some networks properties would not be
+  detected against previously created networks
+
+- Fixed a bug where `docker-compose` would crash when trying to write into
+  a closed pipe
+
 1.11.2 (2017-02-17)
 1.11.2 (2017-02-17)
 -------------------
 -------------------
 
 
@@ -649,7 +740,7 @@ Bug Fixes:
     if at least one container is using the network.
     if at least one container is using the network.
 
 
 -   When printings logs during `up` or `logs`, flush the output buffer after
 -   When printings logs during `up` or `logs`, flush the output buffer after
-    each line to prevent buffering issues from hideing logs.
+    each line to prevent buffering issues from hiding logs.
 
 
 -   Recreate a container if one of its dependencies is being created.
 -   Recreate a container if one of its dependencies is being created.
     Previously a container was only recreated if it's dependencies already
     Previously a container was only recreated if it's dependencies already

+ 71 - 0
Dockerfile.armhf

@@ -0,0 +1,71 @@
+FROM armhf/debian:wheezy
+
+RUN set -ex; \
+    apt-get update -qq; \
+    apt-get install -y \
+        locales \
+        gcc \
+        make \
+        zlib1g \
+        zlib1g-dev \
+        libssl-dev \
+        git \
+        ca-certificates \
+        curl \
+        libsqlite3-dev \
+        libbz2-dev \
+    ; \
+    rm -rf /var/lib/apt/lists/*
+
+RUN curl https://get.docker.com/builds/Linux/armel/docker-1.8.3 \
+        -o /usr/local/bin/docker && \
+    chmod +x /usr/local/bin/docker
+
+# Build Python 2.7.13 from source
+RUN set -ex; \
+    curl -L https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz | tar -xz; \
+    cd Python-2.7.13; \
+    ./configure --enable-shared; \
+    make; \
+    make install; \
+    cd ..; \
+    rm -rf /Python-2.7.13
+
+# Build python 3.4 from source
+RUN set -ex; \
+    curl -L https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz | tar -xz; \
+    cd Python-3.4.6; \
+    ./configure --enable-shared; \
+    make; \
+    make install; \
+    cd ..; \
+    rm -rf /Python-3.4.6
+
+# Make libpython findable
+ENV LD_LIBRARY_PATH /usr/local/lib
+
+# Install pip
+RUN set -ex; \
+    curl -L https://bootstrap.pypa.io/get-pip.py | python
+
+# Python3 requires a valid locale
+RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
+ENV LANG en_US.UTF-8
+
+RUN useradd -d /home/user -m -s /bin/bash user
+WORKDIR /code/
+
+RUN pip install tox==2.1.1
+
+ADD requirements.txt /code/
+ADD requirements-dev.txt /code/
+ADD .pre-commit-config.yaml /code/
+ADD setup.py /code/
+ADD tox.ini /code/
+ADD compose /code/compose/
+RUN tox --notest
+
+ADD . /code/
+RUN chown -R user /code/
+
+ENTRYPOINT ["/code/.tox/py27/bin/docker-compose"]

+ 10 - 10
Dockerfile.run

@@ -1,14 +1,14 @@
+FROM alpine:3.4
 
 
-FROM    alpine:3.4
-ARG     version
-RUN     apk -U add \
-            python \
-            py-pip
+ENV GLIBC 2.23-r3
 
 
-COPY    requirements.txt /code/requirements.txt
-RUN     pip install -r /code/requirements.txt
+RUN apk update && apk add --no-cache openssl ca-certificates && \
+    wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \
+    wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \
+    apk add --no-cache glibc-$GLIBC.apk && rm glibc-$GLIBC.apk && \
+    ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \
+    ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib
 
 
-COPY     dist/docker_compose-${version}-py2.py3-none-any.whl /code/
-RUN     pip install --no-deps /code/docker_compose-${version}-py2.py3-none-any.whl
+COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose
 
 
-ENTRYPOINT ["/usr/bin/docker-compose"]
+ENTRYPOINT ["docker-compose"]

+ 2 - 2
README.md

@@ -35,7 +35,7 @@ A `docker-compose.yml` looks like this:
         image: redis
         image: redis
 
 
 For more information about the Compose file, see the
 For more information about the Compose file, see the
-[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file.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:
 Compose has commands for managing the whole lifecycle of your application:
 
 
@@ -55,7 +55,7 @@ Installation and documentation
 Contributing
 Contributing
 ------------
 ------------
 
 
-[![Build Status](http://jenkins.dockerproject.org/buildStatus/icon?job=Compose%20Master)](http://jenkins.dockerproject.org/job/Compose%20Master/)
+[![Build Status](https://jenkins.dockerproject.org/buildStatus/icon?job=docker/compose/master)](https://jenkins.dockerproject.org/job/docker/job/compose/job/master/)
 
 
 Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md).
 Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md).
 
 

+ 1 - 1
ROADMAP.md

@@ -16,7 +16,7 @@ Some specific things we are considering:
   - It should roll back to a known good state if it fails.
   - It should roll back to a known good state if it fails.
   - It should allow a user to check the actions it is about to perform before running them.
   - It should allow a user to check the actions it is about to perform before running them.
 - It should be possible to partially modify the config file for different environments (dev/test/staging/prod), passing in e.g. custom ports, volume mount paths, or volume drivers. ([#1377](https://github.com/docker/compose/issues/1377))
 - It should be possible to partially modify the config file for different environments (dev/test/staging/prod), passing in e.g. custom ports, volume mount paths, or volume drivers. ([#1377](https://github.com/docker/compose/issues/1377))
-- Compose should recommend a technique for zero-downtime deploys.
+- Compose should recommend a technique for zero-downtime deploys. ([#1786](https://github.com/docker/compose/issues/1786))
 - It should be possible to continuously attempt to keep an application in the correct state, instead of just performing `up` a single time.
 - It should be possible to continuously attempt to keep an application in the correct state, instead of just performing `up` a single time.
 
 
 ## Integration with Swarm
 ## Integration with Swarm

+ 1 - 1
compose/__init__.py

@@ -1,4 +1,4 @@
 from __future__ import absolute_import
 from __future__ import absolute_import
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
-__version__ = '1.11.2'
+__version__ = '1.12.0-rc1'

+ 1 - 1
compose/bundle.py

@@ -202,7 +202,7 @@ def convert_service_to_bundle(name, service_dict, image_digest):
     return container_config
     return container_config
 
 
 
 
-# See https://github.com/docker/swarmkit/blob//agent/exec/container/container.go#L95
+# See https://github.com/docker/swarmkit/blob/agent/exec/container/container.go#L95
 def set_command_and_args(config, entrypoint, command):
 def set_command_and_args(config, entrypoint, command):
     if isinstance(entrypoint, six.string_types):
     if isinstance(entrypoint, six.string_types):
         entrypoint = split_command(entrypoint)
         entrypoint = split_command(entrypoint)

+ 6 - 4
compose/cli/command.py

@@ -33,7 +33,8 @@ def project_from_options(project_dir, options):
         verbose=options.get('--verbose'),
         verbose=options.get('--verbose'),
         host=host,
         host=host,
         tls_config=tls_config_from_options(options),
         tls_config=tls_config_from_options(options),
-        environment=environment
+        environment=environment,
+        override_dir=options.get('--project-directory'),
     )
     )
 
 
 
 
@@ -54,7 +55,8 @@ def get_config_path_from_options(base_dir, options, environment):
 
 
     config_files = environment.get('COMPOSE_FILE')
     config_files = environment.get('COMPOSE_FILE')
     if config_files:
     if config_files:
-        return config_files.split(os.pathsep)
+        pathsep = environment.get('COMPOSE_PATH_SEPARATOR', os.pathsep)
+        return config_files.split(pathsep)
     return None
     return None
 
 
 
 
@@ -93,10 +95,10 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N
 
 
 
 
 def get_project(project_dir, config_path=None, project_name=None, verbose=False,
 def get_project(project_dir, config_path=None, project_name=None, verbose=False,
-                host=None, tls_config=None, environment=None):
+                host=None, tls_config=None, environment=None, override_dir=None):
     if not environment:
     if not environment:
         environment = Environment.from_env_file(project_dir)
         environment = Environment.from_env_file(project_dir)
-    config_details = config.find(project_dir, config_path, environment)
+    config_details = config.find(project_dir, config_path, environment, override_dir)
     project_name = get_project_name(
     project_name = get_project_name(
         config_details.working_dir, project_name, environment
         config_details.working_dir, project_name, environment
     )
     )

+ 8 - 3
compose/cli/errors.py

@@ -7,6 +7,7 @@ import socket
 from distutils.spawn import find_executable
 from distutils.spawn import find_executable
 from textwrap import dedent
 from textwrap import dedent
 
 
+import six
 from docker.errors import APIError
 from docker.errors import APIError
 from requests.exceptions import ConnectionError as RequestsConnectionError
 from requests.exceptions import ConnectionError as RequestsConnectionError
 from requests.exceptions import ReadTimeout
 from requests.exceptions import ReadTimeout
@@ -68,14 +69,18 @@ def log_timeout_error(timeout):
 
 
 
 
 def log_api_error(e, client_version):
 def log_api_error(e, client_version):
-    if b'client is newer than server' not in e.explanation:
-        log.error(e.explanation)
+    explanation = e.explanation
+    if isinstance(explanation, six.binary_type):
+        explanation = explanation.decode('utf-8')
+
+    if 'client is newer than server' not in explanation:
+        log.error(explanation)
         return
         return
 
 
     version = API_VERSION_TO_ENGINE_VERSION.get(client_version)
     version = API_VERSION_TO_ENGINE_VERSION.get(client_version)
     if not version:
     if not version:
         # They've set a custom API version
         # They've set a custom API version
-        log.error(e.explanation)
+        log.error(explanation)
         return
         return
 
 
     log.error(
     log.error(

+ 1 - 1
compose/cli/formatter.py

@@ -11,7 +11,7 @@ from compose.cli import colors
 
 
 
 
 def get_tty_width():
 def get_tty_width():
-    tty_size = os.popen('stty size', 'r').read().split()
+    tty_size = os.popen('stty size 2> /dev/null', 'r').read().split()
     if len(tty_size) != 2:
     if len(tty_size) != 2:
         return 0
         return 0
     _, width = tty_size
     _, width = tty_size

+ 12 - 9
compose/cli/log_printer.py

@@ -87,6 +87,13 @@ class LogPrinter(object):
         for line in consume_queue(queue, self.cascade_stop):
         for line in consume_queue(queue, self.cascade_stop):
             remove_stopped_threads(thread_map)
             remove_stopped_threads(thread_map)
 
 
+            if self.cascade_stop:
+                matching_container = [cont.name for cont in self.containers if cont.name == line]
+                if line in matching_container:
+                    # Returning the name of the container that started the
+                    # the cascade_stop so we can return the correct exit code
+                    return line
+
             if not line:
             if not line:
                 if not thread_map:
                 if not thread_map:
                     # There are no running containers left to tail, so exit
                     # There are no running containers left to tail, so exit
@@ -132,8 +139,8 @@ class QueueItem(namedtuple('_QueueItem', 'item is_stop exc')):
         return cls(None, None, exc)
         return cls(None, None, exc)
 
 
     @classmethod
     @classmethod
-    def stop(cls):
-        return cls(None, True, None)
+    def stop(cls, item=None):
+        return cls(item, True, None)
 
 
 
 
 def tail_container_logs(container, presenter, queue, log_args):
 def tail_container_logs(container, presenter, queue, log_args):
@@ -145,10 +152,9 @@ def tail_container_logs(container, presenter, queue, log_args):
     except Exception as e:
     except Exception as e:
         queue.put(QueueItem.exception(e))
         queue.put(QueueItem.exception(e))
         return
         return
-
     if log_args.get('follow'):
     if log_args.get('follow'):
         queue.put(QueueItem.new(presenter.color_func(wait_on_exit(container))))
         queue.put(QueueItem.new(presenter.color_func(wait_on_exit(container))))
-    queue.put(QueueItem.stop())
+    queue.put(QueueItem.stop(container.name))
 
 
 
 
 def get_log_generator(container):
 def get_log_generator(container):
@@ -228,10 +234,7 @@ def consume_queue(queue, cascade_stop):
         if item.exc:
         if item.exc:
             raise item.exc
             raise item.exc
 
 
-        if item.is_stop:
-            if cascade_stop:
-                raise StopIteration
-            else:
-                continue
+        if item.is_stop and not cascade_stop:
+            continue
 
 
         yield item.item
         yield item.item

+ 194 - 61
compose/cli/main.py

@@ -22,8 +22,10 @@ from ..bundle import MissingDigests
 from ..bundle import serialize_bundle
 from ..bundle import serialize_bundle
 from ..config import ConfigurationError
 from ..config import ConfigurationError
 from ..config import parse_environment
 from ..config import parse_environment
+from ..config import resolve_build_args
 from ..config.environment import Environment
 from ..config.environment import Environment
 from ..config.serialize import serialize_config
 from ..config.serialize import serialize_config
+from ..config.types import VolumeSpec
 from ..const import IS_WINDOWS_PLATFORM
 from ..const import IS_WINDOWS_PLATFORM
 from ..errors import StreamParseError
 from ..errors import StreamParseError
 from ..progress_stream import StreamOutputError
 from ..progress_stream import StreamOutputError
@@ -47,6 +49,7 @@ from .formatter import Formatter
 from .log_printer import build_log_presenters
 from .log_printer import build_log_presenters
 from .log_printer import LogPrinter
 from .log_printer import LogPrinter
 from .utils import get_version_info
 from .utils import get_version_info
+from .utils import human_readable_file_size
 from .utils import yesno
 from .utils import yesno
 
 
 
 
@@ -58,9 +61,9 @@ console_handler = logging.StreamHandler(sys.stderr)
 
 
 
 
 def main():
 def main():
-    command = dispatch()
-
+    signals.ignore_sigpipe()
     try:
     try:
+        command = dispatch()
         command()
         command()
     except (KeyboardInterrupt, signals.ShutdownException):
     except (KeyboardInterrupt, signals.ShutdownException):
         log.error("Aborting.")
         log.error("Aborting.")
@@ -78,6 +81,10 @@ def main():
     except NeedsBuildError as e:
     except NeedsBuildError as e:
         log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name)
         log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name)
         sys.exit(1)
         sys.exit(1)
+    except NoSuchCommand as e:
+        commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))
+        log.error("No such command: %s\n\n%s", e.command, commands)
+        sys.exit(1)
     except (errors.ConnectionError, StreamParseError):
     except (errors.ConnectionError, StreamParseError):
         sys.exit(1)
         sys.exit(1)
 
 
@@ -88,13 +95,7 @@ def dispatch():
         TopLevelCommand,
         TopLevelCommand,
         {'options_first': True, 'version': get_version_info('compose')})
         {'options_first': True, 'version': get_version_info('compose')})
 
 
-    try:
-        options, handler, command_options = dispatcher.parse(sys.argv[1:])
-    except NoSuchCommand as e:
-        commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))
-        log.error("No such command: %s\n\n%s", e.command, commands)
-        sys.exit(1)
-
+    options, handler, command_options = dispatcher.parse(sys.argv[1:])
     setup_console_handler(console_handler, options.get('--verbose'))
     setup_console_handler(console_handler, options.get('--verbose'))
     return functools.partial(perform_command, options, handler, command_options)
     return functools.partial(perform_command, options, handler, command_options)
 
 
@@ -168,6 +169,8 @@ class TopLevelCommand(object):
       --skip-hostname-check       Don't check the daemon's hostname against the name specified
       --skip-hostname-check       Don't check the daemon's hostname against the name specified
                                   in the client certificate (for example if your docker host
                                   in the client certificate (for example if your docker host
                                   is an IP address)
                                   is an IP address)
+      --project-directory PATH    Specify an alternate working directory
+                                  (default: the path of the compose file)
 
 
     Commands:
     Commands:
       build              Build or rebuild services
       build              Build or rebuild services
@@ -178,6 +181,7 @@ class TopLevelCommand(object):
       events             Receive real time events from containers
       events             Receive real time events from containers
       exec               Execute a command in a running container
       exec               Execute a command in a running container
       help               Get help on a command
       help               Get help on a command
+      images             List images
       kill               Kill containers
       kill               Kill containers
       logs               View output from containers
       logs               View output from containers
       pause              Pause services
       pause              Pause services
@@ -209,18 +213,29 @@ class TopLevelCommand(object):
         e.g. `composetest_db`. If you change a service's `Dockerfile` or the
         e.g. `composetest_db`. If you change a service's `Dockerfile` or the
         contents of its build directory, you can run `docker-compose build` to rebuild it.
         contents of its build directory, you can run `docker-compose build` to rebuild it.
 
 
-        Usage: build [options] [SERVICE...]
+        Usage: build [options] [--build-arg key=val...] [SERVICE...]
 
 
         Options:
         Options:
-            --force-rm  Always remove intermediate containers.
-            --no-cache  Do not use cache when building the image.
-            --pull      Always attempt to pull a newer version of the image.
+            --force-rm              Always remove intermediate containers.
+            --no-cache              Do not use cache when building the image.
+            --pull                  Always attempt to pull a newer version of the image.
+            --build-arg key=val     Set build-time variables for one service.
         """
         """
+        service_names = options['SERVICE']
+        build_args = options.get('--build-arg', None)
+        if build_args:
+            environment = Environment.from_env_file(self.project_dir)
+            build_args = resolve_build_args(build_args, environment)
+
+        if not service_names and build_args:
+            raise UserError("Need service name for --build-arg option")
+
         self.project.build(
         self.project.build(
-            service_names=options['SERVICE'],
+            service_names=service_names,
             no_cache=bool(options.get('--no-cache', False)),
             no_cache=bool(options.get('--no-cache', False)),
             pull=bool(options.get('--pull', False)),
             pull=bool(options.get('--pull', False)),
-            force_rm=bool(options.get('--force-rm', False)))
+            force_rm=bool(options.get('--force-rm', False)),
+            build_args=build_args)
 
 
     def bundle(self, config_options, options):
     def bundle(self, config_options, options):
         """
         """
@@ -248,43 +263,7 @@ class TopLevelCommand(object):
         if not output:
         if not output:
             output = "{}.dab".format(self.project.name)
             output = "{}.dab".format(self.project.name)
 
 
-        with errors.handle_connection_errors(self.project.client):
-            try:
-                image_digests = get_image_digests(
-                    self.project,
-                    allow_push=options['--push-images'],
-                )
-            except MissingDigests as e:
-                def list_images(images):
-                    return "\n".join("    {}".format(name) for name in sorted(images))
-
-                paras = ["Some images are missing digests."]
-
-                if e.needs_push:
-                    command_hint = (
-                        "Use `docker-compose push {}` to push them. "
-                        "You can do this automatically with `docker-compose bundle --push-images`."
-                        .format(" ".join(sorted(e.needs_push)))
-                    )
-                    paras += [
-                        "The following images can be pushed:",
-                        list_images(e.needs_push),
-                        command_hint,
-                    ]
-
-                if e.needs_pull:
-                    command_hint = (
-                        "Use `docker-compose pull {}` to pull them. "
-                        .format(" ".join(sorted(e.needs_pull)))
-                    )
-
-                    paras += [
-                        "The following images need to be pulled:",
-                        list_images(e.needs_pull),
-                        command_hint,
-                    ]
-
-                raise UserError("\n\n".join(paras))
+        image_digests = image_digests_for_project(self.project, options['--push-images'])
 
 
         with open(output, 'w') as f:
         with open(output, 'w') as f:
             f.write(serialize_bundle(compose_config, image_digests))
             f.write(serialize_bundle(compose_config, image_digests))
@@ -298,12 +277,20 @@ class TopLevelCommand(object):
         Usage: config [options]
         Usage: config [options]
 
 
         Options:
         Options:
-            -q, --quiet     Only validate the configuration, don't print
-                            anything.
-            --services      Print the service names, one per line.
+            --resolve-image-digests  Pin image tags to digests.
+            -q, --quiet              Only validate the configuration, don't print
+                                     anything.
+            --services               Print the service names, one per line.
+            --volumes                Print the volume names, one per line.
 
 
         """
         """
+
         compose_config = get_config_from_options(self.project_dir, config_options)
         compose_config = get_config_from_options(self.project_dir, config_options)
+        image_digests = None
+
+        if options['--resolve-image-digests']:
+            self.project = project_from_options('.', config_options)
+            image_digests = image_digests_for_project(self.project)
 
 
         if options['--quiet']:
         if options['--quiet']:
             return
             return
@@ -312,7 +299,11 @@ class TopLevelCommand(object):
             print('\n'.join(service['name'] for service in compose_config.services))
             print('\n'.join(service['name'] for service in compose_config.services))
             return
             return
 
 
-        print(serialize_config(compose_config))
+        if options['--volumes']:
+            print('\n'.join(volume for volume in compose_config.volumes))
+            return
+
+        print(serialize_config(compose_config, image_digests))
 
 
     def create(self, options):
     def create(self, options):
         """
         """
@@ -479,6 +470,45 @@ class TopLevelCommand(object):
 
 
         print(getdoc(subject))
         print(getdoc(subject))
 
 
+    def images(self, options):
+        """
+        List images used by the created containers.
+        Usage: images [options] [SERVICE...]
+
+        Options:
+        -q     Only display IDs
+        """
+        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['-q']:
+            for image in set(c.image for c in containers):
+                print(image.split(':')[1])
+        else:
+            headers = [
+                'Container',
+                'Repository',
+                'Tag',
+                'Image Id',
+                'Size'
+            ]
+            rows = []
+            for container in containers:
+                image_config = container.image_config
+                repo_tags = image_config['RepoTags'][0].split(':')
+                image_id = image_config['Id'].split(':')[1][:12]
+                size = human_readable_file_size(image_config['Size'])
+                rows.append([
+                    container.name,
+                    repo_tags[0],
+                    repo_tags[1],
+                    image_id,
+                    size
+                ])
+            print(Formatter().table(headers, rows))
+
     def kill(self, options):
     def kill(self, options):
         """
         """
         Force stop service containers.
         Force stop service containers.
@@ -602,10 +632,12 @@ class TopLevelCommand(object):
 
 
         Options:
         Options:
             --ignore-pull-failures  Pull what it can and ignores images with pull failures.
             --ignore-pull-failures  Pull what it can and ignores images with pull failures.
+            --parallel              Pull multiple images in parallel.
         """
         """
         self.project.pull(
         self.project.pull(
             service_names=options['SERVICE'],
             service_names=options['SERVICE'],
-            ignore_pull_failures=options.get('--ignore-pull-failures')
+            ignore_pull_failures=options.get('--ignore-pull-failures'),
+            parallel_pull=options.get('--parallel')
         )
         )
 
 
     def push(self, options):
     def push(self, options):
@@ -635,6 +667,7 @@ class TopLevelCommand(object):
 
 
         Options:
         Options:
             -f, --force   Don't ask to confirm removal
             -f, --force   Don't ask to confirm removal
+            -s, --stop    Stop the containers, if required, before removing
             -v            Remove any anonymous volumes attached to containers
             -v            Remove any anonymous volumes attached to containers
             -a, --all     Deprecated - no effect.
             -a, --all     Deprecated - no effect.
         """
         """
@@ -645,6 +678,15 @@ class TopLevelCommand(object):
             )
             )
         one_off = OneOffFilter.include
         one_off = OneOffFilter.include
 
 
+        if options.get('--stop'):
+            running_containers = self.project.containers(
+                service_names=options['SERVICE'], stopped=False, one_off=one_off
+            )
+            self.project.stop(
+                service_names=running_containers,
+                one_off=one_off
+            )
+
         all_containers = self.project.containers(
         all_containers = self.project.containers(
             service_names=options['SERVICE'], stopped=True, one_off=one_off
             service_names=options['SERVICE'], stopped=True, one_off=one_off
         )
         )
@@ -674,7 +716,7 @@ class TopLevelCommand(object):
         running. If you do not want to start linked services, use
         running. If you do not want to start linked services, use
         `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`.
         `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`.
 
 
-        Usage: run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]
+        Usage: run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]
 
 
         Options:
         Options:
             -d                    Detached mode: Run container in the background, print
             -d                    Detached mode: Run container in the background, print
@@ -688,6 +730,7 @@ class TopLevelCommand(object):
             -p, --publish=[]      Publish a container's port(s) to the host
             -p, --publish=[]      Publish a container's port(s) to the host
             --service-ports       Run command with the service's ports enabled and mapped
             --service-ports       Run command with the service's ports enabled and mapped
                                   to the host.
                                   to the host.
+            -v, --volume=[]       Bind mount a volume (default [])
             -T                    Disable pseudo-tty allocation. By default `docker-compose run`
             -T                    Disable pseudo-tty allocation. By default `docker-compose run`
                                   allocates a TTY.
                                   allocates a TTY.
             -w, --workdir=""      Working directory inside the container
             -w, --workdir=""      Working directory inside the container
@@ -854,8 +897,11 @@ class TopLevelCommand(object):
                                        running. (default: 10)
                                        running. (default: 10)
             --remove-orphans           Remove containers for services not
             --remove-orphans           Remove containers for services not
                                        defined in the Compose file
                                        defined in the Compose file
+            --exit-code-from SERVICE   Return the exit code of the selected service container.
+                                       Requires --abort-on-container-exit.
         """
         """
         start_deps = not options['--no-deps']
         start_deps = not options['--no-deps']
+        exit_value_from = exitval_from_opts(options, self.project)
         cascade_stop = options['--abort-on-container-exit']
         cascade_stop = options['--abort-on-container-exit']
         service_names = options['SERVICE']
         service_names = options['SERVICE']
         timeout = timeout_from_opts(options)
         timeout = timeout_from_opts(options)
@@ -878,19 +924,50 @@ class TopLevelCommand(object):
             if detached:
             if detached:
                 return
                 return
 
 
+            attached_containers = filter_containers_to_service_names(to_attach, service_names)
+
             log_printer = log_printer_from_project(
             log_printer = log_printer_from_project(
                 self.project,
                 self.project,
-                filter_containers_to_service_names(to_attach, service_names),
+                attached_containers,
                 options['--no-color'],
                 options['--no-color'],
                 {'follow': True},
                 {'follow': True},
                 cascade_stop,
                 cascade_stop,
                 event_stream=self.project.events(service_names=service_names))
                 event_stream=self.project.events(service_names=service_names))
             print("Attaching to", list_containers(log_printer.containers))
             print("Attaching to", list_containers(log_printer.containers))
-            log_printer.run()
+            cascade_starter = log_printer.run()
 
 
             if cascade_stop:
             if cascade_stop:
                 print("Aborting on container exit...")
                 print("Aborting on container exit...")
+
+                exit_code = 0
+                if exit_value_from:
+                    candidates = filter(
+                        lambda c: c.service == exit_value_from,
+                        attached_containers)
+                    if not candidates:
+                        log.error(
+                            'No containers matching the spec "{0}" '
+                            'were run.'.format(exit_value_from)
+                        )
+                        exit_code = 2
+                    elif len(candidates) > 1:
+                        exit_values = filter(
+                            lambda e: e != 0,
+                            [c.inspect()['State']['ExitCode'] for c in candidates]
+                        )
+
+                        exit_code = exit_values[0]
+                    else:
+                        exit_code = candidates[0].inspect()['State']['ExitCode']
+                else:
+                    for e in self.project.containers(service_names=options['SERVICE'], stopped=True):
+                        if (not e.is_running and cascade_starter == e.name):
+                            if not e.exit_code == 0:
+                                exit_code = e.exit_code
+                                break
+
                 self.project.stop(service_names=service_names, timeout=timeout)
                 self.project.stop(service_names=service_names, timeout=timeout)
+                sys.exit(exit_code)
 
 
     @classmethod
     @classmethod
     def version(cls, options):
     def version(cls, options):
@@ -928,6 +1005,58 @@ def timeout_from_opts(options):
     return None if timeout is None else int(timeout)
     return None if timeout is None else int(timeout)
 
 
 
 
+def image_digests_for_project(project, allow_push=False):
+    with errors.handle_connection_errors(project.client):
+        try:
+            return get_image_digests(
+                project,
+                allow_push=allow_push
+            )
+        except MissingDigests as e:
+            def list_images(images):
+                return "\n".join("    {}".format(name) for name in sorted(images))
+
+            paras = ["Some images are missing digests."]
+
+            if e.needs_push:
+                command_hint = (
+                    "Use `docker-compose push {}` to push them. "
+                    .format(" ".join(sorted(e.needs_push)))
+                )
+                paras += [
+                    "The following images can be pushed:",
+                    list_images(e.needs_push),
+                    command_hint,
+                ]
+
+            if e.needs_pull:
+                command_hint = (
+                    "Use `docker-compose pull {}` to pull them. "
+                    .format(" ".join(sorted(e.needs_pull)))
+                )
+
+                paras += [
+                    "The following images need to be pulled:",
+                    list_images(e.needs_pull),
+                    command_hint,
+                ]
+
+            raise UserError("\n\n".join(paras))
+
+
+def exitval_from_opts(options, project):
+    exit_value_from = options.get('--exit-code-from')
+    if exit_value_from:
+        if not options.get('--abort-on-container-exit'):
+            log.warn('using --exit-code-from implies --abort-on-container-exit')
+            options['--abort-on-container-exit'] = True
+        if exit_value_from not in [s.name for s in project.get_services()]:
+            log.error('No service named "%s" was found in your compose file.',
+                      exit_value_from)
+            sys.exit(2)
+    return exit_value_from
+
+
 def image_type_from_opt(flag, value):
 def image_type_from_opt(flag, value):
     if not value:
     if not value:
         return ImageType.none
         return ImageType.none
@@ -984,6 +1113,10 @@ def build_container_options(options, detach, command):
     if options['--workdir']:
     if options['--workdir']:
         container_options['working_dir'] = options['--workdir']
         container_options['working_dir'] = options['--workdir']
 
 
+    if options['--volume']:
+        volumes = [VolumeSpec.parse(i) for i in options['--volume']]
+        container_options['volumes'] = volumes
+
     return container_options
     return container_options
 
 
 
 
@@ -1010,7 +1143,7 @@ def run_one_off_container(container_options, project, service, options):
 
 
     def remove_container(force=False):
     def remove_container(force=False):
         if options['--rm']:
         if options['--rm']:
-            project.client.remove_container(container.id, force=True)
+            project.client.remove_container(container.id, force=True, v=True)
 
 
     signals.set_signal_handler_to_shutdown()
     signals.set_signal_handler_to_shutdown()
     try:
     try:

+ 9 - 0
compose/cli/signals.py

@@ -3,6 +3,8 @@ from __future__ import unicode_literals
 
 
 import signal
 import signal
 
 
+from ..const import IS_WINDOWS_PLATFORM
+
 
 
 class ShutdownException(Exception):
 class ShutdownException(Exception):
     pass
     pass
@@ -19,3 +21,10 @@ def set_signal_handler(handler):
 
 
 def set_signal_handler_to_shutdown():
 def set_signal_handler_to_shutdown():
     set_signal_handler(shutdown)
     set_signal_handler(shutdown)
+
+
+def ignore_sigpipe():
+    # Restore default behavior for SIGPIPE instead of raising
+    # an exception when encountered.
+    if not IS_WINDOWS_PLATFORM:
+        signal.signal(signal.SIGPIPE, signal.SIG_DFL)

+ 13 - 0
compose/cli/utils.py

@@ -2,6 +2,7 @@ from __future__ import absolute_import
 from __future__ import division
 from __future__ import division
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+import math
 import os
 import os
 import platform
 import platform
 import ssl
 import ssl
@@ -135,3 +136,15 @@ def unquote_path(s):
     if s[0] == '"' and s[-1] == '"':
     if s[0] == '"' and s[-1] == '"':
         return s[1:-1]
         return s[1:-1]
     return s
     return s
+
+
+def human_readable_file_size(size):
+    suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', ]
+    order = int(math.log(size, 2) / 10) if size else 0
+    if order >= len(suffixes):
+        order = len(suffixes) - 1
+
+    return '{0:.3g} {1}'.format(
+        size / float(1 << (order * 10)),
+        suffixes[order]
+    )

+ 1 - 0
compose/config/__init__.py

@@ -9,3 +9,4 @@ from .config import find
 from .config import load
 from .config import load
 from .config import merge_environment
 from .config import merge_environment
 from .config import parse_environment
 from .config import parse_environment
+from .config import resolve_build_args

+ 75 - 25
compose/config/config.py

@@ -13,11 +13,8 @@ import yaml
 from cached_property import cached_property
 from cached_property import cached_property
 
 
 from . import types
 from . import types
+from .. import const
 from ..const import COMPOSEFILE_V1 as V1
 from ..const import COMPOSEFILE_V1 as V1
-from ..const import COMPOSEFILE_V2_0 as V2_0
-from ..const import COMPOSEFILE_V2_1 as V2_1
-from ..const import COMPOSEFILE_V3_0 as V3_0
-from ..const import COMPOSEFILE_V3_1 as V3_1
 from ..utils import build_string_dict
 from ..utils import build_string_dict
 from ..utils import parse_nanoseconds_int
 from ..utils import parse_nanoseconds_int
 from ..utils import splitdrive
 from ..utils import splitdrive
@@ -35,6 +32,7 @@ from .sort_services import sort_service_dicts
 from .types import parse_extra_hosts
 from .types import parse_extra_hosts
 from .types import parse_restart_spec
 from .types import parse_restart_spec
 from .types import ServiceLink
 from .types import ServiceLink
+from .types import ServicePort
 from .types import VolumeFromSpec
 from .types import VolumeFromSpec
 from .types import VolumeSpec
 from .types import VolumeSpec
 from .validation import match_named_volumes
 from .validation import match_named_volumes
@@ -61,6 +59,7 @@ DOCKER_CONFIG_KEYS = [
     'devices',
     'devices',
     'dns',
     'dns',
     'dns_search',
     'dns_search',
+    'dns_opt',
     'domainname',
     'domainname',
     'entrypoint',
     'entrypoint',
     'env_file',
     'env_file',
@@ -75,6 +74,7 @@ DOCKER_CONFIG_KEYS = [
     'links',
     'links',
     'mac_address',
     'mac_address',
     'mem_limit',
     'mem_limit',
+    'mem_reservation',
     'memswap_limit',
     'memswap_limit',
     'mem_swappiness',
     'mem_swappiness',
     'net',
     'net',
@@ -87,6 +87,7 @@ DOCKER_CONFIG_KEYS = [
     'secrets',
     'secrets',
     'security_opt',
     'security_opt',
     'shm_size',
     'shm_size',
+    'pids_limit',
     'stdin_open',
     'stdin_open',
     'stop_signal',
     'stop_signal',
     'sysctls',
     'sysctls',
@@ -181,10 +182,10 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
                 .format(self.filename, VERSION_EXPLANATION))
                 .format(self.filename, VERSION_EXPLANATION))
 
 
         if version == '2':
         if version == '2':
-            version = V2_0
+            version = const.COMPOSEFILE_V2_0
 
 
         if version == '3':
         if version == '3':
-            version = V3_0
+            version = const.COMPOSEFILE_V3_0
 
 
         return version
         return version
 
 
@@ -201,7 +202,7 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
         return {} if self.version == V1 else self.config.get('networks', {})
         return {} if self.version == V1 else self.config.get('networks', {})
 
 
     def get_secrets(self):
     def get_secrets(self):
-        return {} if self.version < V3_1 else self.config.get('secrets', {})
+        return {} if self.version < const.COMPOSEFILE_V3_1 else self.config.get('secrets', {})
 
 
 
 
 class Config(namedtuple('_Config', 'version services volumes networks secrets')):
 class Config(namedtuple('_Config', 'version services volumes networks secrets')):
@@ -214,6 +215,8 @@ class Config(namedtuple('_Config', 'version services volumes networks secrets'))
     :type  volumes: :class:`dict`
     :type  volumes: :class:`dict`
     :param networks: Dictionary mapping network names to description dictionaries
     :param networks: Dictionary mapping network names to description dictionaries
     :type  networks: :class:`dict`
     :type  networks: :class:`dict`
+    :param secrets: Dictionary mapping secret names to description dictionaries
+    :type secrets: :class:`dict`
     """
     """
 
 
 
 
@@ -231,10 +234,10 @@ class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name conf
             config)
             config)
 
 
 
 
-def find(base_dir, filenames, environment):
+def find(base_dir, filenames, environment, override_dir='.'):
     if filenames == ['-']:
     if filenames == ['-']:
         return ConfigDetails(
         return ConfigDetails(
-            os.getcwd(),
+            os.path.abspath(override_dir),
             [ConfigFile(None, yaml.safe_load(sys.stdin))],
             [ConfigFile(None, yaml.safe_load(sys.stdin))],
             environment
             environment
         )
         )
@@ -246,7 +249,7 @@ def find(base_dir, filenames, environment):
 
 
     log.debug("Using configuration files: {}".format(",".join(filenames)))
     log.debug("Using configuration files: {}".format(",".join(filenames)))
     return ConfigDetails(
     return ConfigDetails(
-        os.path.dirname(filenames[0]),
+        override_dir or os.path.dirname(filenames[0]),
         [ConfigFile.from_filename(f) for f in filenames],
         [ConfigFile.from_filename(f) for f in filenames],
         environment
         environment
     )
     )
@@ -421,7 +424,7 @@ def load_services(config_details, config_file):
         service_dict = process_service(resolver.run())
         service_dict = process_service(resolver.run())
 
 
         service_config = service_config._replace(config=service_dict)
         service_config = service_config._replace(config=service_dict)
-        validate_service(service_config, service_names, config_file.version)
+        validate_service(service_config, service_names, config_file)
         service_dict = finalize_service(
         service_dict = finalize_service(
             service_config,
             service_config,
             service_names,
             service_names,
@@ -474,7 +477,7 @@ def process_config_file(config_file, environment, service_name=None):
         'service',
         'service',
         environment)
         environment)
 
 
-    if config_file.version in (V2_0, V2_1, V3_0, V3_1):
+    if config_file.version != V1:
         processed_config = dict(config_file.config)
         processed_config = dict(config_file.config)
         processed_config['services'] = services
         processed_config['services'] = services
         processed_config['volumes'] = interpolate_config_section(
         processed_config['volumes'] = interpolate_config_section(
@@ -487,12 +490,14 @@ def process_config_file(config_file, environment, service_name=None):
             config_file.get_networks(),
             config_file.get_networks(),
             'network',
             'network',
             environment)
             environment)
-    elif config_file.version == V1:
-        processed_config = services
+        if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2):
+            processed_config['secrets'] = interpolate_config_section(
+                config_file,
+                config_file.get_secrets(),
+                'secrets',
+                environment)
     else:
     else:
-        raise ConfigurationError(
-            'Version in "{}" is unsupported. {}'
-            .format(config_file.filename, VERSION_EXPLANATION))
+        processed_config = services
 
 
     config_file = config_file._replace(config=processed_config)
     config_file = config_file._replace(config=processed_config)
     validate_against_config_schema(config_file)
     validate_against_config_schema(config_file)
@@ -598,8 +603,8 @@ def resolve_environment(service_dict, environment=None):
     return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env))
     return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env))
 
 
 
 
-def resolve_build_args(build, environment):
-    args = parse_build_arguments(build.get('args'))
+def resolve_build_args(buildargs, environment):
+    args = parse_build_arguments(buildargs)
     return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args))
     return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args))
 
 
 
 
@@ -629,9 +634,9 @@ def validate_extended_service_dict(service_dict, filename, service):
             "%s services with 'depends_on' cannot be extended" % error_prefix)
             "%s services with 'depends_on' cannot be extended" % error_prefix)
 
 
 
 
-def validate_service(service_config, service_names, version):
+def validate_service(service_config, service_names, config_file):
     service_dict, service_name = service_config.config, service_config.name
     service_dict, service_name = service_config.config, service_config.name
-    validate_service_constraints(service_dict, service_name, version)
+    validate_service_constraints(service_dict, service_name, config_file)
     validate_paths(service_dict)
     validate_paths(service_dict)
 
 
     validate_ulimits(service_config)
     validate_ulimits(service_config)
@@ -683,7 +688,22 @@ def process_service(service_config):
             service_dict[field] = to_list(service_dict[field])
             service_dict[field] = to_list(service_dict[field])
 
 
     service_dict = process_healthcheck(service_dict, service_config.name)
     service_dict = process_healthcheck(service_dict, service_config.name)
+    service_dict = process_ports(service_dict)
+
+    return service_dict
+
+
+def process_ports(service_dict):
+    if 'ports' not in service_dict:
+        return service_dict
 
 
+    ports = []
+    for port_definition in service_dict['ports']:
+        if isinstance(port_definition, ServicePort):
+            ports.append(port_definition)
+        else:
+            ports.extend(ServicePort.parse(port_definition))
+    service_dict['ports'] = ports
     return service_dict
     return service_dict
 
 
 
 
@@ -864,7 +884,7 @@ def merge_service_dicts(base, override, version):
         md.merge_field(field, merge_path_mappings)
         md.merge_field(field, merge_path_mappings)
 
 
     for field in [
     for field in [
-        'ports', 'cap_add', 'cap_drop', 'expose', 'external_links',
+        'cap_add', 'cap_drop', 'expose', 'external_links',
         'security_opt', 'volumes_from',
         'security_opt', 'volumes_from',
     ]:
     ]:
         md.merge_field(field, merge_unique_items_lists, default=[])
         md.merge_field(field, merge_unique_items_lists, default=[])
@@ -873,6 +893,7 @@ def merge_service_dicts(base, override, version):
         md.merge_field(field, merge_list_or_string)
         md.merge_field(field, merge_list_or_string)
 
 
     md.merge_field('logging', merge_logging, default={})
     md.merge_field('logging', merge_logging, default={})
+    merge_ports(md, base, override)
 
 
     for field in set(ALLOWED_KEYS) - set(md):
     for field in set(ALLOWED_KEYS) - set(md):
         md.merge_scalar(field)
         md.merge_scalar(field)
@@ -886,9 +907,28 @@ def merge_service_dicts(base, override, version):
 
 
 
 
 def merge_unique_items_lists(base, override):
 def merge_unique_items_lists(base, override):
+    override = [str(o) for o in override]
+    base = [str(b) for b in base]
     return sorted(set().union(base, override))
     return sorted(set().union(base, override))
 
 
 
 
+def merge_ports(md, base, override):
+    def parse_sequence_func(seq):
+        acc = []
+        for item in seq:
+            acc.extend(ServicePort.parse(item))
+        return to_mapping(acc, 'merge_field')
+
+    field = 'ports'
+
+    if not md.needs_merge(field):
+        return
+
+    merged = parse_sequence_func(md.base.get(field, []))
+    merged.update(parse_sequence_func(md.override.get(field, [])))
+    md[field] = [item for item in sorted(merged.values())]
+
+
 def merge_build(output, base, override):
 def merge_build(output, base, override):
     def to_dict(service):
     def to_dict(service):
         build_config = service.get('build', {})
         build_config = service.get('build', {})
@@ -990,7 +1030,13 @@ def resolve_volume_paths(working_dir, service_dict):
 
 
 
 
 def resolve_volume_path(working_dir, volume):
 def resolve_volume_path(working_dir, volume):
-    container_path, host_path = split_path_mapping(volume)
+    if isinstance(volume, dict):
+        host_path = volume.get('source')
+        container_path = volume.get('target')
+        if host_path and volume.get('read_only'):
+            container_path += ':ro'
+    else:
+        container_path, host_path = split_path_mapping(volume)
 
 
     if host_path is not None:
     if host_path is not None:
         if host_path.startswith('.'):
         if host_path.startswith('.'):
@@ -1012,7 +1058,7 @@ def normalize_build(service_dict, working_dir, environment):
             build.update(service_dict['build'])
             build.update(service_dict['build'])
             if 'args' in build:
             if 'args' in build:
                 build['args'] = build_string_dict(
                 build['args'] = build_string_dict(
-                    resolve_build_args(build, environment)
+                    resolve_build_args(build.get('args'), environment)
                 )
                 )
 
 
         service_dict['build'] = build
         service_dict['build'] = build
@@ -1072,6 +1118,8 @@ def split_path_mapping(volume_path):
     path. Using splitdrive so windows absolute paths won't cause issues with
     path. Using splitdrive so windows absolute paths won't cause issues with
     splitting on ':'.
     splitting on ':'.
     """
     """
+    if isinstance(volume_path, dict):
+        return (volume_path.get('target'), volume_path)
     drive, volume_config = splitdrive(volume_path)
     drive, volume_config = splitdrive(volume_path)
 
 
     if ':' in volume_config:
     if ':' in volume_config:
@@ -1083,7 +1131,9 @@ def split_path_mapping(volume_path):
 
 
 def join_path_mapping(pair):
 def join_path_mapping(pair):
     (container, host) = pair
     (container, host) = pair
-    if host is None:
+    if isinstance(host, dict):
+        return host
+    elif host is None:
         return container
         return container
     else:
     else:
         return ":".join((host, container))
         return ":".join((host, container))

+ 9 - 1
compose/config/config_schema_v2.0.json

@@ -80,6 +80,13 @@
         "depends_on": {"$ref": "#/definitions/list_of_strings"},
         "depends_on": {"$ref": "#/definitions/list_of_strings"},
         "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
         "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
         "dns": {"$ref": "#/definitions/string_or_list"},
         "dns": {"$ref": "#/definitions/string_or_list"},
+        "dns_opt": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true
+        },
         "dns_search": {"$ref": "#/definitions/string_or_list"},
         "dns_search": {"$ref": "#/definitions/string_or_list"},
         "domainname": {"type": "string"},
         "domainname": {"type": "string"},
         "entrypoint": {
         "entrypoint": {
@@ -138,8 +145,9 @@
 
 
         "mac_address": {"type": "string"},
         "mac_address": {"type": "string"},
         "mem_limit": {"type": ["number", "string"]},
         "mem_limit": {"type": ["number", "string"]},
-        "memswap_limit": {"type": ["number", "string"]},
+        "mem_reservation": {"type": ["string", "integer"]},
         "mem_swappiness": {"type": "integer"},
         "mem_swappiness": {"type": "integer"},
+        "memswap_limit": {"type": ["number", "string"]},
         "network_mode": {"type": "string"},
         "network_mode": {"type": "string"},
 
 
         "networks": {
         "networks": {

+ 10 - 1
compose/config/config_schema_v2.1.json

@@ -100,6 +100,13 @@
           ]
           ]
         },
         },
         "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
         "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "dns_opt": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "uniqueItems": true
+        },
         "dns": {"$ref": "#/definitions/string_or_list"},
         "dns": {"$ref": "#/definitions/string_or_list"},
         "dns_search": {"$ref": "#/definitions/string_or_list"},
         "dns_search": {"$ref": "#/definitions/string_or_list"},
         "domainname": {"type": "string"},
         "domainname": {"type": "string"},
@@ -161,8 +168,9 @@
 
 
         "mac_address": {"type": "string"},
         "mac_address": {"type": "string"},
         "mem_limit": {"type": ["number", "string"]},
         "mem_limit": {"type": ["number", "string"]},
-        "memswap_limit": {"type": ["number", "string"]},
+        "mem_reservation": {"type": ["string", "integer"]},
         "mem_swappiness": {"type": "integer"},
         "mem_swappiness": {"type": "integer"},
+        "memswap_limit": {"type": ["number", "string"]},
         "network_mode": {"type": "string"},
         "network_mode": {"type": "string"},
 
 
         "networks": {
         "networks": {
@@ -216,6 +224,7 @@
         "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
         "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
         "shm_size": {"type": ["number", "string"]},
         "shm_size": {"type": ["number", "string"]},
         "sysctls": {"$ref": "#/definitions/list_or_dict"},
         "sysctls": {"$ref": "#/definitions/list_or_dict"},
+        "pids_limit": {"type": ["number", "string"]},
         "stdin_open": {"type": "boolean"},
         "stdin_open": {"type": "boolean"},
         "stop_grace_period": {"type": "string", "format": "duration"},
         "stop_grace_period": {"type": "string", "format": "duration"},
         "stop_signal": {"type": "string"},
         "stop_signal": {"type": "string"},

+ 472 - 0
compose/config/config_schema_v3.2.json

@@ -0,0 +1,472 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "id": "config_schema_v3.2.json",
+  "type": "object",
+  "required": ["version"],
+
+  "properties": {
+    "version": {
+      "type": "string"
+    },
+
+    "services": {
+      "id": "#/properties/services",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/service"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "networks": {
+      "id": "#/properties/networks",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/network"
+        }
+      }
+    },
+
+    "volumes": {
+      "id": "#/properties/volumes",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/volume"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "secrets": {
+      "id": "#/properties/secrets",
+      "type": "object",
+      "patternProperties": {
+        "^[a-zA-Z0-9._-]+$": {
+          "$ref": "#/definitions/secret"
+        }
+      },
+      "additionalProperties": false
+    }
+  },
+
+  "additionalProperties": false,
+
+  "definitions": {
+
+    "service": {
+      "id": "#/definitions/service",
+      "type": "object",
+
+      "properties": {
+        "deploy": {"$ref": "#/definitions/deployment"},
+        "build": {
+          "oneOf": [
+            {"type": "string"},
+            {
+              "type": "object",
+              "properties": {
+                "context": {"type": "string"},
+                "dockerfile": {"type": "string"},
+                "args": {"$ref": "#/definitions/list_or_dict"},
+                "cache_from": {"$ref": "#/definitions/list_of_strings"}
+              },
+              "additionalProperties": false
+            }
+          ]
+        },
+        "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "cgroup_parent": {"type": "string"},
+        "command": {
+          "oneOf": [
+            {"type": "string"},
+            {"type": "array", "items": {"type": "string"}}
+          ]
+        },
+        "container_name": {"type": "string"},
+        "depends_on": {"$ref": "#/definitions/list_of_strings"},
+        "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "dns": {"$ref": "#/definitions/string_or_list"},
+        "dns_search": {"$ref": "#/definitions/string_or_list"},
+        "domainname": {"type": "string"},
+        "entrypoint": {
+          "oneOf": [
+            {"type": "string"},
+            {"type": "array", "items": {"type": "string"}}
+          ]
+        },
+        "env_file": {"$ref": "#/definitions/string_or_list"},
+        "environment": {"$ref": "#/definitions/list_or_dict"},
+
+        "expose": {
+          "type": "array",
+          "items": {
+            "type": ["string", "number"],
+            "format": "expose"
+          },
+          "uniqueItems": true
+        },
+
+        "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "extra_hosts": {"$ref": "#/definitions/list_or_dict"},
+        "healthcheck": {"$ref": "#/definitions/healthcheck"},
+        "hostname": {"type": "string"},
+        "image": {"type": "string"},
+        "ipc": {"type": "string"},
+        "labels": {"$ref": "#/definitions/list_or_dict"},
+        "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+
+        "logging": {
+            "type": "object",
+
+            "properties": {
+                "driver": {"type": "string"},
+                "options": {
+                  "type": "object",
+                  "patternProperties": {
+                    "^.+$": {"type": ["string", "number", "null"]}
+                  }
+                }
+            },
+            "additionalProperties": false
+        },
+
+        "mac_address": {"type": "string"},
+        "network_mode": {"type": "string"},
+
+        "networks": {
+          "oneOf": [
+            {"$ref": "#/definitions/list_of_strings"},
+            {
+              "type": "object",
+              "patternProperties": {
+                "^[a-zA-Z0-9._-]+$": {
+                  "oneOf": [
+                    {
+                      "type": "object",
+                      "properties": {
+                        "aliases": {"$ref": "#/definitions/list_of_strings"},
+                        "ipv4_address": {"type": "string"},
+                        "ipv6_address": {"type": "string"}
+                      },
+                      "additionalProperties": false
+                    },
+                    {"type": "null"}
+                  ]
+                }
+              },
+              "additionalProperties": false
+            }
+          ]
+        },
+        "pid": {"type": ["string", "null"]},
+
+        "ports": {
+          "type": "array",
+          "items": {
+            "oneOf": [
+              {"type": ["string", "number"], "format": "ports"},
+              {
+                "type": "object",
+                "properties": {
+                  "mode": {"type": "string"},
+                  "target": {"type": "integer"},
+                  "published": {"type": "integer"},
+                  "protocol": {"type": "string"}
+                },
+                "additionalProperties": false
+              }
+            ]
+          },
+          "uniqueItems": true
+        },
+
+        "privileged": {"type": "boolean"},
+        "read_only": {"type": "boolean"},
+        "restart": {"type": "string"},
+        "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
+        "shm_size": {"type": ["number", "string"]},
+        "secrets": {
+          "type": "array",
+          "items": {
+            "oneOf": [
+              {"type": "string"},
+              {
+                "type": "object",
+                "properties": {
+                  "source": {"type": "string"},
+                  "target": {"type": "string"},
+                  "uid": {"type": "string"},
+                  "gid": {"type": "string"},
+                  "mode": {"type": "number"}
+                }
+              }
+            ]
+          }
+        },
+        "sysctls": {"$ref": "#/definitions/list_or_dict"},
+        "stdin_open": {"type": "boolean"},
+        "stop_grace_period": {"type": "string", "format": "duration"},
+        "stop_signal": {"type": "string"},
+        "tmpfs": {"$ref": "#/definitions/string_or_list"},
+        "tty": {"type": "boolean"},
+        "ulimits": {
+          "type": "object",
+          "patternProperties": {
+            "^[a-z]+$": {
+              "oneOf": [
+                {"type": "integer"},
+                {
+                  "type":"object",
+                  "properties": {
+                    "hard": {"type": "integer"},
+                    "soft": {"type": "integer"}
+                  },
+                  "required": ["soft", "hard"],
+                  "additionalProperties": false
+                }
+              ]
+            }
+          }
+        },
+        "user": {"type": "string"},
+        "userns_mode": {"type": "string"},
+        "volumes": {
+          "type": "array",
+          "items": {
+            "oneOf": [
+              {"type": "string"},
+              {
+                "type": "object",
+                "required": ["type"],
+                "properties": {
+                  "type": {"type": "string"},
+                  "source": {"type": "string"},
+                  "target": {"type": "string"},
+                  "read_only": {"type": "boolean"},
+                  "bind": {
+                    "type": "object",
+                    "properties": {
+                      "propagation": {"type": "string"}
+                    }
+                  },
+                  "volume": {
+                    "type": "object",
+                    "properties": {
+                      "nocopy": {"type": "boolean"}
+                    }
+                  }
+                }
+              }
+            ],
+            "uniqueItems": true
+          }
+        },
+        "working_dir": {"type": "string"}
+      },
+      "additionalProperties": false
+    },
+
+    "healthcheck": {
+      "id": "#/definitions/healthcheck",
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "disable": {"type": "boolean"},
+        "interval": {"type": "string"},
+        "retries": {"type": "number"},
+        "test": {
+          "oneOf": [
+            {"type": "string"},
+            {"type": "array", "items": {"type": "string"}}
+          ]
+        },
+        "timeout": {"type": "string"}
+      }
+    },
+    "deployment": {
+      "id": "#/definitions/deployment",
+      "type": ["object", "null"],
+      "properties": {
+        "mode": {"type": "string"},
+        "endpoint_mode": {"type": "string"},
+        "replicas": {"type": "integer"},
+        "labels": {"$ref": "#/definitions/list_or_dict"},
+        "update_config": {
+          "type": "object",
+          "properties": {
+            "parallelism": {"type": "integer"},
+            "delay": {"type": "string", "format": "duration"},
+            "failure_action": {"type": "string"},
+            "monitor": {"type": "string", "format": "duration"},
+            "max_failure_ratio": {"type": "number"}
+          },
+          "additionalProperties": false
+        },
+        "resources": {
+          "type": "object",
+          "properties": {
+            "limits": {"$ref": "#/definitions/resource"},
+            "reservations": {"$ref": "#/definitions/resource"}
+          }
+        },
+        "restart_policy": {
+          "type": "object",
+          "properties": {
+            "condition": {"type": "string"},
+            "delay": {"type": "string", "format": "duration"},
+            "max_attempts": {"type": "integer"},
+            "window": {"type": "string", "format": "duration"}
+          },
+          "additionalProperties": false
+        },
+        "placement": {
+          "type": "object",
+          "properties": {
+            "constraints": {"type": "array", "items": {"type": "string"}}
+          },
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "resource": {
+      "id": "#/definitions/resource",
+      "type": "object",
+      "properties": {
+        "cpus": {"type": "string"},
+        "memory": {"type": "string"}
+      },
+      "additionalProperties": false
+    },
+
+    "network": {
+      "id": "#/definitions/network",
+      "type": ["object", "null"],
+      "properties": {
+        "driver": {"type": "string"},
+        "driver_opts": {
+          "type": "object",
+          "patternProperties": {
+            "^.+$": {"type": ["string", "number"]}
+          }
+        },
+        "ipam": {
+          "type": "object",
+          "properties": {
+            "driver": {"type": "string"},
+            "config": {
+              "type": "array",
+              "items": {
+                "type": "object",
+                "properties": {
+                  "subnet": {"type": "string"}
+                },
+                "additionalProperties": false
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        "external": {
+          "type": ["boolean", "object"],
+          "properties": {
+            "name": {"type": "string"}
+          },
+          "additionalProperties": false
+        },
+        "internal": {"type": "boolean"},
+        "attachable": {"type": "boolean"},
+        "labels": {"$ref": "#/definitions/list_or_dict"}
+      },
+      "additionalProperties": false
+    },
+
+    "volume": {
+      "id": "#/definitions/volume",
+      "type": ["object", "null"],
+      "properties": {
+        "driver": {"type": "string"},
+        "driver_opts": {
+          "type": "object",
+          "patternProperties": {
+            "^.+$": {"type": ["string", "number"]}
+          }
+        },
+        "external": {
+          "type": ["boolean", "object"],
+          "properties": {
+            "name": {"type": "string"}
+          },
+          "additionalProperties": false
+        },
+        "labels": {"$ref": "#/definitions/list_or_dict"}
+      },
+      "additionalProperties": false
+    },
+
+    "secret": {
+      "id": "#/definitions/secret",
+      "type": "object",
+      "properties": {
+        "file": {"type": "string"},
+        "external": {
+          "type": ["boolean", "object"],
+          "properties": {
+            "name": {"type": "string"}
+          }
+        },
+        "labels": {"$ref": "#/definitions/list_or_dict"}
+      },
+      "additionalProperties": false
+    },
+
+    "string_or_list": {
+      "oneOf": [
+        {"type": "string"},
+        {"$ref": "#/definitions/list_of_strings"}
+      ]
+    },
+
+    "list_of_strings": {
+      "type": "array",
+      "items": {"type": "string"},
+      "uniqueItems": true
+    },
+
+    "list_or_dict": {
+      "oneOf": [
+        {
+          "type": "object",
+          "patternProperties": {
+            ".+": {
+              "type": ["string", "number", "null"]
+            }
+          },
+          "additionalProperties": false
+        },
+        {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
+      ]
+    },
+
+    "constraints": {
+      "service": {
+        "id": "#/definitions/constraints/service",
+        "anyOf": [
+          {"required": ["build"]},
+          {"required": ["image"]}
+        ],
+        "properties": {
+          "build": {
+            "required": ["context"]
+          }
+        }
+      }
+    }
+  }
+}

+ 1 - 1
compose/config/errors.py

@@ -4,7 +4,7 @@ from __future__ import unicode_literals
 
 
 VERSION_EXPLANATION = (
 VERSION_EXPLANATION = (
     'You might be seeing this error because you\'re using the wrong Compose file version. '
     'You might be seeing this error because you\'re using the wrong Compose file version. '
-    'Either specify a supported version ("2.0", "2.1", "3.0") and place your '
+    'Either specify a supported version ("2.0", "2.1", "3.0", "3.1") and place your '
     'service definitions under the `services` key, or omit the `version` key '
     'service definitions under the `services` key, or omit the `version` key '
     'and place your service definitions at the root of the file to use '
     'and place your service definitions at the root of the file to use '
     'version 1.\nFor more on the Compose file format versions, see '
     'version 1.\nFor more on the Compose file format versions, see '

+ 35 - 24
compose/config/serialize.py

@@ -5,8 +5,10 @@ import six
 import yaml
 import yaml
 
 
 from compose.config import types
 from compose.config import types
-from compose.config.config import V1
-from compose.config.config import V2_1
+from compose.const import COMPOSEFILE_V1 as V1
+from compose.const import COMPOSEFILE_V2_1 as V2_1
+from compose.const import COMPOSEFILE_V3_1 as V3_1
+from compose.const import COMPOSEFILE_V3_1 as V3_2
 
 
 
 
 def serialize_config_type(dumper, data):
 def serialize_config_type(dumper, data):
@@ -14,44 +16,47 @@ def serialize_config_type(dumper, data):
     return representer(data.repr())
     return representer(data.repr())
 
 
 
 
+def serialize_dict_type(dumper, data):
+    return dumper.represent_dict(data.repr())
+
+
 yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type)
 yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type)
 yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type)
 yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type)
+yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type)
+yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type)
 
 
 
 
-def denormalize_config(config):
+def denormalize_config(config, image_digests=None):
+    result = {'version': V2_1 if config.version == V1 else config.version}
     denormalized_services = [
     denormalized_services = [
-        denormalize_service_dict(service_dict, config.version)
+        denormalize_service_dict(
+            service_dict,
+            config.version,
+            image_digests[service_dict['name']] if image_digests else None)
         for service_dict in config.services
         for service_dict in config.services
     ]
     ]
-    services = {
+    result['services'] = {
         service_dict.pop('name'): service_dict
         service_dict.pop('name'): service_dict
         for service_dict in denormalized_services
         for service_dict in denormalized_services
     }
     }
-    networks = config.networks.copy()
-    for net_name, net_conf in networks.items():
+    result['networks'] = config.networks.copy()
+    for net_name, net_conf in result['networks'].items():
         if 'external_name' in net_conf:
         if 'external_name' in net_conf:
             del net_conf['external_name']
             del net_conf['external_name']
 
 
-    volumes = config.volumes.copy()
-    for vol_name, vol_conf in volumes.items():
+    result['volumes'] = config.volumes.copy()
+    for vol_name, vol_conf in result['volumes'].items():
         if 'external_name' in vol_conf:
         if 'external_name' in vol_conf:
             del vol_conf['external_name']
             del vol_conf['external_name']
 
 
-    version = config.version
-    if version == V1:
-        version = V2_1
-
-    return {
-        'version': version,
-        'services': services,
-        'networks': networks,
-        'volumes': volumes,
-    }
+    if config.version in (V3_1, V3_2):
+        result['secrets'] = config.secrets
+    return result
 
 
 
 
-def serialize_config(config):
+def serialize_config(config, image_digests=None):
     return yaml.safe_dump(
     return yaml.safe_dump(
-        denormalize_config(config),
+        denormalize_config(config, image_digests),
         default_flow_style=False,
         default_flow_style=False,
         indent=2,
         indent=2,
         width=80)
         width=80)
@@ -76,9 +81,12 @@ def serialize_ns_time_value(value):
     return '{0}{1}'.format(*result)
     return '{0}{1}'.format(*result)
 
 
 
 
-def denormalize_service_dict(service_dict, version):
+def denormalize_service_dict(service_dict, version, image_digest=None):
     service_dict = service_dict.copy()
     service_dict = service_dict.copy()
 
 
+    if image_digest:
+        service_dict['image'] = image_digest
+
     if 'restart' in service_dict:
     if 'restart' in service_dict:
         service_dict['restart'] = types.serialize_restart_spec(
         service_dict['restart'] = types.serialize_restart_spec(
             service_dict['restart']
             service_dict['restart']
@@ -102,7 +110,10 @@ def denormalize_service_dict(service_dict, version):
                 service_dict['healthcheck']['timeout']
                 service_dict['healthcheck']['timeout']
             )
             )
 
 
-    if 'secrets' in service_dict:
-        service_dict['secrets'] = map(lambda s: s.repr(), service_dict['secrets'])
+    if 'ports' in service_dict and version not in (V3_2,):
+        service_dict['ports'] = map(
+            lambda p: p.legacy_repr() if isinstance(p, types.ServicePort) else p,
+            service_dict['ports']
+        )
 
 
     return service_dict
     return service_dict

+ 61 - 1
compose/config/types.py

@@ -9,6 +9,7 @@ import re
 from collections import namedtuple
 from collections import namedtuple
 
 
 import six
 import six
+from docker.utils.ports import build_port_bindings
 
 
 from ..const import COMPOSEFILE_V1 as V1
 from ..const import COMPOSEFILE_V1 as V1
 from .errors import ConfigurationError
 from .errors import ConfigurationError
@@ -203,7 +204,8 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')):
 
 
     def repr(self):
     def repr(self):
         external = self.external + ':' if self.external else ''
         external = self.external + ':' if self.external else ''
-        return '{ext}{v.internal}:{v.mode}'.format(ext=external, v=self)
+        mode = ':' + self.mode if self.external else ''
+        return '{ext}{v.internal}{mode}'.format(mode=mode, ext=external, v=self)
 
 
     @property
     @property
     def is_named_volume(self):
     def is_named_volume(self):
@@ -258,3 +260,61 @@ class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')):
         return dict(
         return dict(
             [(k, v) for k, v in self._asdict().items() if v is not None]
             [(k, v) for k, v in self._asdict().items() if v is not None]
         )
         )
+
+
+class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')):
+
+    @classmethod
+    def parse(cls, spec):
+        if not isinstance(spec, dict):
+            result = []
+            for k, v in build_port_bindings([spec]).items():
+                if '/' in k:
+                    target, proto = k.split('/', 1)
+                else:
+                    target, proto = (k, None)
+                for pub in v:
+                    if pub is None:
+                        result.append(
+                            cls(target, None, proto, None, None)
+                        )
+                    elif isinstance(pub, tuple):
+                        result.append(
+                            cls(target, pub[1], proto, None, pub[0])
+                        )
+                    else:
+                        result.append(
+                            cls(target, pub, proto, None, None)
+                        )
+            return result
+
+        return [cls(
+            spec.get('target'),
+            spec.get('published'),
+            spec.get('protocol'),
+            spec.get('mode'),
+            None
+        )]
+
+    @property
+    def merge_field(self):
+        return (self.target, self.published)
+
+    def repr(self):
+        return dict(
+            [(k, v) for k, v in self._asdict().items() if v is not None]
+        )
+
+    def legacy_repr(self):
+        return normalize_port_dict(self.repr())
+
+
+def normalize_port_dict(port):
+    return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format(
+        published=port.get('published', ''),
+        is_pub=(':' if port.get('published') else ''),
+        target=port.get('target'),
+        protocol=port.get('protocol', 'tcp'),
+        external_ip=port.get('external_ip', ''),
+        has_ext_ip=(':' if port.get('external_ip') else ''),
+    )

+ 18 - 9
compose/config/validation.py

@@ -211,9 +211,12 @@ def handle_error_for_schema_with_id(error, path):
 
 
     if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties':
     if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties':
         return "Invalid service name '{}' - only {} characters are allowed".format(
         return "Invalid service name '{}' - only {} characters are allowed".format(
-            # The service_name is the key to the json object
-            list(error.instance)[0],
-            VALID_NAME_CHARS)
+            # The service_name is one of the keys in the json object
+            [i for i in list(error.instance) if not i or any(filter(
+                lambda c: not re.match(VALID_NAME_CHARS, c), i
+            ))][0],
+            VALID_NAME_CHARS
+        )
 
 
     if error.validator == 'additionalProperties':
     if error.validator == 'additionalProperties':
         if schema_id == '#/definitions/service':
         if schema_id == '#/definitions/service':
@@ -362,7 +365,7 @@ def process_config_schema_errors(error):
 
 
 
 
 def validate_against_config_schema(config_file):
 def validate_against_config_schema(config_file):
-    schema = load_jsonschema(config_file.version)
+    schema = load_jsonschema(config_file)
     format_checker = FormatChecker(["ports", "expose"])
     format_checker = FormatChecker(["ports", "expose"])
     validator = Draft4Validator(
     validator = Draft4Validator(
         schema,
         schema,
@@ -374,11 +377,12 @@ def validate_against_config_schema(config_file):
         config_file.filename)
         config_file.filename)
 
 
 
 
-def validate_service_constraints(config, service_name, version):
+def validate_service_constraints(config, service_name, config_file):
     def handler(errors):
     def handler(errors):
-        return process_service_constraint_errors(errors, service_name, version)
+        return process_service_constraint_errors(
+            errors, service_name, config_file.version)
 
 
-    schema = load_jsonschema(version)
+    schema = load_jsonschema(config_file)
     validator = Draft4Validator(schema['definitions']['constraints']['service'])
     validator = Draft4Validator(schema['definitions']['constraints']['service'])
     handle_errors(validator.iter_errors(config), handler, None)
     handle_errors(validator.iter_errors(config), handler, None)
 
 
@@ -387,10 +391,15 @@ def get_schema_path():
     return os.path.dirname(os.path.abspath(__file__))
     return os.path.dirname(os.path.abspath(__file__))
 
 
 
 
-def load_jsonschema(version):
+def load_jsonschema(config_file):
     filename = os.path.join(
     filename = os.path.join(
         get_schema_path(),
         get_schema_path(),
-        "config_schema_v{0}.json".format(version))
+        "config_schema_v{0}.json".format(config_file.version))
+
+    if not os.path.exists(filename):
+        raise ConfigurationError(
+            'Version in "{}" is unsupported. {}'
+            .format(config_file.filename, VERSION_EXPLANATION))
 
 
     with open(filename, "r") as fh:
     with open(filename, "r") as fh:
         return json.load(fh)
         return json.load(fh)

+ 4 - 0
compose/const.py

@@ -21,8 +21,10 @@ SECRETS_PATH = '/run/secrets'
 COMPOSEFILE_V1 = '1'
 COMPOSEFILE_V1 = '1'
 COMPOSEFILE_V2_0 = '2.0'
 COMPOSEFILE_V2_0 = '2.0'
 COMPOSEFILE_V2_1 = '2.1'
 COMPOSEFILE_V2_1 = '2.1'
+
 COMPOSEFILE_V3_0 = '3.0'
 COMPOSEFILE_V3_0 = '3.0'
 COMPOSEFILE_V3_1 = '3.1'
 COMPOSEFILE_V3_1 = '3.1'
+COMPOSEFILE_V3_2 = '3.2'
 
 
 API_VERSIONS = {
 API_VERSIONS = {
     COMPOSEFILE_V1: '1.21',
     COMPOSEFILE_V1: '1.21',
@@ -30,6 +32,7 @@ API_VERSIONS = {
     COMPOSEFILE_V2_1: '1.24',
     COMPOSEFILE_V2_1: '1.24',
     COMPOSEFILE_V3_0: '1.25',
     COMPOSEFILE_V3_0: '1.25',
     COMPOSEFILE_V3_1: '1.25',
     COMPOSEFILE_V3_1: '1.25',
+    COMPOSEFILE_V3_2: '1.25',
 }
 }
 
 
 API_VERSION_TO_ENGINE_VERSION = {
 API_VERSION_TO_ENGINE_VERSION = {
@@ -38,4 +41,5 @@ API_VERSION_TO_ENGINE_VERSION = {
     API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0',
     API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0',
     API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0',
     API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0',
     API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0',
     API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0',
+    API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0',
 }
 }

+ 50 - 8
compose/network.py

@@ -126,22 +126,64 @@ def create_ipam_config_from_dict(ipam_dict):
     )
     )
 
 
 
 
+class NetworkConfigChangedError(ConfigurationError):
+    def __init__(self, net_name, property_name):
+        super(NetworkConfigChangedError, self).__init__(
+            'Network "{}" needs to be recreated - {} has changed'.format(
+                net_name, property_name
+            )
+        )
+
+
+def check_remote_ipam_config(remote, local):
+    remote_ipam = remote.get('IPAM')
+    ipam_dict = create_ipam_config_from_dict(local.ipam)
+    if local.ipam.get('driver') and local.ipam.get('driver') != remote_ipam.get('Driver'):
+        raise NetworkConfigChangedError(local.full_name, 'IPAM driver')
+    if len(ipam_dict['Config']) != 0:
+        if len(ipam_dict['Config']) != len(remote_ipam['Config']):
+            raise NetworkConfigChangedError(local.full_name, 'IPAM configs')
+        remote_configs = sorted(remote_ipam['Config'], key='Subnet')
+        local_configs = sorted(ipam_dict['Config'], key='Subnet')
+        while local_configs:
+            lc = local_configs.pop()
+            rc = remote_configs.pop()
+            if lc.get('Subnet') != rc.get('Subnet'):
+                raise NetworkConfigChangedError(local.full_name, 'IPAM config subnet')
+            if lc.get('Gateway') is not None and lc.get('Gateway') != rc.get('Gateway'):
+                raise NetworkConfigChangedError(local.full_name, 'IPAM config gateway')
+            if lc.get('IPRange') != rc.get('IPRange'):
+                raise NetworkConfigChangedError(local.full_name, 'IPAM config ip_range')
+            if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')):
+                raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses')
+
+
 def check_remote_network_config(remote, local):
 def check_remote_network_config(remote, local):
     if local.driver and remote.get('Driver') != local.driver:
     if local.driver and remote.get('Driver') != local.driver:
-        raise ConfigurationError(
-            'Network "{}" needs to be recreated - driver has changed'
-            .format(local.full_name)
-        )
+        raise NetworkConfigChangedError(local.full_name, 'driver')
     local_opts = local.driver_opts or {}
     local_opts = local.driver_opts or {}
     remote_opts = remote.get('Options') or {}
     remote_opts = remote.get('Options') or {}
     for k in set.union(set(remote_opts.keys()), set(local_opts.keys())):
     for k in set.union(set(remote_opts.keys()), set(local_opts.keys())):
         if k in OPTS_EXCEPTIONS:
         if k in OPTS_EXCEPTIONS:
             continue
             continue
         if remote_opts.get(k) != local_opts.get(k):
         if remote_opts.get(k) != local_opts.get(k):
-            raise ConfigurationError(
-                'Network "{}" needs to be recreated - options have changed'
-                .format(local.full_name)
-            )
+            raise NetworkConfigChangedError(local.full_name, 'option "{}"'.format(k))
+
+    if local.ipam is not None:
+        check_remote_ipam_config(remote, local)
+
+    if local.internal is not None and local.internal != remote.get('Internal', False):
+        raise NetworkConfigChangedError(local.full_name, 'internal')
+    if local.enable_ipv6 is not None and local.enable_ipv6 != remote.get('EnableIPv6', False):
+        raise NetworkConfigChangedError(local.full_name, 'enable_ipv6')
+
+    local_labels = local.labels or {}
+    remote_labels = remote.get('Labels', {})
+    for k in set.union(set(remote_labels.keys()), set(local_labels.keys())):
+        if k.startswith('com.docker.compose.'):  # We are only interested in user-specified labels
+            continue
+        if remote_labels.get(k) != local_labels.get(k):
+            raise NetworkConfigChangedError(local.full_name, 'label "{}"'.format(k))
 
 
 
 
 def build_networks(name, config_data, client):
 def build_networks(name, config_data, client):

+ 34 - 17
compose/parallel.py

@@ -4,6 +4,7 @@ from __future__ import unicode_literals
 import logging
 import logging
 import operator
 import operator
 import sys
 import sys
+from threading import Semaphore
 from threading import Thread
 from threading import Thread
 
 
 from docker.errors import APIError
 from docker.errors import APIError
@@ -11,6 +12,8 @@ from six.moves import _thread as thread
 from six.moves.queue import Empty
 from six.moves.queue import Empty
 from six.moves.queue import Queue
 from six.moves.queue import Queue
 
 
+from compose.cli.colors import green
+from compose.cli.colors import red
 from compose.cli.signals import ShutdownException
 from compose.cli.signals import ShutdownException
 from compose.errors import HealthCheckFailed
 from compose.errors import HealthCheckFailed
 from compose.errors import NoHealthCheckConfigured
 from compose.errors import NoHealthCheckConfigured
@@ -23,7 +26,7 @@ log = logging.getLogger(__name__)
 STOP = object()
 STOP = object()
 
 
 
 
-def parallel_execute(objects, func, get_name, msg, get_deps=None):
+def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None):
     """Runs func on objects in parallel while ensuring that func is
     """Runs func on objects in parallel while ensuring that func is
     ran on object only after it is ran on all its dependencies.
     ran on object only after it is ran on all its dependencies.
 
 
@@ -37,7 +40,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None):
     for obj in objects:
     for obj in objects:
         writer.initialize(get_name(obj))
         writer.initialize(get_name(obj))
 
 
-    events = parallel_execute_iter(objects, func, get_deps)
+    events = parallel_execute_iter(objects, func, get_deps, limit)
 
 
     errors = {}
     errors = {}
     results = []
     results = []
@@ -45,16 +48,16 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None):
 
 
     for obj, result, exception in events:
     for obj, result, exception in events:
         if exception is None:
         if exception is None:
-            writer.write(get_name(obj), 'done')
+            writer.write(get_name(obj), green('done'))
             results.append(result)
             results.append(result)
         elif isinstance(exception, APIError):
         elif isinstance(exception, APIError):
             errors[get_name(obj)] = exception.explanation
             errors[get_name(obj)] = exception.explanation
-            writer.write(get_name(obj), 'error')
+            writer.write(get_name(obj), red('error'))
         elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)):
         elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)):
             errors[get_name(obj)] = exception.msg
             errors[get_name(obj)] = exception.msg
-            writer.write(get_name(obj), 'error')
+            writer.write(get_name(obj), red('error'))
         elif isinstance(exception, UpstreamError):
         elif isinstance(exception, UpstreamError):
-            writer.write(get_name(obj), 'error')
+            writer.write(get_name(obj), red('error'))
         else:
         else:
             errors[get_name(obj)] = exception
             errors[get_name(obj)] = exception
             error_to_reraise = exception
             error_to_reraise = exception
@@ -94,7 +97,15 @@ class State(object):
         return set(self.objects) - self.started - self.finished - self.failed
         return set(self.objects) - self.started - self.finished - self.failed
 
 
 
 
-def parallel_execute_iter(objects, func, get_deps):
+class NoLimit(object):
+    def __enter__(self):
+        pass
+
+    def __exit__(self, *ex):
+        pass
+
+
+def parallel_execute_iter(objects, func, get_deps, limit):
     """
     """
     Runs func on objects in parallel while ensuring that func is
     Runs func on objects in parallel while ensuring that func is
     ran on object only after it is ran on all its dependencies.
     ran on object only after it is ran on all its dependencies.
@@ -113,11 +124,16 @@ def parallel_execute_iter(objects, func, get_deps):
     if get_deps is None:
     if get_deps is None:
         get_deps = _no_deps
         get_deps = _no_deps
 
 
+    if limit is None:
+        limiter = NoLimit()
+    else:
+        limiter = Semaphore(limit)
+
     results = Queue()
     results = Queue()
     state = State(objects)
     state = State(objects)
 
 
     while True:
     while True:
-        feed_queue(objects, func, get_deps, results, state)
+        feed_queue(objects, func, get_deps, results, state, limiter)
 
 
         try:
         try:
             event = results.get(timeout=0.1)
             event = results.get(timeout=0.1)
@@ -141,19 +157,20 @@ def parallel_execute_iter(objects, func, get_deps):
         yield event
         yield event
 
 
 
 
-def producer(obj, func, results):
+def producer(obj, func, results, limiter):
     """
     """
     The entry point for a producer thread which runs func on a single object.
     The entry point for a producer thread which runs func on a single object.
     Places a tuple on the results queue once func has either returned or raised.
     Places a tuple on the results queue once func has either returned or raised.
     """
     """
-    try:
-        result = func(obj)
-        results.put((obj, result, None))
-    except Exception as e:
-        results.put((obj, None, e))
+    with limiter:
+        try:
+            result = func(obj)
+            results.put((obj, result, None))
+        except Exception as e:
+            results.put((obj, None, e))
 
 
 
 
-def feed_queue(objects, func, get_deps, results, state):
+def feed_queue(objects, func, get_deps, results, state, limiter):
     """
     """
     Starts producer threads for any objects which are ready to be processed
     Starts producer threads for any objects which are ready to be processed
     (i.e. they have no dependencies which haven't been successfully processed).
     (i.e. they have no dependencies which haven't been successfully processed).
@@ -177,7 +194,7 @@ def feed_queue(objects, func, get_deps, results, state):
                 ) for dep, ready_check in deps
                 ) for dep, ready_check in deps
             ):
             ):
                 log.debug('Starting producer thread for {}'.format(obj))
                 log.debug('Starting producer thread for {}'.format(obj))
-                t = Thread(target=producer, args=(obj, func, results))
+                t = Thread(target=producer, args=(obj, func, results, limiter))
                 t.daemon = True
                 t.daemon = True
                 t.start()
                 t.start()
                 state.started.add(obj)
                 state.started.add(obj)
@@ -199,7 +216,7 @@ class UpstreamError(Exception):
 class ParallelStreamWriter(object):
 class ParallelStreamWriter(object):
     """Write out messages for operations happening in parallel.
     """Write out messages for operations happening in parallel.
 
 
-    Each operation has it's own line, and ANSI code characters are used
+    Each operation has its own line, and ANSI code characters are used
     to jump to the correct line, and write over the line.
     to jump to the correct line, and write over the line.
     """
     """
 
 

+ 19 - 6
compose/project.py

@@ -307,10 +307,10 @@ class Project(object):
             'Restarting')
             'Restarting')
         return containers
         return containers
 
 
-    def build(self, service_names=None, no_cache=False, pull=False, force_rm=False):
+    def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None):
         for service in self.get_services(service_names):
         for service in self.get_services(service_names):
             if service.can_be_built():
             if service.can_be_built():
-                service.build(no_cache, pull, force_rm)
+                service.build(no_cache, pull, force_rm, build_args)
             else:
             else:
                 log.info('%s uses an image, skipping' % service.name)
                 log.info('%s uses an image, skipping' % service.name)
 
 
@@ -365,7 +365,7 @@ class Project(object):
 
 
             # TODO: get labels from the API v1.22 , see github issue 2618
             # TODO: get labels from the API v1.22 , see github issue 2618
             try:
             try:
-                # this can fail if the conatiner has been removed
+                # this can fail if the container has been removed
                 container = Container.from_id(self.client, event['id'])
                 container = Container.from_id(self.client, event['id'])
             except APIError:
             except APIError:
                 continue
                 continue
@@ -454,9 +454,22 @@ class Project(object):
 
 
         return plans
         return plans
 
 
-    def pull(self, service_names=None, ignore_pull_failures=False):
-        for service in self.get_services(service_names, include_deps=False):
-            service.pull(ignore_pull_failures)
+    def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False):
+        services = self.get_services(service_names, include_deps=False)
+
+        if parallel_pull:
+            def pull_service(service):
+                service.pull(ignore_pull_failures, True)
+
+            parallel.parallel_execute(
+                services,
+                pull_service,
+                operator.attrgetter('name'),
+                'Pulling',
+                limit=5)
+        else:
+            for service in services:
+                service.pull(ignore_pull_failures)
 
 
     def push(self, service_names=None, ignore_push_failures=False):
     def push(self, service_names=None, ignore_push_failures=False):
         for service in self.get_services(service_names, include_deps=False):
         for service in self.get_services(service_names, include_deps=False):

+ 72 - 16
compose/service.py

@@ -2,6 +2,7 @@ from __future__ import absolute_import
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import logging
 import logging
+import os
 import re
 import re
 import sys
 import sys
 from collections import namedtuple
 from collections import namedtuple
@@ -21,6 +22,8 @@ from . import const
 from . import progress_stream
 from . import progress_stream
 from .config import DOCKER_CONFIG_KEYS
 from .config import DOCKER_CONFIG_KEYS
 from .config import merge_environment
 from .config import merge_environment
+from .config.errors import DependencyError
+from .config.types import ServicePort
 from .config.types import VolumeSpec
 from .config.types import VolumeSpec
 from .const import DEFAULT_TIMEOUT
 from .const import DEFAULT_TIMEOUT
 from .const import IS_WINDOWS_PLATFORM
 from .const import IS_WINDOWS_PLATFORM
@@ -53,6 +56,7 @@ DOCKER_START_KEYS = [
     'devices',
     'devices',
     'dns',
     'dns',
     'dns_search',
     'dns_search',
+    'dns_opt',
     'env_file',
     'env_file',
     'extra_hosts',
     'extra_hosts',
     'group_add',
     'group_add',
@@ -61,10 +65,12 @@ DOCKER_START_KEYS = [
     'log_driver',
     'log_driver',
     'log_opt',
     'log_opt',
     'mem_limit',
     'mem_limit',
+    'mem_reservation',
     'memswap_limit',
     'memswap_limit',
-    'oom_score_adj',
     'mem_swappiness',
     'mem_swappiness',
+    'oom_score_adj',
     'pid',
     'pid',
+    'pids_limit',
     'privileged',
     'privileged',
     'restart',
     'restart',
     'security_opt',
     'security_opt',
@@ -226,9 +232,20 @@ class Service(object):
 
 
             if num_running != len(all_containers):
             if num_running != len(all_containers):
                 # we have some stopped containers, let's start them up again
                 # we have some stopped containers, let's start them up again
+                stopped_containers = [
+                    c for c in all_containers if not c.is_running
+                ]
+
+                # Remove containers that have diverged
+                divergent_containers = [
+                    c for c in stopped_containers if self._containers_have_diverged([c])
+                ]
                 stopped_containers = sorted(
                 stopped_containers = sorted(
-                    (c for c in all_containers if not c.is_running),
-                    key=attrgetter('number'))
+                    set(stopped_containers) - set(divergent_containers),
+                    key=attrgetter('number')
+                )
+                for c in divergent_containers:
+                        c.remove()
 
 
                 num_stopped = len(stopped_containers)
                 num_stopped = len(stopped_containers)
 
 
@@ -682,7 +699,7 @@ class Service(object):
 
 
         if 'ports' in container_options or 'expose' in self.options:
         if 'ports' in container_options or 'expose' in self.options:
             container_options['ports'] = build_container_ports(
             container_options['ports'] = build_container_ports(
-                container_options,
+                formatted_ports(container_options.get('ports', [])),
                 self.options)
                 self.options)
 
 
         container_options['environment'] = merge_environment(
         container_options['environment'] = merge_environment(
@@ -736,18 +753,22 @@ class Service(object):
 
 
         host_config = self.client.create_host_config(
         host_config = self.client.create_host_config(
             links=self._get_links(link_to_self=one_off),
             links=self._get_links(link_to_self=one_off),
-            port_bindings=build_port_bindings(options.get('ports') or []),
+            port_bindings=build_port_bindings(
+                formatted_ports(options.get('ports', []))
+            ),
             binds=options.get('binds'),
             binds=options.get('binds'),
             volumes_from=self._get_volumes_from(),
             volumes_from=self._get_volumes_from(),
             privileged=options.get('privileged', False),
             privileged=options.get('privileged', False),
             network_mode=self.network_mode.mode,
             network_mode=self.network_mode.mode,
             devices=options.get('devices'),
             devices=options.get('devices'),
             dns=options.get('dns'),
             dns=options.get('dns'),
+            dns_opt=options.get('dns_opt'),
             dns_search=options.get('dns_search'),
             dns_search=options.get('dns_search'),
             restart_policy=options.get('restart'),
             restart_policy=options.get('restart'),
             cap_add=options.get('cap_add'),
             cap_add=options.get('cap_add'),
             cap_drop=options.get('cap_drop'),
             cap_drop=options.get('cap_drop'),
             mem_limit=options.get('mem_limit'),
             mem_limit=options.get('mem_limit'),
+            mem_reservation=options.get('mem_reservation'),
             memswap_limit=options.get('memswap_limit'),
             memswap_limit=options.get('memswap_limit'),
             ulimits=build_ulimits(options.get('ulimits')),
             ulimits=build_ulimits(options.get('ulimits')),
             log_config=log_config,
             log_config=log_config,
@@ -760,6 +781,7 @@ class Service(object):
             cpu_quota=options.get('cpu_quota'),
             cpu_quota=options.get('cpu_quota'),
             shm_size=options.get('shm_size'),
             shm_size=options.get('shm_size'),
             sysctls=options.get('sysctls'),
             sysctls=options.get('sysctls'),
+            pids_limit=options.get('pids_limit'),
             tmpfs=options.get('tmpfs'),
             tmpfs=options.get('tmpfs'),
             oom_score_adj=options.get('oom_score_adj'),
             oom_score_adj=options.get('oom_score_adj'),
             mem_swappiness=options.get('mem_swappiness'),
             mem_swappiness=options.get('mem_swappiness'),
@@ -782,13 +804,18 @@ class Service(object):
 
 
         return [build_spec(secret) for secret in self.secrets]
         return [build_spec(secret) for secret in self.secrets]
 
 
-    def build(self, no_cache=False, pull=False, force_rm=False):
+    def build(self, no_cache=False, pull=False, force_rm=False, build_args_override=None):
         log.info('Building %s' % self.name)
         log.info('Building %s' % self.name)
 
 
         build_opts = self.options.get('build', {})
         build_opts = self.options.get('build', {})
-        path = build_opts.get('context')
+
+        build_args = build_opts.get('args', {}).copy()
+        if build_args_override:
+            build_args.update(build_args_override)
+
         # python2 os.stat() doesn't support unicode on some UNIX, so we
         # python2 os.stat() doesn't support unicode on some UNIX, so we
         # encode it to a bytestring to be safe
         # encode it to a bytestring to be safe
+        path = build_opts.get('context')
         if not six.PY3 and not IS_WINDOWS_PLATFORM:
         if not six.PY3 and not IS_WINDOWS_PLATFORM:
             path = path.encode('utf8')
             path = path.encode('utf8')
 
 
@@ -801,7 +828,8 @@ class Service(object):
             pull=pull,
             pull=pull,
             nocache=no_cache,
             nocache=no_cache,
             dockerfile=build_opts.get('dockerfile', None),
             dockerfile=build_opts.get('dockerfile', None),
-            buildargs=build_opts.get('args', None),
+            cache_from=build_opts.get('cache_from', None),
+            buildargs=build_args
         )
         )
 
 
         try:
         try:
@@ -845,7 +873,17 @@ class Service(object):
         if self.custom_container_name and not one_off:
         if self.custom_container_name and not one_off:
             return self.custom_container_name
             return self.custom_container_name
 
 
-        return build_container_name(self.project, self.name, number, one_off)
+        container_name = build_container_name(
+            self.project, self.name, number, one_off,
+        )
+        ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])]
+        if container_name in ext_links_origins:
+            raise DependencyError(
+                'Service {0} has a self-referential external link: {1}'.format(
+                    self.name, container_name
+                )
+            )
+        return container_name
 
 
     def remove_image(self, image_type):
     def remove_image(self, image_type):
         if not image_type or image_type == ImageType.none:
         if not image_type or image_type == ImageType.none:
@@ -863,7 +901,10 @@ class Service(object):
 
 
     def specifies_host_port(self):
     def specifies_host_port(self):
         def has_host_port(binding):
         def has_host_port(binding):
-            _, external_bindings = split_port(binding)
+            if isinstance(binding, dict):
+                external_bindings = binding.get('published')
+            else:
+                _, external_bindings = split_port(binding)
 
 
             # there are no external bindings
             # there are no external bindings
             if external_bindings is None:
             if external_bindings is None:
@@ -885,17 +926,23 @@ class Service(object):
 
 
         return any(has_host_port(binding) for binding in self.options.get('ports', []))
         return any(has_host_port(binding) for binding in self.options.get('ports', []))
 
 
-    def pull(self, ignore_pull_failures=False):
+    def pull(self, ignore_pull_failures=False, silent=False):
         if 'image' not in self.options:
         if 'image' not in self.options:
             return
             return
 
 
         repo, tag, separator = parse_repository_tag(self.options['image'])
         repo, tag, separator = parse_repository_tag(self.options['image'])
         tag = tag or 'latest'
         tag = tag or 'latest'
-        log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag))
+        if not silent:
+            log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag))
         try:
         try:
             output = self.client.pull(repo, tag=tag, stream=True)
             output = self.client.pull(repo, tag=tag, stream=True)
-            return progress_stream.get_digest_from_pull(
-                stream_output(output, sys.stdout))
+            if silent:
+                with open(os.devnull, 'w') as devnull:
+                    return progress_stream.get_digest_from_pull(
+                        stream_output(output, devnull))
+            else:
+                return progress_stream.get_digest_from_pull(
+                    stream_output(output, sys.stdout))
         except (StreamOutputError, NotFound) as e:
         except (StreamOutputError, NotFound) as e:
             if not ignore_pull_failures:
             if not ignore_pull_failures:
                 raise
                 raise
@@ -1202,12 +1249,21 @@ def format_environment(environment):
         return '{key}={value}'.format(key=key, value=value)
         return '{key}={value}'.format(key=key, value=value)
     return [format_env(*item) for item in environment.items()]
     return [format_env(*item) for item in environment.items()]
 
 
+
 # Ports
 # Ports
+def formatted_ports(ports):
+    result = []
+    for port in ports:
+        if isinstance(port, ServicePort):
+            result.append(port.legacy_repr())
+        else:
+            result.append(port)
+    return result
 
 
 
 
-def build_container_ports(container_options, options):
+def build_container_ports(container_ports, options):
     ports = []
     ports = []
-    all_ports = container_options.get('ports', []) + options.get('expose', [])
+    all_ports = container_ports + options.get('expose', [])
     for port_range in all_ports:
     for port_range in all_ports:
         internal_range, _ = split_port(port_range)
         internal_range, _ = split_port(port_range)
         for port in internal_range:
         for port in internal_range:

+ 58 - 18
contrib/completion/bash/docker-compose

@@ -1,4 +1,4 @@
-#!bash
+#!/bin/bash
 #
 #
 # bash completion for docker-compose
 # bash completion for docker-compose
 #
 #
@@ -18,7 +18,7 @@
 
 
 
 
 __docker_compose_q() {
 __docker_compose_q() {
-	docker-compose 2>/dev/null $daemon_options "$@"
+	docker-compose 2>/dev/null "${top_level_options[@]}" "$@"
 }
 }
 
 
 # Transforms a multiline list of strings into a single line string
 # Transforms a multiline list of strings into a single line string
@@ -36,6 +36,18 @@ __docker_compose_to_extglob() {
 	echo "@($extglob)"
 	echo "@($extglob)"
 }
 }
 
 
+# Determines whether the option passed as the first argument exist on
+# the commandline. The option may be a pattern, e.g. `--force|-f`.
+__docker_compose_has_option() {
+	local pattern="$1"
+	for (( i=2; i < $cword; ++i)); do
+		if [[ ${words[$i]} =~ ^($pattern)$ ]] ; then
+			return 0
+		fi
+	done
+	return 1
+}
+
 # suppress trailing whitespace
 # suppress trailing whitespace
 __docker_compose_nospace() {
 __docker_compose_nospace() {
 	# compopt is not available in ancient bash versions
 	# compopt is not available in ancient bash versions
@@ -98,9 +110,17 @@ __docker_compose_services_stopped() {
 
 
 
 
 _docker_compose_build() {
 _docker_compose_build() {
+	case "$prev" in
+		--build-arg)
+			COMPREPLY=( $( compgen -e -- "$cur" ) )
+			__docker_compose_nospace
+			return
+			;;
+	esac
+
 	case "$cur" in
 	case "$cur" in
 		-*)
 		-*)
-			COMPREPLY=( $( compgen -W "--force-rm --help --no-cache --pull" -- "$cur" ) )
+			COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --no-cache --pull" -- "$cur" ) )
 			;;
 			;;
 		*)
 		*)
 			__docker_compose_services_from_build
 			__docker_compose_services_from_build
@@ -148,14 +168,18 @@ _docker_compose_docker_compose() {
 			_filedir "y?(a)ml"
 			_filedir "y?(a)ml"
 			return
 			return
 			;;
 			;;
-		$(__docker_compose_to_extglob "$daemon_options_with_args") )
+		--project-directory)
+			_filedir -d
+			return
+			;;
+		$(__docker_compose_to_extglob "$top_level_options_with_args") )
 			return
 			return
 			;;
 			;;
 	esac
 	esac
 
 
 	case "$cur" in
 	case "$cur" in
 		-*)
 		-*)
-			COMPREPLY=( $( compgen -W "$daemon_boolean_options  $daemon_options_with_args --help -h --verbose --version -v" -- "$cur" ) )
+			COMPREPLY=( $( compgen -W "$top_level_boolean_options  $top_level_options_with_args --help -h --verbose --version -v" -- "$cur" ) )
 			;;
 			;;
 		*)
 		*)
 			COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) )
 			COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) )
@@ -220,6 +244,16 @@ _docker_compose_help() {
 	COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) )
 	COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) )
 }
 }
 
 
+_docker_compose_images() {
+	case "$cur" in
+		-*)
+			COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) )
+			;;
+		*)
+			__docker_compose_services_all
+			;;
+	esac
+}
 
 
 _docker_compose_kill() {
 _docker_compose_kill() {
 	case "$prev" in
 	case "$prev" in
@@ -349,10 +383,14 @@ _docker_compose_restart() {
 _docker_compose_rm() {
 _docker_compose_rm() {
 	case "$cur" in
 	case "$cur" in
 		-*)
 		-*)
-			COMPREPLY=( $( compgen -W "--force -f --help -v" -- "$cur" ) )
+			COMPREPLY=( $( compgen -W "--force -f --help --stop -s -v" -- "$cur" ) )
 			;;
 			;;
 		*)
 		*)
-			__docker_compose_services_stopped
+			if __docker_compose_has_option "--stop|-s" ; then
+				__docker_compose_services_all
+			else
+				__docker_compose_services_stopped
+			fi
 			;;
 			;;
 	esac
 	esac
 }
 }
@@ -365,14 +403,14 @@ _docker_compose_run() {
 			__docker_compose_nospace
 			__docker_compose_nospace
 			return
 			return
 			;;
 			;;
-		--entrypoint|--name|--user|-u|--workdir|-w)
+		--entrypoint|--name|--user|-u|--volume|-v|--workdir|-w)
 			return
 			return
 			;;
 			;;
 	esac
 	esac
 
 
 	case "$cur" in
 	case "$cur" in
 		-*)
 		-*)
-			COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u --workdir -w" -- "$cur" ) )
+			COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) )
 			;;
 			;;
 		*)
 		*)
 			__docker_compose_services_all
 			__docker_compose_services_all
@@ -467,7 +505,7 @@ _docker_compose_up() {
 
 
 	case "$cur" in
 	case "$cur" in
 		-*)
 		-*)
-			COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) )
+			COMPREPLY=( $( compgen -W "--exit-code-from --abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) )
 			;;
 			;;
 		*)
 		*)
 			__docker_compose_services_all
 			__docker_compose_services_all
@@ -498,6 +536,7 @@ _docker_compose() {
 		events
 		events
 		exec
 		exec
 		help
 		help
+		images
 		kill
 		kill
 		logs
 		logs
 		pause
 		pause
@@ -519,14 +558,15 @@ _docker_compose() {
 
 
 	# options for the docker daemon that have to be passed to secondary calls to
 	# options for the docker daemon that have to be passed to secondary calls to
 	# docker-compose executed by this script
 	# docker-compose executed by this script
-	local daemon_boolean_options="
+	local top_level_boolean_options="
 		--skip-hostname-check
 		--skip-hostname-check
 		--tls
 		--tls
 		--tlsverify
 		--tlsverify
 	"
 	"
-	local daemon_options_with_args="
+	local top_level_options_with_args="
 		--file -f
 		--file -f
 		--host -H
 		--host -H
+		--project-directory
 		--project-name -p
 		--project-name -p
 		--tlscacert
 		--tlscacert
 		--tlscert
 		--tlscert
@@ -540,19 +580,19 @@ _docker_compose() {
 	# search subcommand and invoke its handler.
 	# search subcommand and invoke its handler.
 	# special treatment of some top-level options
 	# special treatment of some top-level options
 	local command='docker_compose'
 	local command='docker_compose'
-	local daemon_options=()
+	local top_level_options=()
 	local counter=1
 	local counter=1
 
 
 	while [ $counter -lt $cword ]; do
 	while [ $counter -lt $cword ]; do
 		case "${words[$counter]}" in
 		case "${words[$counter]}" in
-			$(__docker_compose_to_extglob "$daemon_boolean_options") )
+			$(__docker_compose_to_extglob "$top_level_boolean_options") )
 				local opt=${words[counter]}
 				local opt=${words[counter]}
-				daemon_options+=($opt)
+				top_level_options+=($opt)
 				;;
 				;;
-			$(__docker_compose_to_extglob "$daemon_options_with_args") )
+			$(__docker_compose_to_extglob "$top_level_options_with_args") )
 				local opt=${words[counter]}
 				local opt=${words[counter]}
 				local arg=${words[++counter]}
 				local arg=${words[++counter]}
-				daemon_options+=($opt $arg)
+				top_level_options+=($opt $arg)
 				;;
 				;;
 			-*)
 			-*)
 				;;
 				;;
@@ -571,4 +611,4 @@ _docker_compose() {
 	return 0
 	return 0
 }
 }
 
 
-complete -F _docker_compose docker-compose
+complete -F _docker_compose docker-compose docker-compose.exe

+ 24 - 0
contrib/completion/fish/docker-compose.fish

@@ -0,0 +1,24 @@
+# Tab completion for docker-compose (https://github.com/docker/compose).
+# Version: 1.9.0
+
+complete -e -c docker-compose
+
+for line in (docker-compose --help | \
+             string match -r '^\s+\w+\s+[^\n]+' | \
+             string trim)
+  set -l doc (string split -m 1 ' ' -- $line)
+  complete -c docker-compose -n '__fish_use_subcommand' -xa $doc[1] --description $doc[2]
+end
+
+complete -c docker-compose -s f -l file -r                -d 'Specify an alternate compose file'
+complete -c docker-compose -s p -l project-name -x        -d 'Specify an alternate project name'
+complete -c docker-compose -l verbose                     -d 'Show more output'
+complete -c docker-compose -s H -l host -x                -d 'Daemon socket to connect to'
+complete -c docker-compose -l tls                         -d 'Use TLS; implied by --tlsverify'
+complete -c docker-compose -l tlscacert -r                -d 'Trust certs signed only by this CA'
+complete -c docker-compose -l tlscert -r                  -d 'Path to TLS certificate file'
+complete -c docker-compose -l tlskey -r                   -d 'Path to TLS key file'
+complete -c docker-compose -l tlsverify                   -d 'Use TLS and verify the remote'
+complete -c docker-compose -l skip-hostname-check         -d "Don't check the daemon's hostname against the name specified in the client certificate (for example if your docker host is an IP address)"
+complete -c docker-compose -s h -l help                   -d 'Print usage'
+complete -c docker-compose -s v -l version                -d 'Print version and exit'

+ 3 - 1
script/build/image

@@ -8,8 +8,10 @@ if [ -z "$1" ]; then
 fi
 fi
 
 
 TAG=$1
 TAG=$1
+
 VERSION="$(python setup.py --version)"
 VERSION="$(python setup.py --version)"
 
 
 ./script/build/write-git-sha
 ./script/build/write-git-sha
 python setup.py sdist bdist_wheel
 python setup.py sdist bdist_wheel
-docker build --build-arg version=$VERSION -t docker/compose:$TAG -f Dockerfile.run .
+./script/build/linux
+docker build -t docker/compose:$TAG -f Dockerfile.run .

+ 1 - 1
script/release/utils.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 #!/bin/bash
 #
 #
-# Util functions for release scritps
+# Util functions for release scripts
 #
 #
 
 
 set -e
 set -e

+ 2 - 2
script/run/run.sh

@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/sh
 #
 #
 # Run docker-compose in a container
 # Run docker-compose in a container
 #
 #
@@ -15,7 +15,7 @@
 
 
 set -e
 set -e
 
 
-VERSION="1.11.2"
+VERSION="1.12.0-rc1"
 IMAGE="docker/compose:$VERSION"
 IMAGE="docker/compose:$VERSION"
 
 
 
 

+ 5 - 1
script/test/default

@@ -5,11 +5,15 @@ set -ex
 
 
 TAG="docker-compose:$(git rev-parse --short HEAD)"
 TAG="docker-compose:$(git rev-parse --short HEAD)"
 
 
+# By default use the Dockerfile, but can be overriden to use an alternative file
+# e.g DOCKERFILE=Dockerfile.armhf script/test/default
+DOCKERFILE="${DOCKERFILE:-Dockerfile}"
+
 rm -rf coverage-html
 rm -rf coverage-html
 # Create the host directory so it's owned by $USER
 # Create the host directory so it's owned by $USER
 mkdir -p coverage-html
 mkdir -p coverage-html
 
 
-docker build -t "$TAG" .
+docker build -f ${DOCKERFILE} -t "$TAG" .
 
 
 GIT_VOLUME="--volume=$(pwd)/.git:/code/.git"
 GIT_VOLUME="--volume=$(pwd)/.git:/code/.git"
 . script/test/all
 . script/test/all

+ 2 - 2
script/test/versions.py

@@ -44,7 +44,7 @@ class Version(namedtuple('_Version', 'major minor patch rc')):
         version = version.lstrip('v')
         version = version.lstrip('v')
         version, _, rc = version.partition('-')
         version, _, rc = version.partition('-')
         major, minor, patch = version.split('.', 3)
         major, minor, patch = version.split('.', 3)
-        return cls(int(major), int(minor), int(patch), rc)
+        return cls(major, minor, patch, rc)
 
 
     @property
     @property
     def major_minor(self):
     def major_minor(self):
@@ -57,7 +57,7 @@ class Version(namedtuple('_Version', 'major minor patch rc')):
         """
         """
         # rc releases should appear before official releases
         # rc releases should appear before official releases
         rc = (0, self.rc) if self.rc else (1, )
         rc = (0, self.rc) if self.rc else (1, )
-        return (self.major, self.minor, self.patch) + rc
+        return (int(self.major), int(self.minor), int(self.patch)) + rc
 
 
     def __str__(self):
     def __str__(self):
         rc = '-{}'.format(self.rc) if self.rc else ''
         rc = '-{}'.format(self.rc) if self.rc else ''

+ 172 - 16
tests/acceptance/cli_test.py

@@ -5,6 +5,7 @@ from __future__ import unicode_literals
 import datetime
 import datetime
 import json
 import json
 import os
 import os
+import os.path
 import signal
 import signal
 import subprocess
 import subprocess
 import time
 import time
@@ -18,6 +19,7 @@ import yaml
 from docker import errors
 from docker import errors
 
 
 from .. import mock
 from .. import mock
+from ..helpers import create_host_file
 from compose.cli.command import get_project
 from compose.cli.command import get_project
 from compose.container import Container
 from compose.container import Container
 from compose.project import OneOffFilter
 from compose.project import OneOffFilter
@@ -105,6 +107,7 @@ class CLITestCase(DockerClientTestCase):
     def setUp(self):
     def setUp(self):
         super(CLITestCase, self).setUp()
         super(CLITestCase, self).setUp()
         self.base_dir = 'tests/fixtures/simple-composefile'
         self.base_dir = 'tests/fixtures/simple-composefile'
+        self.override_dir = None
 
 
     def tearDown(self):
     def tearDown(self):
         if self.base_dir:
         if self.base_dir:
@@ -127,7 +130,7 @@ class CLITestCase(DockerClientTestCase):
     def project(self):
     def project(self):
         # Hack: allow project to be overridden
         # Hack: allow project to be overridden
         if not hasattr(self, '_project'):
         if not hasattr(self, '_project'):
-            self._project = get_project(self.base_dir)
+            self._project = get_project(self.base_dir, override_dir=self.override_dir)
         return self._project
         return self._project
 
 
     def dispatch(self, options, project_options=None, returncode=0):
     def dispatch(self, options, project_options=None, returncode=0):
@@ -152,6 +155,12 @@ class CLITestCase(DockerClientTestCase):
         # Prevent tearDown from trying to create a project
         # Prevent tearDown from trying to create a project
         self.base_dir = None
         self.base_dir = None
 
 
+    def test_help_nonexistent(self):
+        self.base_dir = 'tests/fixtures/no-composefile'
+        result = self.dispatch(['help', 'foobar'], returncode=1)
+        assert 'No such command' in result.stderr
+        self.base_dir = None
+
     def test_shorthand_host_opt(self):
     def test_shorthand_host_opt(self):
         self.dispatch(
         self.dispatch(
             ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')),
             ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')),
@@ -177,6 +186,11 @@ class CLITestCase(DockerClientTestCase):
         result = self.dispatch(['config', '--services'])
         result = self.dispatch(['config', '--services'])
         assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'}
         assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'}
 
 
+    def test_config_list_volumes(self):
+        self.base_dir = 'tests/fixtures/v2-full'
+        result = self.dispatch(['config', '--volumes'])
+        assert set(result.stdout.rstrip().split('\n')) == {'data'}
+
     def test_config_quiet_with_error(self):
     def test_config_quiet_with_error(self):
         self.base_dir = None
         self.base_dir = None
         result = self.dispatch([
         result = self.dispatch([
@@ -211,7 +225,7 @@ class CLITestCase(DockerClientTestCase):
                 'other': {
                 'other': {
                     'image': 'busybox:latest',
                     'image': 'busybox:latest',
                     'command': 'top',
                     'command': 'top',
-                    'volumes': ['/data:rw'],
+                    'volumes': ['/data'],
                 },
                 },
             },
             },
         }
         }
@@ -288,7 +302,7 @@ class CLITestCase(DockerClientTestCase):
                 },
                 },
                 'volume': {
                 'volume': {
                     'image': 'busybox',
                     'image': 'busybox',
-                    'volumes': ['/data:rw'],
+                    'volumes': ['/data'],
                     'network_mode': 'bridge',
                     'network_mode': 'bridge',
                 },
                 },
                 'app': {
                 'app': {
@@ -307,7 +321,7 @@ class CLITestCase(DockerClientTestCase):
         result = self.dispatch(['config'])
         result = self.dispatch(['config'])
 
 
         assert yaml.load(result.stdout) == {
         assert yaml.load(result.stdout) == {
-            'version': '3.0',
+            'version': '3.2',
             'networks': {},
             'networks': {},
             'volumes': {
             'volumes': {
                 'foobar': {
                 'foobar': {
@@ -357,6 +371,11 @@ class CLITestCase(DockerClientTestCase):
                         'timeout': '1s',
                         'timeout': '1s',
                         'retries': 5,
                         'retries': 5,
                     },
                     },
+                    'volumes': [
+                        '/host/path:/container/path:ro',
+                        'foobar:/container/volumepath:rw',
+                        '/anonymous'
+                    ],
 
 
                     'stop_grace_period': '20s',
                     'stop_grace_period': '20s',
                 },
                 },
@@ -505,6 +524,23 @@ class CLITestCase(DockerClientTestCase):
             },
             },
         }
         }
 
 
+    def test_build_override_dir(self):
+        self.base_dir = 'tests/fixtures/build-path-override-dir'
+        self.override_dir = os.path.abspath('tests/fixtures')
+        result = self.dispatch([
+            '--project-directory', self.override_dir,
+            'build'])
+
+        assert 'Successfully built' in result.stdout
+
+    def test_build_override_dir_invalid_path(self):
+        config_path = os.path.abspath('tests/fixtures/build-path-override-dir/docker-compose.yml')
+        result = self.dispatch([
+            '-f', config_path,
+            'build'], returncode=1)
+
+        assert 'does not exist, is not accessible, or is not a valid URL' in result.stderr
+
     def test_create(self):
     def test_create(self):
         self.dispatch(['create'])
         self.dispatch(['create'])
         service = self.project.get_service('simple')
         service = self.project.get_service('simple')
@@ -546,6 +582,45 @@ class CLITestCase(DockerClientTestCase):
 
 
         self.assertEqual(old_ids, new_ids)
         self.assertEqual(old_ids, new_ids)
 
 
+    def test_run_one_off_with_volume(self):
+        self.base_dir = 'tests/fixtures/simple-composefile-volume-ready'
+        volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files'))
+        create_host_file(self.client, os.path.join(volume_path, 'example.txt'))
+
+        self.dispatch([
+            'run',
+            '-v', '{}:/data'.format(volume_path),
+            'simple',
+            'test', '-f', '/data/example.txt'
+        ], returncode=0)
+        # FIXME: does not work with Python 3
+        # assert cmd_result.stdout.strip() == 'FILE_CONTENT'
+
+    def test_run_one_off_with_multiple_volumes(self):
+        self.base_dir = 'tests/fixtures/simple-composefile-volume-ready'
+        volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files'))
+        create_host_file(self.client, os.path.join(volume_path, 'example.txt'))
+
+        self.dispatch([
+            'run',
+            '-v', '{}:/data'.format(volume_path),
+            '-v', '{}:/data1'.format(volume_path),
+            'simple',
+            'test', '-f', '/data/example.txt'
+        ], returncode=0)
+        # FIXME: does not work with Python 3
+        # assert cmd_result.stdout.strip() == 'FILE_CONTENT'
+
+        self.dispatch([
+            'run',
+            '-v', '{}:/data'.format(volume_path),
+            '-v', '{}:/data1'.format(volume_path),
+            'simple',
+            'test', '-f' '/data1/example.txt'
+        ], returncode=0)
+        # FIXME: does not work with Python 3
+        # assert cmd_result.stdout.strip() == 'FILE_CONTENT'
+
     def test_create_with_force_recreate_and_no_recreate(self):
     def test_create_with_force_recreate_and_no_recreate(self):
         self.dispatch(
         self.dispatch(
             ['create', '--force-recreate', '--no-recreate'],
             ['create', '--force-recreate', '--no-recreate'],
@@ -1074,10 +1149,18 @@ class CLITestCase(DockerClientTestCase):
         wait_on_condition(ContainerCountCondition(self.project, 0))
         wait_on_condition(ContainerCountCondition(self.project, 0))
 
 
     def test_up_handles_abort_on_container_exit(self):
     def test_up_handles_abort_on_container_exit(self):
-        start_process(self.base_dir, ['up', '--abort-on-container-exit'])
-        wait_on_condition(ContainerCountCondition(self.project, 2))
-        self.project.stop(['simple'])
+        self.base_dir = 'tests/fixtures/abort-on-container-exit-0'
+        proc = start_process(self.base_dir, ['up', '--abort-on-container-exit'])
+        wait_on_condition(ContainerCountCondition(self.project, 0))
+        proc.wait()
+        self.assertEqual(proc.returncode, 0)
+
+    def test_up_handles_abort_on_container_exit_code(self):
+        self.base_dir = 'tests/fixtures/abort-on-container-exit-1'
+        proc = start_process(self.base_dir, ['up', '--abort-on-container-exit'])
         wait_on_condition(ContainerCountCondition(self.project, 0))
         wait_on_condition(ContainerCountCondition(self.project, 0))
+        proc.wait()
+        self.assertEqual(proc.returncode, 1)
 
 
     def test_exec_without_tty(self):
     def test_exec_without_tty(self):
         self.base_dir = 'tests/fixtures/links-composefile'
         self.base_dir = 'tests/fixtures/links-composefile'
@@ -1085,8 +1168,8 @@ class CLITestCase(DockerClientTestCase):
         self.assertEqual(len(self.project.containers()), 1)
         self.assertEqual(len(self.project.containers()), 1)
 
 
         stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/'])
         stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/'])
-        self.assertEquals(stdout, "/\n")
-        self.assertEquals(stderr, "")
+        self.assertEqual(stdout, "/\n")
+        self.assertEqual(stderr, "")
 
 
     def test_exec_custom_user(self):
     def test_exec_custom_user(self):
         self.base_dir = 'tests/fixtures/links-composefile'
         self.base_dir = 'tests/fixtures/links-composefile'
@@ -1094,8 +1177,8 @@ class CLITestCase(DockerClientTestCase):
         self.assertEqual(len(self.project.containers()), 1)
         self.assertEqual(len(self.project.containers()), 1)
 
 
         stdout, stderr = self.dispatch(['exec', '-T', '--user=operator', 'console', 'whoami'])
         stdout, stderr = self.dispatch(['exec', '-T', '--user=operator', 'console', 'whoami'])
-        self.assertEquals(stdout, "operator\n")
-        self.assertEquals(stderr, "")
+        self.assertEqual(stdout, "operator\n")
+        self.assertEqual(stderr, "")
 
 
     def test_run_service_without_links(self):
     def test_run_service_without_links(self):
         self.base_dir = 'tests/fixtures/links-composefile'
         self.base_dir = 'tests/fixtures/links-composefile'
@@ -1167,6 +1250,36 @@ class CLITestCase(DockerClientTestCase):
             [u'/bin/true'],
             [u'/bin/true'],
         )
         )
 
 
+    def test_run_rm(self):
+        self.base_dir = 'tests/fixtures/volume'
+        proc = start_process(self.base_dir, ['run', '--rm', 'test'])
+        wait_on_condition(ContainerStateCondition(
+            self.project.client,
+            'volume_test_run_1',
+            'running'))
+        service = self.project.get_service('test')
+        containers = service.containers(one_off=OneOffFilter.only)
+        self.assertEqual(len(containers), 1)
+        mounts = containers[0].get('Mounts')
+        for mount in mounts:
+            if mount['Destination'] == '/container-path':
+                anonymousName = mount['Name']
+                break
+        os.kill(proc.pid, signal.SIGINT)
+        wait_on_process(proc, 1)
+
+        self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0)
+
+        volumes = self.client.volumes()['Volumes']
+        assert volumes is not None
+        for volume in service.options.get('volumes'):
+            if volume.internal == '/container-named-path':
+                name = volume.external
+                break
+        volumeNames = [v['Name'] for v in volumes]
+        assert name in volumeNames
+        assert anonymousName not in volumeNames
+
     def test_run_service_with_dockerfile_entrypoint(self):
     def test_run_service_with_dockerfile_entrypoint(self):
         self.base_dir = 'tests/fixtures/entrypoint-dockerfile'
         self.base_dir = 'tests/fixtures/entrypoint-dockerfile'
         self.dispatch(['run', 'test'])
         self.dispatch(['run', 'test'])
@@ -1234,7 +1347,7 @@ class CLITestCase(DockerClientTestCase):
         container = service.containers(stopped=True, one_off=OneOffFilter.only)[0]
         container = service.containers(stopped=True, one_off=OneOffFilter.only)[0]
         self.assertEqual(user, container.get('Config.User'))
         self.assertEqual(user, container.get('Config.User'))
 
 
-    def test_run_service_with_environement_overridden(self):
+    def test_run_service_with_environment_overridden(self):
         name = 'service'
         name = 'service'
         self.base_dir = 'tests/fixtures/environment-composefile'
         self.base_dir = 'tests/fixtures/environment-composefile'
         self.dispatch([
         self.dispatch([
@@ -1246,9 +1359,9 @@ class CLITestCase(DockerClientTestCase):
         ])
         ])
         service = self.project.get_service(name)
         service = self.project.get_service(name)
         container = service.containers(stopped=True, one_off=OneOffFilter.only)[0]
         container = service.containers(stopped=True, one_off=OneOffFilter.only)[0]
-        # env overriden
+        # env overridden
         self.assertEqual('notbar', container.environment['foo'])
         self.assertEqual('notbar', container.environment['foo'])
-        # keep environement from yaml
+        # keep environment from yaml
         self.assertEqual('world', container.environment['hello'])
         self.assertEqual('world', container.environment['hello'])
         # added option from command line
         # added option from command line
         self.assertEqual('beta', container.environment['alpha'])
         self.assertEqual('beta', container.environment['alpha'])
@@ -1293,7 +1406,7 @@ class CLITestCase(DockerClientTestCase):
         self.assertEqual(port_range[0], "0.0.0.0:49153")
         self.assertEqual(port_range[0], "0.0.0.0:49153")
         self.assertEqual(port_range[1], "0.0.0.0:49154")
         self.assertEqual(port_range[1], "0.0.0.0:49154")
 
 
-    def test_run_service_with_explicitly_maped_ports(self):
+    def test_run_service_with_explicitly_mapped_ports(self):
         # create one off container
         # create one off container
         self.base_dir = 'tests/fixtures/ports-composefile'
         self.base_dir = 'tests/fixtures/ports-composefile'
         self.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple'])
         self.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple'])
@@ -1310,7 +1423,7 @@ class CLITestCase(DockerClientTestCase):
         self.assertEqual(port_short, "0.0.0.0:30000")
         self.assertEqual(port_short, "0.0.0.0:30000")
         self.assertEqual(port_full, "0.0.0.0:30001")
         self.assertEqual(port_full, "0.0.0.0:30001")
 
 
-    def test_run_service_with_explicitly_maped_ip_ports(self):
+    def test_run_service_with_explicitly_mapped_ip_ports(self):
         # create one off container
         # create one off container
         self.base_dir = 'tests/fixtures/ports-composefile'
         self.base_dir = 'tests/fixtures/ports-composefile'
         self.dispatch([
         self.dispatch([
@@ -1498,6 +1611,11 @@ class CLITestCase(DockerClientTestCase):
         self.assertEqual(len(service.containers(stopped=True)), 1)
         self.assertEqual(len(service.containers(stopped=True)), 1)
         self.dispatch(['rm', '-f'], None)
         self.dispatch(['rm', '-f'], None)
         self.assertEqual(len(service.containers(stopped=True)), 0)
         self.assertEqual(len(service.containers(stopped=True)), 0)
+        service = self.project.get_service('simple')
+        service.create_container()
+        self.dispatch(['rm', '-fs'], None)
+        simple = self.project.get_service('simple')
+        self.assertEqual(len(simple.containers()), 0)
 
 
     def test_rm_all(self):
     def test_rm_all(self):
         service = self.project.get_service('simple')
         service = self.project.get_service('simple')
@@ -1759,6 +1877,19 @@ class CLITestCase(DockerClientTestCase):
         self.assertEqual(get_port(3001), "0.0.0.0:49152")
         self.assertEqual(get_port(3001), "0.0.0.0:49152")
         self.assertEqual(get_port(3002), "0.0.0.0:49153")
         self.assertEqual(get_port(3002), "0.0.0.0:49153")
 
 
+    def test_expanded_port(self):
+        self.base_dir = 'tests/fixtures/ports-composefile'
+        self.dispatch(['-f', 'expanded-notation.yml', 'up', '-d'])
+        container = self.project.get_service('simple').get_container()
+
+        def get_port(number):
+            result = self.dispatch(['port', 'simple', str(number)])
+            return result.stdout.rstrip()
+
+        self.assertEqual(get_port(3000), container.get_local_port(3000))
+        self.assertEqual(get_port(3001), "0.0.0.0:49152")
+        self.assertEqual(get_port(3002), "0.0.0.0:49153")
+
     def test_port_with_scale(self):
     def test_port_with_scale(self):
         self.base_dir = 'tests/fixtures/ports-composefile-scale'
         self.base_dir = 'tests/fixtures/ports-composefile-scale'
         self.dispatch(['scale', 'simple=2'], None)
         self.dispatch(['scale', 'simple=2'], None)
@@ -1927,3 +2058,28 @@ class CLITestCase(DockerClientTestCase):
         self.dispatch(['up', '-d'])
         self.dispatch(['up', '-d'])
         result = self.dispatch(['top'])
         result = self.dispatch(['top'])
         assert result.stdout.count("top") == 4
         assert result.stdout.count("top") == 4
+
+    def test_forward_exitval(self):
+        self.base_dir = 'tests/fixtures/exit-code-from'
+        proc = start_process(
+            self.base_dir,
+            ['up', '--abort-on-container-exit', '--exit-code-from', 'another'])
+
+        result = wait_on_process(proc, returncode=1)
+
+        assert 'exitcodefrom_another_1 exited with code 1' in result.stdout
+
+    def test_images(self):
+        self.project.get_service('simple').create_container()
+        result = self.dispatch(['images'])
+        assert 'busybox' in result.stdout
+        assert 'simplecomposefile_simple_1' in result.stdout
+
+    def test_images_default_composefile(self):
+        self.base_dir = 'tests/fixtures/multiple-composefiles'
+        self.dispatch(['up', '-d'])
+        result = self.dispatch(['images'])
+
+        assert 'busybox' in result.stdout
+        assert 'multiplecomposefiles_another_1' in result.stdout
+        assert 'multiplecomposefiles_simple_1' in result.stdout

+ 6 - 0
tests/fixtures/abort-on-container-exit-0/docker-compose.yml

@@ -0,0 +1,6 @@
+simple:
+  image: busybox:latest
+  command: top
+another:
+  image: busybox:latest
+  command: ls .

+ 6 - 0
tests/fixtures/abort-on-container-exit-1/docker-compose.yml

@@ -0,0 +1,6 @@
+simple:
+  image: busybox:latest
+  command: top
+another:
+  image: busybox:latest
+  command: ls /thecakeisalie

+ 2 - 0
tests/fixtures/build-path-override-dir/docker-compose.yml

@@ -0,0 +1,2 @@
+foo:
+  build: ./build-ctx/

+ 6 - 0
tests/fixtures/exit-code-from/docker-compose.yml

@@ -0,0 +1,6 @@
+simple:
+  image: busybox:latest
+  command: sh -c "echo hello && tail -f /dev/null"
+another:
+  image: busybox:latest
+  command: /bin/false

+ 15 - 0
tests/fixtures/ports-composefile/expanded-notation.yml

@@ -0,0 +1,15 @@
+version: '3.2'
+services:
+    simple:
+      image: busybox:latest
+      command: top
+      ports:
+        - target: 3000
+        - target: 3001
+          published: 49152
+        - target: 3002
+          published: 49153
+          protocol: tcp
+        - target: 3003
+          published: 49154
+          protocol: udp

+ 2 - 0
tests/fixtures/simple-composefile-volume-ready/docker-compose.yml

@@ -0,0 +1,2 @@
+simple:
+  image: busybox:latest

+ 1 - 0
tests/fixtures/simple-composefile-volume-ready/files/example.txt

@@ -0,0 +1 @@
+FILE_CONTENT

+ 12 - 1
tests/fixtures/v3-full/docker-compose.yml

@@ -1,4 +1,4 @@
-version: "3"
+version: "3.2"
 services:
 services:
   web:
   web:
     image: busybox
     image: busybox
@@ -34,6 +34,17 @@ services:
       timeout: 1s
       timeout: 1s
       retries: 5
       retries: 5
 
 
+    volumes:
+      - source: /host/path
+        target: /container/path
+        type: bind
+        read_only: true
+      - source: foobar
+        type: volume
+        target: /container/volumepath
+      - type: volume
+        target: /anonymous
+
     stop_grace_period: 20s
     stop_grace_period: 20s
 volumes:
 volumes:
   foobar:
   foobar:

+ 11 - 0
tests/fixtures/volume/docker-compose.yml

@@ -0,0 +1,11 @@
+version: '2'
+services:
+  test:
+    image: busybox
+    command: top
+    volumes:
+      - /container-path
+      - testvolume:/container-named-path
+
+volumes:
+  testvolume: {}

+ 29 - 0
tests/helpers.py

@@ -1,6 +1,8 @@
 from __future__ import absolute_import
 from __future__ import absolute_import
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+import os
+
 from compose.config.config import ConfigDetails
 from compose.config.config import ConfigDetails
 from compose.config.config import ConfigFile
 from compose.config.config import ConfigFile
 from compose.config.config import load
 from compose.config.config import load
@@ -15,3 +17,30 @@ def build_config_details(contents, working_dir='working_dir', filename='filename
         working_dir,
         working_dir,
         [ConfigFile(filename, contents)],
         [ConfigFile(filename, contents)],
     )
     )
+
+
+def create_host_file(client, filename):
+    dirname = os.path.dirname(filename)
+
+    with open(filename, 'r') as fh:
+        content = fh.read()
+
+    container = client.create_container(
+        'busybox:latest',
+        ['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)],
+        volumes={dirname: {}},
+        host_config=client.create_host_config(
+            binds={dirname: {'bind': dirname, 'ro': False}},
+            network_mode='none',
+        ),
+    )
+    try:
+        client.start(container)
+        exitcode = client.wait(container)
+
+        if exitcode != 0:
+            output = client.logs(container)
+            raise Exception(
+                "Container exited with code {}:\n{}".format(exitcode, output))
+    finally:
+        client.remove_container(container, force=True)

+ 4 - 30
tests/integration/project_test.py

@@ -10,15 +10,16 @@ from docker.errors import NotFound
 
 
 from .. import mock
 from .. import mock
 from ..helpers import build_config as load_config
 from ..helpers import build_config as load_config
+from ..helpers import create_host_file
 from .testcases import DockerClientTestCase
 from .testcases import DockerClientTestCase
 from compose.config import config
 from compose.config import config
 from compose.config import ConfigurationError
 from compose.config import ConfigurationError
 from compose.config import types
 from compose.config import types
-from compose.config.config import V2_0
-from compose.config.config import V2_1
-from compose.config.config import V3_1
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeSpec
 from compose.config.types import VolumeSpec
+from compose.const import COMPOSEFILE_V2_0 as V2_0
+from compose.const import COMPOSEFILE_V2_1 as V2_1
+from compose.const import COMPOSEFILE_V3_1 as V3_1
 from compose.const import LABEL_PROJECT
 from compose.const import LABEL_PROJECT
 from compose.const import LABEL_SERVICE
 from compose.const import LABEL_SERVICE
 from compose.container import Container
 from compose.container import Container
@@ -1517,30 +1518,3 @@ class ProjectTest(DockerClientTestCase):
         assert 'svc1' in svc2.get_dependency_names()
         assert 'svc1' in svc2.get_dependency_names()
         with pytest.raises(NoHealthCheckConfigured):
         with pytest.raises(NoHealthCheckConfigured):
             svc1.is_healthy()
             svc1.is_healthy()
-
-
-def create_host_file(client, filename):
-    dirname = os.path.dirname(filename)
-
-    with open(filename, 'r') as fh:
-        content = fh.read()
-
-    container = client.create_container(
-        'busybox:latest',
-        ['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)],
-        volumes={dirname: {}},
-        host_config=client.create_host_config(
-            binds={dirname: {'bind': dirname, 'ro': False}},
-            network_mode='none',
-        ),
-    )
-    try:
-        client.start(container)
-        exitcode = client.wait(container)
-
-        if exitcode != 0:
-            output = client.logs(container)
-            raise Exception(
-                "Container exited with code {}:\n{}".format(exitcode, output))
-    finally:
-        client.remove_container(container, force=True)

+ 60 - 4
tests/integration/service_test.py

@@ -32,6 +32,7 @@ from compose.service import NetworkMode
 from compose.service import Service
 from compose.service import Service
 from tests.integration.testcases import v2_1_only
 from tests.integration.testcases import v2_1_only
 from tests.integration.testcases import v2_only
 from tests.integration.testcases import v2_only
+from tests.integration.testcases import v3_only
 
 
 
 
 def create_and_start_container(service, **override_options):
 def create_and_start_container(service, **override_options):
@@ -40,6 +41,7 @@ def create_and_start_container(service, **override_options):
 
 
 
 
 class ServiceTest(DockerClientTestCase):
 class ServiceTest(DockerClientTestCase):
+
     def test_containers(self):
     def test_containers(self):
         foo = self.create_service('foo')
         foo = self.create_service('foo')
         bar = self.create_service('bar')
         bar = self.create_service('bar')
@@ -113,6 +115,14 @@ class ServiceTest(DockerClientTestCase):
         service.start_container(container)
         service.start_container(container)
         self.assertEqual(container.get('HostConfig.ShmSize'), 67108864)
         self.assertEqual(container.get('HostConfig.ShmSize'), 67108864)
 
 
+    @pytest.mark.xfail(True, reason='Some kernels/configs do not support pids_limit')
+    def test_create_container_with_pids_limit(self):
+        self.require_api_version('1.23')
+        service = self.create_service('db', pids_limit=10)
+        container = service.create_container()
+        service.start_container(container)
+        assert container.get('HostConfig.PidsLimit') == 10
+
     def test_create_container_with_extra_hosts_list(self):
     def test_create_container_with_extra_hosts_list(self):
         extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229']
         extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229']
         service = self.create_service('db', extra_hosts=extra_hosts)
         service = self.create_service('db', extra_hosts=extra_hosts)
@@ -587,12 +597,30 @@ class ServiceTest(DockerClientTestCase):
         with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f:
         with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f:
             f.write("FROM busybox\n")
             f.write("FROM busybox\n")
             f.write("ARG build_version\n")
             f.write("ARG build_version\n")
+            f.write("RUN echo ${build_version}\n")
 
 
         service = self.create_service('buildwithargs',
         service = self.create_service('buildwithargs',
                                       build={'context': text_type(base_dir),
                                       build={'context': text_type(base_dir),
                                              'args': {"build_version": "1"}})
                                              'args': {"build_version": "1"}})
         service.build()
         service.build()
         assert service.image()
         assert service.image()
+        assert "build_version=1" in service.image()['ContainerConfig']['Cmd']
+
+    def test_build_with_build_args_override(self):
+        base_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, base_dir)
+
+        with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f:
+            f.write("FROM busybox\n")
+            f.write("ARG build_version\n")
+            f.write("RUN echo ${build_version}\n")
+
+        service = self.create_service('buildwithargs',
+                                      build={'context': text_type(base_dir),
+                                             'args': {"build_version": "1"}})
+        service.build(build_args_override={'build_version': '2'})
+        assert service.image()
+        assert "build_version=2" in service.image()['ContainerConfig']['Cmd']
 
 
     def test_start_container_stays_unprivileged(self):
     def test_start_container_stays_unprivileged(self):
         service = self.create_service('web')
         service = self.create_service('web')
@@ -870,6 +898,11 @@ class ServiceTest(DockerClientTestCase):
         container = create_and_start_container(service)
         container = create_and_start_container(service)
         self.assertEqual(container.get('HostConfig.MemorySwappiness'), 11)
         self.assertEqual(container.get('HostConfig.MemorySwappiness'), 11)
 
 
+    def test_mem_reservation(self):
+        service = self.create_service('web', mem_reservation='20m')
+        container = create_and_start_container(service)
+        assert container.get('HostConfig.MemoryReservation') == 20 * 1024 * 1024
+
     def test_restart_always_value(self):
     def test_restart_always_value(self):
         service = self.create_service('web', restart={'Name': 'always'})
         service = self.create_service('web', restart={'Name': 'always'})
         container = create_and_start_container(service)
         container = create_and_start_container(service)
@@ -885,8 +918,16 @@ class ServiceTest(DockerClientTestCase):
         container = create_and_start_container(service)
         container = create_and_start_container(service)
 
 
         host_container_groupadd = container.get('HostConfig.GroupAdd')
         host_container_groupadd = container.get('HostConfig.GroupAdd')
-        self.assertTrue("root" in host_container_groupadd)
-        self.assertTrue("1" in host_container_groupadd)
+        assert "root" in host_container_groupadd
+        assert "1" in host_container_groupadd
+
+    def test_dns_opt_value(self):
+        service = self.create_service('web', dns_opt=["use-vc", "no-tld-query"])
+        container = create_and_start_container(service)
+
+        dns_opt = container.get('HostConfig.DnsOptions')
+        assert 'use-vc' in dns_opt
+        assert 'no-tld-query' in dns_opt
 
 
     def test_restart_on_failure_value(self):
     def test_restart_on_failure_value(self):
         service = self.create_service('web', restart={
         service = self.create_service('web', restart={
@@ -946,6 +987,20 @@ class ServiceTest(DockerClientTestCase):
         }.items():
         }.items():
             self.assertEqual(env[k], v)
             self.assertEqual(env[k], v)
 
 
+    @v3_only()
+    def test_build_with_cachefrom(self):
+        base_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, base_dir)
+
+        with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f:
+            f.write("FROM busybox\n")
+
+        service = self.create_service('cache_from',
+                                      build={'context': base_dir,
+                                             'cache_from': ['build1']})
+        service.build()
+        assert service.image()
+
     @mock.patch.dict(os.environ)
     @mock.patch.dict(os.environ)
     def test_resolve_env(self):
     def test_resolve_env(self):
         os.environ['FILE_DEF'] = 'E1'
         os.environ['FILE_DEF'] = 'E1'
@@ -974,7 +1029,7 @@ class ServiceTest(DockerClientTestCase):
         with mock.patch.object(self.client, '_version', '1.20'):
         with mock.patch.object(self.client, '_version', '1.20'):
             service = self.create_service('web')
             service = self.create_service('web')
             service_config = service._get_container_host_config({})
             service_config = service._get_container_host_config({})
-            self.assertEquals(service_config['NetworkMode'], 'default')
+            self.assertEqual(service_config['NetworkMode'], 'default')
 
 
     def test_labels(self):
     def test_labels(self):
         labels_dict = {
         labels_dict = {
@@ -1020,7 +1075,7 @@ class ServiceTest(DockerClientTestCase):
         one_off_container = service.create_container(one_off=True)
         one_off_container = service.create_container(one_off=True)
         self.assertNotEqual(one_off_container.name, 'my-web-container')
         self.assertNotEqual(one_off_container.name, 'my-web-container')
 
 
-    @pytest.mark.skipif(True, reason="Broken on 1.11.0rc1")
+    @pytest.mark.skipif(True, reason="Broken on 1.11.0 - 17.03.0")
     def test_log_drive_invalid(self):
     def test_log_drive_invalid(self):
         service = self.create_service('web', logging={'driver': 'xxx'})
         service = self.create_service('web', logging={'driver': 'xxx'})
         expected_error_msg = "logger: no log driver named 'xxx' is registered"
         expected_error_msg = "logger: no log driver named 'xxx' is registered"
@@ -1078,6 +1133,7 @@ def converge(service, strategy=ConvergenceStrategy.changed):
 
 
 
 
 class ConfigHashTest(DockerClientTestCase):
 class ConfigHashTest(DockerClientTestCase):
+
     def test_no_config_hash_when_one_off(self):
     def test_no_config_hash_when_one_off(self):
         web = self.create_service('web')
         web = self.create_service('web')
         container = web.create_container(one_off=True)
         container = web.create_container(one_off=True)

+ 4 - 4
tests/integration/testcases.py

@@ -10,12 +10,12 @@ from pytest import skip
 from .. import unittest
 from .. import unittest
 from compose.cli.docker_client import docker_client
 from compose.cli.docker_client import docker_client
 from compose.config.config import resolve_environment
 from compose.config.config import resolve_environment
-from compose.config.config import V1
-from compose.config.config import V2_0
-from compose.config.config import V2_1
-from compose.config.config import V3_0
 from compose.config.environment import Environment
 from compose.config.environment import Environment
 from compose.const import API_VERSIONS
 from compose.const import API_VERSIONS
+from compose.const import COMPOSEFILE_V1 as V1
+from compose.const import COMPOSEFILE_V2_0 as V2_0
+from compose.const import COMPOSEFILE_V2_0 as V2_1
+from compose.const import COMPOSEFILE_V3_0 as V3_0
 from compose.const import LABEL_PROJECT
 from compose.const import LABEL_PROJECT
 from compose.progress_stream import stream_output
 from compose.progress_stream import stream_output
 from compose.service import Service
 from compose.service import Service

+ 9 - 0
tests/unit/cli/command_test.py

@@ -45,6 +45,15 @@ class TestGetConfigPathFromOptions(object):
                 '.', {}, environment
                 '.', {}, environment
             ) == ['one.yml', 'two.yml']
             ) == ['one.yml', 'two.yml']
 
 
+    def test_multiple_path_from_env_custom_separator(self):
+        with mock.patch.dict(os.environ):
+            os.environ['COMPOSE_PATH_SEPARATOR'] = '^'
+            os.environ['COMPOSE_FILE'] = 'c:\\one.yml^.\\semi;colon.yml'
+            environment = Environment.from_env_file('.')
+            assert get_config_path_from_options(
+                '.', {}, environment
+            ) == ['c:\\one.yml', '.\\semi;colon.yml']
+
     def test_no_path(self):
     def test_no_path(self):
         environment = Environment.from_env_file('.')
         environment = Environment.from_env_file('.')
         assert not get_config_path_from_options('.', {}, environment)
         assert not get_config_path_from_options('.', {}, environment)

+ 16 - 0
tests/unit/cli/errors_test.py

@@ -42,10 +42,26 @@ class TestHandleConnectionErrors(object):
         _, args, _ = mock_logging.error.mock_calls[0]
         _, args, _ = mock_logging.error.mock_calls[0]
         assert "Docker Engine of version 1.10.0 or greater" in args[0]
         assert "Docker Engine of version 1.10.0 or greater" in args[0]
 
 
+    def test_api_error_version_mismatch_unicode_explanation(self, mock_logging):
+        with pytest.raises(errors.ConnectionError):
+            with handle_connection_errors(mock.Mock(api_version='1.22')):
+                raise APIError(None, None, u"client is newer than server")
+
+        _, args, _ = mock_logging.error.mock_calls[0]
+        assert "Docker Engine of version 1.10.0 or greater" in args[0]
+
     def test_api_error_version_other(self, mock_logging):
     def test_api_error_version_other(self, mock_logging):
         msg = b"Something broke!"
         msg = b"Something broke!"
         with pytest.raises(errors.ConnectionError):
         with pytest.raises(errors.ConnectionError):
             with handle_connection_errors(mock.Mock(api_version='1.22')):
             with handle_connection_errors(mock.Mock(api_version='1.22')):
                 raise APIError(None, None, msg)
                 raise APIError(None, None, msg)
 
 
+        mock_logging.error.assert_called_once_with(msg.decode('utf-8'))
+
+    def test_api_error_version_other_unicode_explanation(self, mock_logging):
+        msg = u"Something broke!"
+        with pytest.raises(errors.ConnectionError):
+            with handle_connection_errors(mock.Mock(api_version='1.22')):
+                raise APIError(None, None, msg)
+
         mock_logging.error.assert_called_once_with(msg)
         mock_logging.error.assert_called_once_with(msg)

+ 4 - 2
tests/unit/cli/log_printer_test.py

@@ -187,11 +187,13 @@ class TestConsumeQueue(object):
         assert next(generator) == 'b'
         assert next(generator) == 'b'
 
 
     def test_item_is_stop_with_cascade_stop(self):
     def test_item_is_stop_with_cascade_stop(self):
+        """Return the name of the container that caused the cascade_stop"""
         queue = Queue()
         queue = Queue()
-        for item in QueueItem.stop(), QueueItem.new('a'), QueueItem.new('b'):
+        for item in QueueItem.stop('foobar-1'), QueueItem.new('a'), QueueItem.new('b'):
             queue.put(item)
             queue.put(item)
 
 
-        assert list(consume_queue(queue, True)) == []
+        generator = consume_queue(queue, True)
+        assert next(generator) is 'foobar-1'
 
 
     def test_item_is_none_when_timeout_is_hit(self):
     def test_item_is_none_when_timeout_is_hit(self):
         queue = Queue()
         queue = Queue()

+ 11 - 8
tests/unit/cli_test.py

@@ -29,36 +29,36 @@ class CLITestCase(unittest.TestCase):
         test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile')
         test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile')
         with test_dir.as_cwd():
         with test_dir.as_cwd():
             project_name = get_project_name('.')
             project_name = get_project_name('.')
-            self.assertEquals('simplecomposefile', project_name)
+            self.assertEqual('simplecomposefile', project_name)
 
 
     def test_project_name_with_explicit_base_dir(self):
     def test_project_name_with_explicit_base_dir(self):
         base_dir = 'tests/fixtures/simple-composefile'
         base_dir = 'tests/fixtures/simple-composefile'
         project_name = get_project_name(base_dir)
         project_name = get_project_name(base_dir)
-        self.assertEquals('simplecomposefile', project_name)
+        self.assertEqual('simplecomposefile', project_name)
 
 
     def test_project_name_with_explicit_uppercase_base_dir(self):
     def test_project_name_with_explicit_uppercase_base_dir(self):
         base_dir = 'tests/fixtures/UpperCaseDir'
         base_dir = 'tests/fixtures/UpperCaseDir'
         project_name = get_project_name(base_dir)
         project_name = get_project_name(base_dir)
-        self.assertEquals('uppercasedir', project_name)
+        self.assertEqual('uppercasedir', project_name)
 
 
     def test_project_name_with_explicit_project_name(self):
     def test_project_name_with_explicit_project_name(self):
         name = 'explicit-project-name'
         name = 'explicit-project-name'
         project_name = get_project_name(None, project_name=name)
         project_name = get_project_name(None, project_name=name)
-        self.assertEquals('explicitprojectname', project_name)
+        self.assertEqual('explicitprojectname', project_name)
 
 
     @mock.patch.dict(os.environ)
     @mock.patch.dict(os.environ)
     def test_project_name_from_environment_new_var(self):
     def test_project_name_from_environment_new_var(self):
         name = 'namefromenv'
         name = 'namefromenv'
         os.environ['COMPOSE_PROJECT_NAME'] = name
         os.environ['COMPOSE_PROJECT_NAME'] = name
         project_name = get_project_name(None)
         project_name = get_project_name(None)
-        self.assertEquals(project_name, name)
+        self.assertEqual(project_name, name)
 
 
     def test_project_name_with_empty_environment_var(self):
     def test_project_name_with_empty_environment_var(self):
         base_dir = 'tests/fixtures/simple-composefile'
         base_dir = 'tests/fixtures/simple-composefile'
         with mock.patch.dict(os.environ):
         with mock.patch.dict(os.environ):
             os.environ['COMPOSE_PROJECT_NAME'] = ''
             os.environ['COMPOSE_PROJECT_NAME'] = ''
             project_name = get_project_name(base_dir)
             project_name = get_project_name(base_dir)
-        self.assertEquals('simplecomposefile', project_name)
+        self.assertEqual('simplecomposefile', project_name)
 
 
     @mock.patch.dict(os.environ)
     @mock.patch.dict(os.environ)
     def test_project_name_with_environment_file(self):
     def test_project_name_with_environment_file(self):
@@ -119,6 +119,7 @@ class CLITestCase(unittest.TestCase):
                 '--entrypoint': None,
                 '--entrypoint': None,
                 '--service-ports': None,
                 '--service-ports': None,
                 '--publish': [],
                 '--publish': [],
+                '--volume': [],
                 '--rm': None,
                 '--rm': None,
                 '--name': None,
                 '--name': None,
                 '--workdir': None,
                 '--workdir': None,
@@ -153,12 +154,13 @@ class CLITestCase(unittest.TestCase):
             '--entrypoint': None,
             '--entrypoint': None,
             '--service-ports': None,
             '--service-ports': None,
             '--publish': [],
             '--publish': [],
+            '--volume': [],
             '--rm': None,
             '--rm': None,
             '--name': None,
             '--name': None,
             '--workdir': None,
             '--workdir': None,
         })
         })
 
 
-        self.assertEquals(
+        self.assertEqual(
             mock_client.create_host_config.call_args[1]['restart_policy']['Name'],
             mock_client.create_host_config.call_args[1]['restart_policy']['Name'],
             'always'
             'always'
         )
         )
@@ -175,6 +177,7 @@ class CLITestCase(unittest.TestCase):
             '--entrypoint': None,
             '--entrypoint': None,
             '--service-ports': None,
             '--service-ports': None,
             '--publish': [],
             '--publish': [],
+            '--volume': [],
             '--rm': True,
             '--rm': True,
             '--name': None,
             '--name': None,
             '--workdir': None,
             '--workdir': None,
@@ -184,7 +187,7 @@ class CLITestCase(unittest.TestCase):
             mock_client.create_host_config.call_args[1].get('restart_policy')
             mock_client.create_host_config.call_args[1].get('restart_policy')
         )
         )
 
 
-    def test_command_manula_and_service_ports_together(self):
+    def test_command_manual_and_service_ports_together(self):
         project = Project.from_config(
         project = Project.from_config(
             name='composetest',
             name='composetest',
             client=None,
             client=None,

+ 288 - 73
tests/unit/config/config_test.py

@@ -10,23 +10,26 @@ from operator import itemgetter
 
 
 import py
 import py
 import pytest
 import pytest
+import yaml
 
 
 from ...helpers import build_config_details
 from ...helpers import build_config_details
 from compose.config import config
 from compose.config import config
 from compose.config import types
 from compose.config import types
 from compose.config.config import resolve_build_args
 from compose.config.config import resolve_build_args
 from compose.config.config import resolve_environment
 from compose.config.config import resolve_environment
-from compose.config.config import V1
-from compose.config.config import V2_0
-from compose.config.config import V2_1
-from compose.config.config import V3_0
-from compose.config.config import V3_1
 from compose.config.environment import Environment
 from compose.config.environment import Environment
 from compose.config.errors import ConfigurationError
 from compose.config.errors import ConfigurationError
 from compose.config.errors import VERSION_EXPLANATION
 from compose.config.errors import VERSION_EXPLANATION
 from compose.config.serialize import denormalize_service_dict
 from compose.config.serialize import denormalize_service_dict
+from compose.config.serialize import serialize_config
 from compose.config.serialize import serialize_ns_time_value
 from compose.config.serialize import serialize_ns_time_value
 from compose.config.types import VolumeSpec
 from compose.config.types import VolumeSpec
+from compose.const import COMPOSEFILE_V1 as V1
+from compose.const import COMPOSEFILE_V2_0 as V2_0
+from compose.const import COMPOSEFILE_V2_1 as V2_1
+from compose.const import COMPOSEFILE_V3_0 as V3_0
+from compose.const import COMPOSEFILE_V3_1 as V3_1
+from compose.const import COMPOSEFILE_V3_2 as V3_2
 from compose.const import IS_WINDOWS_PLATFORM
 from compose.const import IS_WINDOWS_PLATFORM
 from compose.utils import nanoseconds_from_time_seconds
 from compose.utils import nanoseconds_from_time_seconds
 from tests import mock
 from tests import mock
@@ -59,6 +62,7 @@ def secret_sort(secrets):
 
 
 
 
 class ConfigTest(unittest.TestCase):
 class ConfigTest(unittest.TestCase):
+
     def test_load(self):
     def test_load(self):
         service_dicts = config.load(
         service_dicts = config.load(
             build_config_details(
             build_config_details(
@@ -554,6 +558,20 @@ class ConfigTest(unittest.TestCase):
             excinfo.exconly()
             excinfo.exconly()
         )
         )
 
 
+    def test_config_invalid_service_name_raise_validation_error(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            config.load(
+                build_config_details({
+                    'version': '2',
+                    'services': {
+                        'test_app': {'build': '.'},
+                        'mong\\o': {'image': 'mongo'},
+                    }
+                })
+            )
+
+            assert 'Invalid service name \'mong\\o\'' in excinfo.exconly()
+
     def test_load_with_multiple_files_v1(self):
     def test_load_with_multiple_files_v1(self):
         base_file = config.ConfigFile(
         base_file = config.ConfigFile(
             'base.yaml',
             'base.yaml',
@@ -947,6 +965,44 @@ class ConfigTest(unittest.TestCase):
         ]
         ]
         assert service_sort(service_dicts) == service_sort(expected)
         assert service_sort(service_dicts) == service_sort(expected)
 
 
+    @mock.patch.dict(os.environ)
+    def test_load_with_multiple_files_v3_2(self):
+        os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true'
+        base_file = config.ConfigFile(
+            'base.yaml',
+            {
+                'version': '3.2',
+                'services': {
+                    'web': {
+                        'image': 'example/web',
+                        'volumes': [
+                            {'source': '/a', 'target': '/b', 'type': 'bind'},
+                            {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True}
+                        ]
+                    }
+                },
+                'volumes': {'vol': {}}
+            }
+        )
+
+        override_file = config.ConfigFile(
+            'override.yaml',
+            {
+                'version': '3.2',
+                'services': {
+                    'web': {
+                        'volumes': ['/c:/b', '/anonymous']
+                    }
+                }
+            }
+        )
+        details = config.ConfigDetails('.', [base_file, override_file])
+        service_dicts = config.load(details).services
+        svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes'])
+        assert sorted(svc_volumes) == sorted(
+            ['/anonymous', '/c:/b:rw', 'vol:/x:ro']
+        )
+
     def test_undeclared_volume_v2(self):
     def test_undeclared_volume_v2(self):
         base_file = config.ConfigFile(
         base_file = config.ConfigFile(
             'base.yaml',
             'base.yaml',
@@ -1396,7 +1452,6 @@ class ConfigTest(unittest.TestCase):
         ]
         ]
 
 
     def test_group_add_option(self):
     def test_group_add_option(self):
-
         actual = config.load(build_config_details({
         actual = config.load(build_config_details({
             'version': '2',
             'version': '2',
             'services': {
             'services': {
@@ -1415,6 +1470,25 @@ class ConfigTest(unittest.TestCase):
             }
             }
         ]
         ]
 
 
+    def test_dns_opt_option(self):
+        actual = config.load(build_config_details({
+            'version': '2',
+            'services': {
+                'web': {
+                    'image': 'alpine',
+                    'dns_opt': ["use-vc", "no-tld-query"]
+                }
+            }
+        }))
+
+        assert actual.services == [
+            {
+                'name': 'web',
+                'image': 'alpine',
+                'dns_opt': ["use-vc", "no-tld-query"]
+            }
+        ]
+
     def test_isolation_option(self):
     def test_isolation_option(self):
         actual = config.load(build_config_details({
         actual = config.load(build_config_details({
             'version': V2_1,
             'version': V2_1,
@@ -1471,39 +1545,67 @@ class ConfigTest(unittest.TestCase):
             'extends': {'service': 'foo'}
             'extends': {'service': 'foo'}
         }
         }
 
 
-    def test_merge_build_args(self):
+    def test_merge_service_dicts_heterogeneous(self):
         base = {
         base = {
-            'build': {
-                'context': '.',
-                'args': {
-                    'ONE': '1',
-                    'TWO': '2',
-                },
-            }
+            'volumes': ['.:/app'],
+            'ports': ['5432']
         }
         }
         override = {
         override = {
-            'build': {
-                'args': {
-                    'TWO': 'dos',
-                    'THREE': '3',
-                },
-            }
+            'image': 'alpine:edge',
+            'ports': [5432]
         }
         }
-        actual = config.merge_service_dicts(
+        actual = config.merge_service_dicts_from_files(
             base,
             base,
             override,
             override,
             DEFAULT_VERSION)
             DEFAULT_VERSION)
         assert actual == {
         assert actual == {
-            'build': {
-                'context': '.',
-                'args': {
-                    'ONE': '1',
-                    'TWO': 'dos',
-                    'THREE': '3',
-                },
-            }
+            'image': 'alpine:edge',
+            'volumes': ['.:/app'],
+            'ports': types.ServicePort.parse('5432')
         }
         }
 
 
+    def test_merge_service_dicts_heterogeneous_2(self):
+        base = {
+            'volumes': ['.:/app'],
+            'ports': [5432]
+        }
+        override = {
+            'image': 'alpine:edge',
+            'ports': ['5432']
+        }
+        actual = config.merge_service_dicts_from_files(
+            base,
+            override,
+            DEFAULT_VERSION)
+        assert actual == {
+            'image': 'alpine:edge',
+            'volumes': ['.:/app'],
+            'ports': types.ServicePort.parse('5432')
+        }
+
+    def test_merge_service_dicts_heterogeneous_volumes(self):
+        base = {
+            'volumes': ['/a:/b', '/x:/z'],
+        }
+
+        override = {
+            'image': 'alpine:edge',
+            'volumes': [
+                {'source': '/e', 'target': '/b', 'type': 'bind'},
+                {'source': '/c', 'target': '/d', 'type': 'bind'}
+            ]
+        }
+
+        actual = config.merge_service_dicts_from_files(
+            base, override, V3_2
+        )
+
+        assert actual['volumes'] == [
+            {'source': '/e', 'target': '/b', 'type': 'bind'},
+            {'source': '/c', 'target': '/d', 'type': 'bind'},
+            '/x:/z'
+        ]
+
     def test_merge_logging_v1(self):
     def test_merge_logging_v1(self):
         base = {
         base = {
             'image': 'alpine:edge',
             'image': 'alpine:edge',
@@ -1723,6 +1825,30 @@ class ConfigTest(unittest.TestCase):
             }
             }
         }
         }
 
 
+    def test_merge_mixed_ports(self):
+        base = {
+            'image': 'busybox:latest',
+            'command': 'top',
+            'ports': [
+                {
+                    'target': '1245',
+                    'published': '1245',
+                    'protocol': 'tcp',
+                }
+            ]
+        }
+
+        override = {
+            'ports': ['1245:1245/udp']
+        }
+
+        actual = config.merge_service_dicts(base, override, V3_1)
+        assert actual == {
+            'image': 'busybox:latest',
+            'command': 'top',
+            'ports': [types.ServicePort('1245', '1245', 'udp', None, None)]
+        }
+
     def test_merge_depends_on_no_override(self):
     def test_merge_depends_on_no_override(self):
         base = {
         base = {
             'image': 'busybox',
             'image': 'busybox',
@@ -1757,6 +1883,23 @@ class ConfigTest(unittest.TestCase):
             }
             }
         }
         }
 
 
+    def test_empty_environment_key_allowed(self):
+        service_dict = config.load(
+            build_config_details(
+                {
+                    'web': {
+                        'build': '.',
+                        'environment': {
+                            'POSTGRES_PASSWORD': ''
+                        },
+                    },
+                },
+                '.',
+                None,
+            )
+        ).services[0]
+        self.assertEqual(service_dict['environment']['POSTGRES_PASSWORD'], '')
+
     def test_merge_pid(self):
     def test_merge_pid(self):
         # Regression: https://github.com/docker/compose/issues/4184
         # Regression: https://github.com/docker/compose/issues/4184
         base = {
         base = {
@@ -1973,6 +2116,7 @@ class ConfigTest(unittest.TestCase):
 
 
 
 
 class NetworkModeTest(unittest.TestCase):
 class NetworkModeTest(unittest.TestCase):
+
     def test_network_mode_standard(self):
     def test_network_mode_standard(self):
         config_data = config.load(build_config_details({
         config_data = config.load(build_config_details({
             'version': '2',
             'version': '2',
@@ -2184,6 +2328,7 @@ class PortsTest(unittest.TestCase):
 
 
 
 
 class InterpolationTest(unittest.TestCase):
 class InterpolationTest(unittest.TestCase):
+
     @mock.patch.dict(os.environ)
     @mock.patch.dict(os.environ)
     def test_config_file_with_environment_file(self):
     def test_config_file_with_environment_file(self):
         project_dir = 'tests/fixtures/default-env-file'
         project_dir = 'tests/fixtures/default-env-file'
@@ -2196,7 +2341,10 @@ class InterpolationTest(unittest.TestCase):
         self.assertEqual(service_dicts[0], {
         self.assertEqual(service_dicts[0], {
             'name': 'web',
             'name': 'web',
             'image': 'alpine:latest',
             'image': 'alpine:latest',
-            'ports': ['5643', '9999'],
+            'ports': [
+                types.ServicePort.parse('5643')[0],
+                types.ServicePort.parse('9999')[0]
+            ],
             'command': 'true'
             'command': 'true'
         })
         })
 
 
@@ -2219,7 +2367,7 @@ class InterpolationTest(unittest.TestCase):
             {
             {
                 'name': 'web',
                 'name': 'web',
                 'image': 'busybox',
                 'image': 'busybox',
-                'ports': ['80:8000'],
+                'ports': types.ServicePort.parse('80:8000'),
                 'labels': {'mylabel': 'myvalue'},
                 'labels': {'mylabel': 'myvalue'},
                 'hostname': 'host-',
                 'hostname': 'host-',
                 'command': '${ESCAPED}',
                 'command': '${ESCAPED}',
@@ -2266,25 +2414,27 @@ class InterpolationTest(unittest.TestCase):
         self.assertIn('in service "web"', cm.exception.msg)
         self.assertIn('in service "web"', cm.exception.msg)
         self.assertIn('"${"', cm.exception.msg)
         self.assertIn('"${"', cm.exception.msg)
 
 
-    def test_empty_environment_key_allowed(self):
-        service_dict = config.load(
-            build_config_details(
-                {
-                    'web': {
-                        'build': '.',
-                        'environment': {
-                            'POSTGRES_PASSWORD': ''
-                        },
-                    },
-                },
-                '.',
-                None,
-            )
-        ).services[0]
-        self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '')
+    @mock.patch.dict(os.environ)
+    def test_interpolation_secrets_section(self):
+        os.environ['FOO'] = 'baz.bar'
+        config_dict = config.load(build_config_details({
+            'version': '3.1',
+            'secrets': {
+                'secretdata': {
+                    'external': {'name': '$FOO'}
+                }
+            }
+        }))
+        assert config_dict.secrets == {
+            'secretdata': {
+                'external': {'name': 'baz.bar'},
+                'external_name': 'baz.bar'
+            }
+        }
 
 
 
 
 class VolumeConfigTest(unittest.TestCase):
 class VolumeConfigTest(unittest.TestCase):
+
     def test_no_binding(self):
     def test_no_binding(self):
         d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.')
         d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.')
         self.assertEqual(d['volumes'], ['/data'])
         self.assertEqual(d['volumes'], ['/data'])
@@ -2429,6 +2579,7 @@ class MergeDevicesTest(unittest.TestCase, MergePathMappingTest):
 
 
 
 
 class BuildOrImageMergeTest(unittest.TestCase):
 class BuildOrImageMergeTest(unittest.TestCase):
+
     def test_merge_build_or_image_no_override(self):
     def test_merge_build_or_image_no_override(self):
         self.assertEqual(
         self.assertEqual(
             config.merge_service_dicts({'build': '.'}, {}, V1),
             config.merge_service_dicts({'build': '.'}, {}, V1),
@@ -2501,13 +2652,37 @@ class MergePortsTest(unittest.TestCase, MergeListsTest):
     base_config = ['10:8000', '9000']
     base_config = ['10:8000', '9000']
     override_config = ['20:8000']
     override_config = ['20:8000']
 
 
+    def merged_config(self):
+        return self.convert(self.base_config) | self.convert(self.override_config)
+
+    def convert(self, port_config):
+        return set(config.merge_service_dicts(
+            {self.config_name: port_config},
+            {self.config_name: []},
+            DEFAULT_VERSION
+        )[self.config_name])
+
     def test_duplicate_port_mappings(self):
     def test_duplicate_port_mappings(self):
         service_dict = config.merge_service_dicts(
         service_dict = config.merge_service_dicts(
             {self.config_name: self.base_config},
             {self.config_name: self.base_config},
             {self.config_name: self.base_config},
             {self.config_name: self.base_config},
             DEFAULT_VERSION
             DEFAULT_VERSION
         )
         )
-        assert set(service_dict[self.config_name]) == set(self.base_config)
+        assert set(service_dict[self.config_name]) == self.convert(self.base_config)
+
+    def test_no_override(self):
+        service_dict = config.merge_service_dicts(
+            {self.config_name: self.base_config},
+            {},
+            DEFAULT_VERSION)
+        assert set(service_dict[self.config_name]) == self.convert(self.base_config)
+
+    def test_no_base(self):
+        service_dict = config.merge_service_dicts(
+            {},
+            {self.config_name: self.base_config},
+            DEFAULT_VERSION)
+        assert set(service_dict[self.config_name]) == self.convert(self.base_config)
 
 
 
 
 class MergeNetworksTest(unittest.TestCase, MergeListsTest):
 class MergeNetworksTest(unittest.TestCase, MergeListsTest):
@@ -2517,6 +2692,7 @@ class MergeNetworksTest(unittest.TestCase, MergeListsTest):
 
 
 
 
 class MergeStringsOrListsTest(unittest.TestCase):
 class MergeStringsOrListsTest(unittest.TestCase):
+
     def test_no_override(self):
     def test_no_override(self):
         service_dict = config.merge_service_dicts(
         service_dict = config.merge_service_dicts(
             {'dns': '8.8.8.8'},
             {'dns': '8.8.8.8'},
@@ -2547,6 +2723,7 @@ class MergeStringsOrListsTest(unittest.TestCase):
 
 
 
 
 class MergeLabelsTest(unittest.TestCase):
 class MergeLabelsTest(unittest.TestCase):
+
     def test_empty(self):
     def test_empty(self):
         assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION)
         assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION)
 
 
@@ -2587,6 +2764,7 @@ class MergeLabelsTest(unittest.TestCase):
 
 
 
 
 class MemoryOptionsTest(unittest.TestCase):
 class MemoryOptionsTest(unittest.TestCase):
+
     def test_validation_fails_with_just_memswap_limit(self):
     def test_validation_fails_with_just_memswap_limit(self):
         """
         """
         When you set a 'memswap_limit' it is invalid config unless you also set
         When you set a 'memswap_limit' it is invalid config unless you also set
@@ -2629,6 +2807,7 @@ class MemoryOptionsTest(unittest.TestCase):
 
 
 
 
 class EnvTest(unittest.TestCase):
 class EnvTest(unittest.TestCase):
+
     def test_parse_environment_as_list(self):
     def test_parse_environment_as_list(self):
         environment = [
         environment = [
             'NORMAL=F1',
             'NORMAL=F1',
@@ -2745,7 +2924,7 @@ class EnvTest(unittest.TestCase):
             }
             }
         }
         }
         self.assertEqual(
         self.assertEqual(
-            resolve_build_args(build, Environment.from_env_file(build['context'])),
+            resolve_build_args(build['args'], Environment.from_env_file(build['context'])),
             {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None},
             {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None},
         )
         )
 
 
@@ -2776,13 +2955,14 @@ class EnvTest(unittest.TestCase):
             set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')]))
             set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')]))
 
 
 
 
-def load_from_filename(filename):
+def load_from_filename(filename, override_dir=None):
     return config.load(
     return config.load(
-        config.find('.', [filename], Environment.from_env_file('.'))
+        config.find('.', [filename], Environment.from_env_file('.'), override_dir=override_dir)
     ).services
     ).services
 
 
 
 
 class ExtendsTest(unittest.TestCase):
 class ExtendsTest(unittest.TestCase):
+
     def test_extends(self):
     def test_extends(self):
         service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml')
         service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml')
 
 
@@ -2974,9 +3154,9 @@ class ExtendsTest(unittest.TestCase):
             )
             )
         ).services
         ).services
 
 
-        self.assertEquals(len(service), 1)
+        self.assertEqual(len(service), 1)
         self.assertIsInstance(service[0], dict)
         self.assertIsInstance(service[0], dict)
-        self.assertEquals(service[0]['command'], "/bin/true")
+        self.assertEqual(service[0]['command'], "/bin/true")
 
 
     def test_extended_service_with_invalid_config(self):
     def test_extended_service_with_invalid_config(self):
         with pytest.raises(ConfigurationError) as exc:
         with pytest.raises(ConfigurationError) as exc:
@@ -2988,7 +3168,7 @@ class ExtendsTest(unittest.TestCase):
 
 
     def test_extended_service_with_valid_config(self):
     def test_extended_service_with_valid_config(self):
         service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml')
         service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml')
-        self.assertEquals(service[0]['command'], "top")
+        self.assertEqual(service[0]['command'], "top")
 
 
     def test_extends_file_defaults_to_self(self):
     def test_extends_file_defaults_to_self(self):
         """
         """
@@ -3220,7 +3400,7 @@ class ExtendsTest(unittest.TestCase):
         """)
         """)
 
 
         service = load_from_filename(str(tmpdir.join('docker-compose.yml')))
         service = load_from_filename(str(tmpdir.join('docker-compose.yml')))
-        self.assertEquals(service[0]['command'], "top")
+        self.assertEqual(service[0]['command'], "top")
 
 
     def test_extends_with_depends_on(self):
     def test_extends_with_depends_on(self):
         tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
         tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
@@ -3279,6 +3459,7 @@ class ExpandPathTest(unittest.TestCase):
 
 
 
 
 class VolumePathTest(unittest.TestCase):
 class VolumePathTest(unittest.TestCase):
+
     def test_split_path_mapping_with_windows_path(self):
     def test_split_path_mapping_with_windows_path(self):
         host_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config"
         host_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config"
         windows_volume_path = host_path + ":/opt/connect/config:ro"
         windows_volume_path = host_path + ":/opt/connect/config:ro"
@@ -3305,6 +3486,7 @@ class VolumePathTest(unittest.TestCase):
 
 
 @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
 @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
 class BuildPathTest(unittest.TestCase):
 class BuildPathTest(unittest.TestCase):
+
     def setUp(self):
     def setUp(self):
         self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx')
         self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx')
 
 
@@ -3327,7 +3509,7 @@ class BuildPathTest(unittest.TestCase):
             {'build': relative_build_path},
             {'build': relative_build_path},
             working_dir='tests/fixtures/build-path'
             working_dir='tests/fixtures/build-path'
         )
         )
-        self.assertEquals(service_dict['build'], self.abs_context_path)
+        self.assertEqual(service_dict['build'], self.abs_context_path)
 
 
     def test_absolute_path(self):
     def test_absolute_path(self):
         service_dict = make_service_dict(
         service_dict = make_service_dict(
@@ -3335,10 +3517,16 @@ class BuildPathTest(unittest.TestCase):
             {'build': self.abs_context_path},
             {'build': self.abs_context_path},
             working_dir='tests/fixtures/build-path'
             working_dir='tests/fixtures/build-path'
         )
         )
-        self.assertEquals(service_dict['build'], self.abs_context_path)
+        self.assertEqual(service_dict['build'], self.abs_context_path)
 
 
     def test_from_file(self):
     def test_from_file(self):
         service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml')
         service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml')
+        self.assertEqual(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}])
+
+    def test_from_file_override_dir(self):
+        override_dir = os.path.join(os.getcwd(), 'tests/fixtures/')
+        service_dict = load_from_filename(
+            'tests/fixtures/build-path-override-dir/docker-compose.yml', override_dir=override_dir)
         self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}])
         self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}])
 
 
     def test_valid_url_in_build_path(self):
     def test_valid_url_in_build_path(self):
@@ -3528,23 +3716,50 @@ class SerializeTest(unittest.TestCase):
         assert denormalized_service['healthcheck']['interval'] == '100s'
         assert denormalized_service['healthcheck']['interval'] == '100s'
         assert denormalized_service['healthcheck']['timeout'] == '30s'
         assert denormalized_service['healthcheck']['timeout'] == '30s'
 
 
-    def test_denormalize_secrets(self):
+    def test_denormalize_image_has_digest(self):
+        service_dict = {
+            'image': 'busybox'
+        }
+        image_digest = 'busybox@sha256:abcde'
+
+        assert denormalize_service_dict(service_dict, V3_0, image_digest) == {
+            'image': 'busybox@sha256:abcde'
+        }
+
+    def test_denormalize_image_no_digest(self):
+        service_dict = {
+            'image': 'busybox'
+        }
+
+        assert denormalize_service_dict(service_dict, V3_0) == {
+            'image': 'busybox'
+        }
+
+    def test_serialize_secrets(self):
         service_dict = {
         service_dict = {
-            'name': 'web',
             'image': 'example/web',
             'image': 'example/web',
             'secrets': [
             'secrets': [
-                types.ServiceSecret('one', None, None, None, None),
-                types.ServiceSecret('source', 'target', '100', '200', 0o777),
-            ],
+                {'source': 'one'},
+                {
+                    'source': 'source',
+                    'target': 'target',
+                    'uid': '100',
+                    'gid': '200',
+                    'mode': 0o777,
+                }
+            ]
         }
         }
-        denormalized_service = denormalize_service_dict(service_dict, V3_1)
-        assert secret_sort(denormalized_service['secrets']) == secret_sort([
-            {'source': 'one'},
-            {
-                'source': 'source',
-                'target': 'target',
-                'uid': '100',
-                'gid': '200',
-                'mode': 0o777,
-            },
-        ])
+        secrets_dict = {
+            'one': {'file': '/one.txt'},
+            'source': {'file': '/source.pem'}
+        }
+        config_dict = config.load(build_config_details({
+            'version': '3.1',
+            'services': {'web': service_dict},
+            'secrets': secrets_dict
+        }))
+
+        serialized_config = yaml.load(serialize_config(config_dict))
+        serialized_service = serialized_config['services']['web']
+        assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets'])
+        assert 'secrets' in serialized_config

+ 26 - 1
tests/unit/config/interpolation_test.py

@@ -75,7 +75,32 @@ def test_interpolate_environment_variables_in_volumes(mock_env):
         },
         },
         'other': {},
         'other': {},
     }
     }
-    value = interpolate_environment_variables("2.0",  volumes, 'volume', mock_env)
+    value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env)
+    assert value == expected
+
+
+def test_interpolate_environment_variables_in_secrets(mock_env):
+    secrets = {
+        'secretservice': {
+            'file': '$FOO',
+            'labels': {
+                'max': 2,
+                'user': '${USER}'
+            }
+        },
+        'other': None,
+    }
+    expected = {
+        'secretservice': {
+            'file': 'bar',
+            'labels': {
+                'max': 2,
+                'user': 'jenny'
+            }
+        },
+        'other': {},
+    }
+    value = interpolate_environment_variables("3.1", secrets, 'volume', mock_env)
     assert value == expected
     assert value == expected
 
 
 
 

+ 46 - 2
tests/unit/config/types_test.py

@@ -3,12 +3,13 @@ from __future__ import unicode_literals
 
 
 import pytest
 import pytest
 
 
-from compose.config.config import V1
-from compose.config.config import V2_0
 from compose.config.errors import ConfigurationError
 from compose.config.errors import ConfigurationError
 from compose.config.types import parse_extra_hosts
 from compose.config.types import parse_extra_hosts
+from compose.config.types import ServicePort
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeSpec
 from compose.config.types import VolumeSpec
+from compose.const import COMPOSEFILE_V1 as V1
+from compose.const import COMPOSEFILE_V2_0 as V2_0
 
 
 
 
 def test_parse_extra_hosts_list():
 def test_parse_extra_hosts_list():
@@ -41,6 +42,49 @@ def test_parse_extra_hosts_dict():
     }
     }
 
 
 
 
+class TestServicePort(object):
+    def test_parse_dict(self):
+        data = {
+            'target': 8000,
+            'published': 8000,
+            'protocol': 'udp',
+            'mode': 'global',
+        }
+        ports = ServicePort.parse(data)
+        assert len(ports) == 1
+        assert ports[0].repr() == data
+
+    def test_parse_simple_target_port(self):
+        ports = ServicePort.parse(8000)
+        assert len(ports) == 1
+        assert ports[0].target == '8000'
+
+    def test_parse_complete_port_definition(self):
+        port_def = '1.1.1.1:3000:3000/udp'
+        ports = ServicePort.parse(port_def)
+        assert len(ports) == 1
+        assert ports[0].repr() == {
+            'target': '3000',
+            'published': '3000',
+            'external_ip': '1.1.1.1',
+            'protocol': 'udp',
+        }
+        assert ports[0].legacy_repr() == port_def
+
+    def test_parse_port_range(self):
+        ports = ServicePort.parse('25000-25001:4000-4001')
+        assert len(ports) == 2
+        reprs = [p.repr() for p in ports]
+        assert {
+            'target': '4000',
+            'published': '25000'
+        } in reprs
+        assert {
+            'target': '4001',
+            'published': '25001'
+        } in reprs
+
+
 class TestVolumeSpec(object):
 class TestVolumeSpec(object):
 
 
     def test_parse_volume_spec_only_one_path(self):
     def test_parse_volume_spec_only_one_path(self):

+ 69 - 5
tests/unit/network_test.py

@@ -4,20 +4,62 @@ from __future__ import unicode_literals
 import pytest
 import pytest
 
 
 from .. import unittest
 from .. import unittest
-from compose.config import ConfigurationError
 from compose.network import check_remote_network_config
 from compose.network import check_remote_network_config
 from compose.network import Network
 from compose.network import Network
+from compose.network import NetworkConfigChangedError
 
 
 
 
 class NetworkTest(unittest.TestCase):
 class NetworkTest(unittest.TestCase):
     def test_check_remote_network_config_success(self):
     def test_check_remote_network_config_success(self):
         options = {'com.docker.network.driver.foo': 'bar'}
         options = {'com.docker.network.driver.foo': 'bar'}
+        ipam_config = {
+            'driver': 'default',
+            'config': [
+                {'subnet': '172.0.0.1/16', },
+                {
+                    'subnet': '156.0.0.1/25',
+                    'gateway': '156.0.0.1',
+                    'aux_addresses': ['11.0.0.1', '24.25.26.27'],
+                    'ip_range': '156.0.0.1-254'
+                }
+            ]
+        }
+        labels = {
+            'com.project.tests.istest': 'true',
+            'com.project.sound.track': 'way out of here',
+        }
+        remote_labels = labels.copy()
+        remote_labels.update({
+            'com.docker.compose.project': 'compose_test',
+            'com.docker.compose.network': 'net1',
+        })
         net = Network(
         net = Network(
             None, 'compose_test', 'net1', 'bridge',
             None, 'compose_test', 'net1', 'bridge',
-            options
+            options, enable_ipv6=True, ipam=ipam_config,
+            labels=labels
         )
         )
         check_remote_network_config(
         check_remote_network_config(
-            {'Driver': 'bridge', 'Options': options}, net
+            {
+                'Driver': 'bridge',
+                'Options': options,
+                'EnableIPv6': True,
+                'Internal': False,
+                'Attachable': True,
+                'IPAM': {
+                    'Driver': 'default',
+                    'Config': [{
+                        'Subnet': '156.0.0.1/25',
+                        'Gateway': '156.0.0.1',
+                        'AuxiliaryAddresses': ['24.25.26.27', '11.0.0.1'],
+                        'IPRange': '156.0.0.1-254'
+                    }, {
+                        'Subnet': '172.0.0.1/16',
+                        'Gateway': '172.0.0.1'
+                    }],
+                },
+                'Labels': remote_labels
+            },
+            net
         )
         )
 
 
     def test_check_remote_network_config_whitelist(self):
     def test_check_remote_network_config_whitelist(self):
@@ -36,20 +78,42 @@ class NetworkTest(unittest.TestCase):
 
 
     def test_check_remote_network_config_driver_mismatch(self):
     def test_check_remote_network_config_driver_mismatch(self):
         net = Network(None, 'compose_test', 'net1', 'overlay')
         net = Network(None, 'compose_test', 'net1', 'overlay')
-        with pytest.raises(ConfigurationError):
+        with pytest.raises(NetworkConfigChangedError) as e:
             check_remote_network_config(
             check_remote_network_config(
                 {'Driver': 'bridge', 'Options': {}}, net
                 {'Driver': 'bridge', 'Options': {}}, net
             )
             )
 
 
+        assert 'driver has changed' in str(e.value)
+
     def test_check_remote_network_config_options_mismatch(self):
     def test_check_remote_network_config_options_mismatch(self):
         net = Network(None, 'compose_test', 'net1', 'overlay')
         net = Network(None, 'compose_test', 'net1', 'overlay')
-        with pytest.raises(ConfigurationError):
+        with pytest.raises(NetworkConfigChangedError) as e:
             check_remote_network_config({'Driver': 'overlay', 'Options': {
             check_remote_network_config({'Driver': 'overlay', 'Options': {
                 'com.docker.network.driver.foo': 'baz'
                 'com.docker.network.driver.foo': 'baz'
             }}, net)
             }}, net)
 
 
+        assert 'option "com.docker.network.driver.foo" has changed' in str(e.value)
+
     def test_check_remote_network_config_null_remote(self):
     def test_check_remote_network_config_null_remote(self):
         net = Network(None, 'compose_test', 'net1', 'overlay')
         net = Network(None, 'compose_test', 'net1', 'overlay')
         check_remote_network_config(
         check_remote_network_config(
             {'Driver': 'overlay', 'Options': None}, net
             {'Driver': 'overlay', 'Options': None}, net
         )
         )
+
+    def test_check_remote_network_labels_mismatch(self):
+        net = Network(None, 'compose_test', 'net1', 'overlay', labels={
+            'com.project.touhou.character': 'sakuya.izayoi'
+        })
+        remote = {
+            'Driver': 'overlay',
+            'Options': None,
+            'Labels': {
+                'com.docker.compose.network': 'net1',
+                'com.docker.compose.project': 'compose_test',
+                'com.project.touhou.character': 'marisa.kirisame',
+            }
+        }
+        with pytest.raises(NetworkConfigChangedError) as e:
+            check_remote_network_config(remote, net)
+
+        assert 'label "com.project.touhou.character" has changed' in str(e.value)

+ 27 - 1
tests/unit/parallel_test.py

@@ -1,6 +1,8 @@
 from __future__ import absolute_import
 from __future__ import absolute_import
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+from threading import Lock
+
 import six
 import six
 from docker.errors import APIError
 from docker.errors import APIError
 
 
@@ -40,6 +42,30 @@ def test_parallel_execute():
     assert errors == {}
     assert errors == {}
 
 
 
 
+def test_parallel_execute_with_limit():
+    limit = 1
+    tasks = 20
+    lock = Lock()
+
+    def f(obj):
+        locked = lock.acquire(False)
+        # we should always get the lock because we're the only thread running
+        assert locked
+        lock.release()
+        return None
+
+    results, errors = parallel_execute(
+        objects=list(range(tasks)),
+        func=f,
+        get_name=six.text_type,
+        msg="Testing",
+        limit=limit,
+    )
+
+    assert results == tasks*[None]
+    assert errors == {}
+
+
 def test_parallel_execute_with_deps():
 def test_parallel_execute_with_deps():
     log = []
     log = []
 
 
@@ -82,7 +108,7 @@ def test_parallel_execute_with_upstream_errors():
     events = [
     events = [
         (obj, result, type(exception))
         (obj, result, type(exception))
         for obj, result, exception
         for obj, result, exception
-        in parallel_execute_iter(objects, process, get_deps)
+        in parallel_execute_iter(objects, process, get_deps, None)
     ]
     ]
 
 
     assert (cache, None, type(None)) in events
     assert (cache, None, type(None)) in events

+ 65 - 2
tests/unit/service_test.py

@@ -7,6 +7,8 @@ from docker.errors import APIError
 
 
 from .. import mock
 from .. import mock
 from .. import unittest
 from .. import unittest
+from compose.config.errors import DependencyError
+from compose.config.types import ServicePort
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeFromSpec
 from compose.config.types import VolumeSpec
 from compose.config.types import VolumeSpec
 from compose.const import LABEL_CONFIG_HASH
 from compose.const import LABEL_CONFIG_HASH
@@ -19,6 +21,7 @@ from compose.service import build_ulimits
 from compose.service import build_volume_binding
 from compose.service import build_volume_binding
 from compose.service import BuildAction
 from compose.service import BuildAction
 from compose.service import ContainerNetworkMode
 from compose.service import ContainerNetworkMode
+from compose.service import formatted_ports
 from compose.service import get_container_data_volumes
 from compose.service import get_container_data_volumes
 from compose.service import ImageType
 from compose.service import ImageType
 from compose.service import merge_volume_bindings
 from compose.service import merge_volume_bindings
@@ -168,6 +171,28 @@ class ServiceTest(unittest.TestCase):
             2000000000
             2000000000
         )
         )
 
 
+    def test_self_reference_external_link(self):
+        service = Service(
+            name='foo',
+            external_links=['default_foo_1']
+        )
+        with self.assertRaises(DependencyError):
+            service.get_container_name(1)
+
+    def test_mem_reservation(self):
+        self.mock_client.create_host_config.return_value = {}
+
+        service = Service(
+            name='foo',
+            image='foo',
+            hostname='name',
+            client=self.mock_client,
+            mem_reservation='512m'
+        )
+        service._get_container_create_options({'some': 'overrides'}, 1)
+        assert self.mock_client.create_host_config.called is True
+        assert self.mock_client.create_host_config.call_args[1]['mem_reservation'] == '512m'
+
     def test_cgroup_parent(self):
     def test_cgroup_parent(self):
         self.mock_client.create_host_config.return_value = {}
         self.mock_client.create_host_config.return_value = {}
 
 
@@ -445,7 +470,8 @@ class ServiceTest(unittest.TestCase):
             forcerm=False,
             forcerm=False,
             nocache=False,
             nocache=False,
             rm=True,
             rm=True,
-            buildargs=None,
+            buildargs={},
+            cache_from=None,
         )
         )
 
 
     def test_ensure_image_exists_no_build(self):
     def test_ensure_image_exists_no_build(self):
@@ -481,7 +507,8 @@ class ServiceTest(unittest.TestCase):
             forcerm=False,
             forcerm=False,
             nocache=False,
             nocache=False,
             rm=True,
             rm=True,
-            buildargs=None,
+            buildargs={},
+            cache_from=None,
         )
         )
 
 
     def test_build_does_not_pull(self):
     def test_build_does_not_pull(self):
@@ -495,6 +522,23 @@ class ServiceTest(unittest.TestCase):
         self.assertEqual(self.mock_client.build.call_count, 1)
         self.assertEqual(self.mock_client.build.call_count, 1)
         self.assertFalse(self.mock_client.build.call_args[1]['pull'])
         self.assertFalse(self.mock_client.build.call_args[1]['pull'])
 
 
+    def test_build_with_override_build_args(self):
+        self.mock_client.build.return_value = [
+            b'{"stream": "Successfully built 12345"}',
+        ]
+
+        build_args = {
+            'arg1': 'arg1_new_value',
+        }
+        service = Service('foo', client=self.mock_client,
+                          build={'context': '.', 'args': {'arg1': 'arg1', 'arg2': 'arg2'}})
+        service.build(build_args_override=build_args)
+
+        called_build_args = self.mock_client.build.call_args[1]['buildargs']
+
+        assert called_build_args['arg1'] == build_args['arg1']
+        assert called_build_args['arg2'] == 'arg2'
+
     def test_config_dict(self):
     def test_config_dict(self):
         self.mock_client.inspect_image.return_value = {'Id': 'abcd'}
         self.mock_client.inspect_image.return_value = {'Id': 'abcd'}
         service = Service(
         service = Service(
@@ -776,6 +820,25 @@ class NetTestCase(unittest.TestCase):
         self.assertEqual(network_mode.service_name, service_name)
         self.assertEqual(network_mode.service_name, service_name)
 
 
 
 
+class ServicePortsTest(unittest.TestCase):
+    def test_formatted_ports(self):
+        ports = [
+            '3000',
+            '0.0.0.0:4025-4030:23000-23005',
+            ServicePort(6000, None, None, None, None),
+            ServicePort(8080, 8080, None, None, None),
+            ServicePort('20000', '20000', 'udp', 'ingress', None),
+            ServicePort(30000, '30000', 'tcp', None, '127.0.0.1'),
+        ]
+        formatted = formatted_ports(ports)
+        assert ports[0] in formatted
+        assert ports[1] in formatted
+        assert '6000/tcp' in formatted
+        assert '8080:8080/tcp' in formatted
+        assert '20000:20000/udp' in formatted
+        assert '127.0.0.1:30000:30000/tcp' in formatted
+
+
 def build_mount(destination, source, mode='rw'):
 def build_mount(destination, source, mode='rw'):
     return {'Source': source, 'Destination': destination, 'Mode': mode}
     return {'Source': source, 'Destination': destination, 'Mode': mode}