diff options
author | Joffrey F <f.joffrey@gmail.com> | 2018-04-02 14:57:03 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-04-02 14:57:03 -0700 |
commit | 9cc30ad0e940453228d6d968e4827cae34eda770 (patch) | |
tree | b83009b4d0674bf56fcd69ec74b4c819a7a25dfb | |
parent | a2f685927a980604aa82e039a11c9c77cdc53d18 (diff) | |
parent | 1d329808cc5c8b6f97a7fd23503f0a48c432f302 (diff) |
Merge pull request #5844 from docker/bump-1.21.0-rc1
Bump 1.21.0 RC1
38 files changed, 1329 insertions, 143 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml index 66a87d1d..d422fdcc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,11 +6,11 @@ jobs: steps: - checkout - run: - name: install python3 - command: brew update > /dev/null && brew upgrade python + name: setup script + command: ./script/setup/osx - run: name: install tox - command: sudo pip3 install --upgrade tox==2.1.1 + command: sudo pip install --upgrade tox==2.1.1 - run: name: unit tests command: tox -e py27,py36 -- tests/unit diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b18d0c8..a8e0b8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,70 @@ Change log ========== +1.21.0 (2018-04-11) +------------------- + +### New features + +#### Compose file version 2.4 + +- Introduced version 2.4 of the `docker-compose.yml` specification. + This version requires Docker Engine 17.12.0 or above. + +- Added support for the `platform` parameter in service definitions. + If supplied, the parameter is also used when performing build for the + service. + +#### Compose file version 2.2 and up + +- Added support for the `cpu_rt_period` and `cpu_rt_runtime` parameters + in service definitions (2.x only). + +#### Compose file version 2.1 and up + +- Added support for the `cpu_period` parameter in service definitions + (2.x only). + +- Added support for the `isolation` parameter in service build configurations. + Additionally, the `isolation` parameter is used for builds as well if no + `build.isolation` parameter is defined. (2.x only) + +#### All formats + +- Added support for the `--workdir` flag in `docker-compose exec`. + +- Added support for the `--compress` flag in `docker-compose build`. + +- `docker-compose pull` is now performed in parallel by default. You can + opt out using the `--no-parallel` flag. The `--parallel` flag is now + deprecated and will be removed in a future version. + +- Dashes and underscores in project names are no longer stripped out. + +- `docker-compose build` now supports the use of Dockerfile from outside + the build context. + +### Bugfixes + +- Compose now checks that the volume's configuration matches the remote + volume, and errors out if a mismatch is detected. + +- Fixed a bug that caused Compose to raise unexpected errors when attempting + to create several one-off containers in parallel. + +- Fixed a bug with argument parsing when using `docker-machine config` to + generate TLS flags for `exec` and `run` commands. + +- Fixed a bug where variable substitution with an empty default value + (e.g. `${VAR:-}`) would print an incorrect warning. + +- Improved resilience when encoding of the Compose file doesn't match the + system's. Users are encouraged to use UTF-8 when possible. + +- Fixed a bug where external overlay networks in Swarm would be incorrectly + recognized as inexistent by Compose, interrupting otherwise valid + operations. + 1.20.1 (2018-03-21) ------------------- @@ -17,7 +81,7 @@ Change log #### Compose file version 3.6 - Introduced version 3.6 of the `docker-compose.yml` specification. - This version requires to be used with Docker Engine 18.02.0 or above. + This version requires Docker Engine 18.02.0 or above. - Added support for the `tmpfs.size` property in volume mappings diff --git a/Dockerfile.run b/Dockerfile.run index 04acf01c..c403ac23 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -3,12 +3,13 @@ FROM alpine:3.6 ENV GLIBC 2.27-r0 ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 -RUN apk update && apk add --no-cache openssl ca-certificates curl && \ +RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ curl -fsSL -o glibc-$GLIBC.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ apk add --no-cache 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 && \ + ln -s /usr/lib/libgcc_s.so.1 /usr/glibc-compat/lib && \ curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.1-ce.tgz" && \ echo "${DOCKERBINS_SHA} dockerbins.tgz" | sha256sum -c - && \ tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ diff --git a/MAINTAINERS b/MAINTAINERS index 89f5b412..7aedd46e 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -11,11 +11,29 @@ [Org] [Org."Core maintainers"] people = [ + "mefyl", + "mnottale", + "shin-", + ] + [Org.Alumni] + people = [ + # Aanand Prasad is one of the two creators of the fig project + # which later went on to become docker-compose, and a longtime + # maintainer responsible for several keystone features "aanand", + # Ben Firshman is also one of the fig creators and contributed + # heavily to the project's design and UX as well as the + # day-to-day maintenance "bfirsh", - "dnephin", + # Mazz Mosley made significant contributions to the project + # in 2015 with solid bugfixes and improved error handling + # among them "mnowster", - "shin-", + # Daniel Nephin is one of the longest-running maitainers on + # the Compose project, and has contributed several major features + # including muti-file support, variable interpolation, secrets + # emulation and many more + "dnephin", ] [people] @@ -41,6 +59,16 @@ Email = "dnephin@gmail.com" GitHub = "dnephin" + [people.mefyl] + Name = "Quentin Hocquet" + Email = "quentin.hocquet@docker.com" + GitHub = "mefyl" + + [people.mnottale] + Name = "Matthieu Nottale" + Email = "matthieu.nottale@docker.com" + GitHub = "mnottale" + [people.mnowster] Name = "Mazz Mosley" Email = "mazz@houseofmnowster.com" diff --git a/compose/__init__.py b/compose/__init__.py index 5706c78e..d7ed9198 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.20.1' +__version__ = '1.21.0-rc1' diff --git a/compose/cli/command.py b/compose/cli/command.py index 9fd941bb..8a32a93a 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -122,12 +122,14 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client) + return Project.from_config( + project_name, config_data, client, environment.get('DOCKER_DEFAULT_PLATFORM') + ) def get_project_name(working_dir, project_name=None, environment=None): def normalize_name(name): - return re.sub(r'[^a-z0-9]', '', name.lower()) + return re.sub(r'[^-_a-z0-9]', '', name.lower()) if not environment: environment = Environment.from_env_file(working_dir) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5e077abe..a9720583 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -254,6 +254,7 @@ class TopLevelCommand(object): Usage: build [options] [--build-arg key=val...] [SERVICE...] Options: + --compress Compress the build context using gzip. --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. @@ -277,7 +278,9 @@ class TopLevelCommand(object): pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False)), memory=options.get('--memory'), - build_args=build_args) + build_args=build_args, + gzip=options.get('--compress', False), + ) def bundle(self, options): """ @@ -459,6 +462,7 @@ class TopLevelCommand(object): instances of a service [default: 1] -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) + -w, --workdir DIR Path to workdir directory for this command. """ environment = Environment.from_env_file(self.project_dir) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') @@ -467,7 +471,12 @@ class TopLevelCommand(object): detach = options.get('--detach') if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'): - raise UserError("Setting environment for exec is not supported in API < 1.25'") + raise UserError("Setting environment for exec is not supported in API < 1.25 (%s)" + % self.project.client.api_version) + + if options['--workdir'] and docker.utils.version_lt(self.project.client.api_version, '1.35'): + raise UserError("Setting workdir for exec is not supported in API < 1.35 (%s)" + % self.project.client.api_version) try: container = service.get_container(number=index) @@ -487,6 +496,7 @@ class TopLevelCommand(object): "user": options["--user"], "tty": tty, "stdin": True, + "workdir": options["--workdir"], } if docker.utils.version_gte(self.project.client.api_version, '1.25'): @@ -704,14 +714,17 @@ class TopLevelCommand(object): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. - --parallel Pull multiple images in parallel. + --parallel Deprecated, pull multiple images in parallel (enabled by default). + --no-parallel Disable parallel pulling. -q, --quiet Pull without printing progress information --include-deps Also pull services declared as dependencies """ + if options.get('--parallel'): + log.warn('--parallel option is deprecated and will be removed in future versions.') self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), - parallel_pull=options.get('--parallel'), + parallel_pull=not options.get('--no-parallel'), silent=options.get('--quiet'), include_deps=options.get('--include-deps'), ) @@ -1408,7 +1421,7 @@ def call_docker(args, dockeropts): if verify: tls_options.append('--tlsverify') if host: - tls_options.extend(['--host', host]) + tls_options.extend(['--host', host.lstrip('=')]) args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) @@ -1453,6 +1466,9 @@ def build_exec_command(options, container_id, command): for env_variable in options["--env"]: args += ["--env", env_variable] + if options["--workdir"]: + args += ["--workdir", options["--workdir"]] + args += [container_id] args += command return args diff --git a/compose/config/config.py b/compose/config/config.py index 508ceafb..9f8a50c6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import functools +import io import logging import os import string @@ -67,7 +68,10 @@ DOCKER_CONFIG_KEYS = [ 'command', 'cpu_count', 'cpu_percent', + 'cpu_period', 'cpu_quota', + 'cpu_rt_period', + 'cpu_rt_runtime', 'cpu_shares', 'cpus', 'cpuset', @@ -125,11 +129,12 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'container_name', 'credential_spec', 'dockerfile', + 'init', 'log_driver', 'log_opt', 'logging', 'network_mode', - 'init', + 'platform', 'scale', 'stop_grace_period', ] @@ -1115,6 +1120,7 @@ def merge_build(output, base, override): md.merge_scalar('network') md.merge_scalar('target') md.merge_scalar('shm_size') + md.merge_scalar('isolation') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) @@ -1428,10 +1434,15 @@ def has_uppercase(name): return any(char in string.ascii_uppercase for char in name) -def load_yaml(filename): +def load_yaml(filename, encoding=None): try: - with open(filename, 'r') as fh: + with io.open(filename, 'r', encoding=encoding) as fh: return yaml.safe_load(fh) - except (IOError, yaml.YAMLError) as e: + except (IOError, yaml.YAMLError, UnicodeDecodeError) as e: + if encoding is None: + # Sometimes the user's locale sets an encoding that doesn't match + # the YAML files. Im such cases, retry once with the "default" + # UTF-8 encoding + return load_yaml(filename, encoding='utf-8') error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ raise ConfigurationError(u"{}: {}".format(error_name, e)) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 984c46c5..5ad5a20e 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -88,7 +88,8 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/labels"} + "labels": {"$ref": "#/definitions/labels"}, + "isolation": {"type": "string"} }, "additionalProperties": false } @@ -106,6 +107,7 @@ "container_name": {"type": "string"}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, "cpuset": {"type": "string"}, "depends_on": { "oneOf": [ diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 8e3927e1..26044b65 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -90,7 +90,8 @@ "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"} + "network": {"type": "string"}, + "isolation": {"type": "string"} }, "additionalProperties": false } @@ -110,6 +111,9 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 33840dba..ac0778f2 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -93,7 +93,8 @@ "network": {"type": "string"}, "target": {"type": "string"}, "shm_size": {"type": ["integer", "string"]}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"} + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "isolation": {"type": "string"} }, "additionalProperties": false } @@ -113,6 +114,9 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json new file mode 100644 index 00000000..731fa2f9 --- /dev/null +++ b/compose/config/config_schema_v2.4.json @@ -0,0 +1,513 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v2.4.json", + "type": "object", + + "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 + } + }, + + "patternProperties": {"^x-": {}}, + "additionalProperties": false, + + "definitions": { + + "service": { + "id": "#/definitions/service", + "type": "object", + + "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + + "build": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "context": {"type": "string"}, + "dockerfile": {"type": "string"}, + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "isolation": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"$ref": "#/definitions/list_of_strings"}, + "cap_drop": {"$ref": "#/definitions/list_of_strings"}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "container_name": {"type": "string"}, + "cpu_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, + "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, + "cpus": {"type": "number", "minimum": 0}, + "cpuset": {"type": "string"}, + "depends_on": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "enum": ["service_started", "service_healthy"] + } + }, + "required": ["condition"] + } + } + } + ] + }, + "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"$ref": "#/definitions/list_of_strings"}, + "dns_opt": { + "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 + }, + + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, + + "external_links": {"$ref": "#/definitions/list_of_strings"}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "init": {"type": ["boolean", "string"]}, + "ipc": {"type": "string"}, + "isolation": {"type": "string"}, + "labels": {"$ref": "#/definitions/labels"}, + "links": {"$ref": "#/definitions/list_of_strings"}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": {"type": "object"} + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "mem_limit": {"type": ["number", "string"]}, + "mem_reservation": {"type": ["string", "integer"]}, + "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "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"}, + "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, + "priority": {"type": "number"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "oom_kill_disable": {"type": "boolean"}, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "pid": {"type": ["string", "null"]}, + "platform": {"type": "string"}, + "ports": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "ports" + }, + "uniqueItems": true + }, + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "runtime": {"type": "string"}, + "scale": {"type": "integer"}, + "security_opt": {"$ref": "#/definitions/list_of_strings"}, + "shm_size": {"type": ["number", "string"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, + "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"], + "additionalProperties": false, + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + }, + "tmpfs": { + "type": "object", + "properties": { + "size": {"type": ["integer", "string"]} + } + } + } + } + ], + "uniqueItems": true + } + }, + "volume_driver": {"type": "string"}, + "volumes_from": {"$ref": "#/definitions/list_of_strings"}, + "working_dir": {"type": "string"} + }, + + "dependencies": { + "memswap_limit": ["mem_limit"] + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "start_period": {"type": "string"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + + "network": { + "id": "#/definitions/network", + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "enable_ipv6": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/labels"}, + "name": {"type": "string"} + }, + "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/labels"}, + "name": {"type": "string"} + }, + "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} + ] + }, + + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + + "constraints": { + "service": { + "id": "#/definitions/constraints/service", + "anyOf": [ + {"required": ["build"]}, + {"required": ["image"]} + ], + "properties": { + "build": { + "required": ["context"] + } + } + } + } + } +} diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index b1143d66..8845d73b 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -10,6 +10,7 @@ import six from .errors import ConfigurationError from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.utils import parse_bytes +from compose.utils import parse_nanoseconds_int log = logging.getLogger(__name__) @@ -132,9 +133,8 @@ class TemplateWithDefaults(Template): braced = mo.group('braced') if braced is not None: sep = mo.group('sep') - result = self.process_braced_group(braced, sep, mapping) - if result: - return result + if sep: + return self.process_braced_group(braced, sep, mapping) if named is not None: val = mapping[named] @@ -223,6 +223,12 @@ def bytes_to_int(s): return v +def to_microseconds(v): + if not isinstance(v, six.string_types): + return v + return int(parse_nanoseconds_int(v) / 1000) + + class ConversionMap(object): map = { service_path('blkio_config', 'weight'): to_int, @@ -230,6 +236,10 @@ class ConversionMap(object): service_path('build', 'labels', FULL_JOKER): to_str, service_path('cpus'): to_float, service_path('cpu_count'): to_int, + service_path('cpu_quota'): to_microseconds, + service_path('cpu_period'): to_microseconds, + service_path('cpu_rt_period'): to_microseconds, + service_path('cpu_rt_runtime'): to_microseconds, service_path('configs', 'mode'): to_int, service_path('secrets', 'mode'): to_int, service_path('healthcheck', 'retries'): to_int, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 7de7f41e..c0cf35c1 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -151,9 +151,10 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['start_period'] = serialize_ns_time_value( service_dict['healthcheck']['start_period'] ) - if 'ports' in service_dict and version < V3_2: + + if 'ports' in service_dict: service_dict['ports'] = [ - p.legacy_repr() if isinstance(p, types.ServicePort) else p + p.legacy_repr() if p.external_ip or version < V3_2 else p for p in service_dict['ports'] ] if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)): diff --git a/compose/const.py b/compose/const.py index 495539fb..200a458a 100644 --- a/compose/const.py +++ b/compose/const.py @@ -27,6 +27,7 @@ COMPOSEFILE_V2_0 = ComposeVersion('2.0') COMPOSEFILE_V2_1 = ComposeVersion('2.1') COMPOSEFILE_V2_2 = ComposeVersion('2.2') COMPOSEFILE_V2_3 = ComposeVersion('2.3') +COMPOSEFILE_V2_4 = ComposeVersion('2.4') COMPOSEFILE_V3_0 = ComposeVersion('3.0') COMPOSEFILE_V3_1 = ComposeVersion('3.1') @@ -42,6 +43,7 @@ API_VERSIONS = { COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V2_2: '1.25', COMPOSEFILE_V2_3: '1.30', + COMPOSEFILE_V2_4: '1.35', COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', @@ -57,6 +59,7 @@ API_VERSION_TO_ENGINE_VERSION = { API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V2_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V2_3]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V2_4]: '17.12.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', diff --git a/compose/network.py b/compose/network.py index 027e7d5a..1a080c40 100644 --- a/compose/network.py +++ b/compose/network.py @@ -42,6 +42,11 @@ class Network(object): def ensure(self): if self.external: + if self.driver == 'overlay': + # Swarm nodes do not register overlay networks that were + # created on a different node unless they're in use. + # See docker/compose#4399 + return try: self.inspect() log.debug( diff --git a/compose/project.py b/compose/project.py index 2cbc4aee..afbec183 100644 --- a/compose/project.py +++ b/compose/project.py @@ -77,7 +77,7 @@ class Project(object): return labels @classmethod - def from_config(cls, name, config_data, client): + def from_config(cls, name, config_data, client, default_platform=None): """ Construct a Project from a config.Config object. """ @@ -128,6 +128,7 @@ class Project(object): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, + platform=service_dict.pop('platform', default_platform), **service_dict) ) @@ -366,10 +367,10 @@ class Project(object): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None): + build_args=None, gzip=False): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm, memory, build_args) + service.build(no_cache, pull, force_rm, memory, build_args, gzip) else: log.info('%s uses an image, skipping' % service.name) @@ -551,7 +552,7 @@ class Project(object): services, pull_service, operator.attrgetter('name'), - 'Pulling', + not silent and 'Pulling' or None, limit=5, ) if len(errors): diff --git a/compose/service.py b/compose/service.py index 65208047..bb9e26ba 100644 --- a/compose/service.py +++ b/compose/service.py @@ -62,7 +62,10 @@ HOST_CONFIG_KEYS = [ 'cgroup_parent', 'cpu_count', 'cpu_percent', + 'cpu_period', 'cpu_quota', + 'cpu_rt_period', + 'cpu_rt_runtime', 'cpu_shares', 'cpus', 'cpuset', @@ -682,15 +685,27 @@ class Service(object): # TODO: this would benefit from github.com/docker/docker/pull/14699 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - containers = filter(None, [ - Container.from_ps(self.client, container) - for container in self.client.containers( - all=True, - filters={'label': self.labels(one_off=one_off)}) - ]) + containers = self._fetch_containers( + all=True, + filters={'label': self.labels(one_off=one_off)} + ) numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 + def _fetch_containers(self, **fetch_options): + # Account for containers that might have been removed since we fetched + # the list. + def soft_inspect(container): + try: + return Container.from_id(self.client, container['Id']) + except NotFound: + return None + + return filter(None, [ + soft_inspect(container) + for container in self.client.containers(**fetch_options) + ]) + def _get_aliases(self, network, container=None): return list( {self.name} | @@ -947,6 +962,9 @@ class Service(object): device_write_iops=blkio_config.get('device_write_iops'), mounts=options.get('mounts'), device_cgroup_rules=options.get('device_cgroup_rules'), + cpu_period=options.get('cpu_period'), + cpu_rt_period=options.get('cpu_rt_period'), + cpu_rt_runtime=options.get('cpu_rt_runtime'), ) def get_secret_volumes(self): @@ -961,7 +979,8 @@ class Service(object): return [build_spec(secret) for secret in self.secrets] - def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None): + def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None, + gzip=False): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) @@ -979,6 +998,12 @@ class Service(object): if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') + platform = self.options.get('platform') + if platform and version_lt(self.client.api_version, '1.35'): + raise OperationFailedError( + 'Impossible to perform platform-targeted builds for API version < 1.35' + ) + build_output = self.client.build( path=path, tag=self.image_name, @@ -997,6 +1022,9 @@ class Service(object): container_limits={ 'memory': parse_bytes(memory) if memory else None }, + gzip=gzip, + isolation=build_opts.get('isolation', self.options.get('isolation', None)), + platform=platform, ) try: @@ -1098,11 +1126,20 @@ class Service(object): return repo, tag, separator = parse_repository_tag(self.options['image']) - tag = tag or 'latest' + kwargs = { + 'tag': tag or 'latest', + 'stream': True, + 'platform': self.options.get('platform'), + } if not silent: log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) + + if kwargs['platform'] and version_lt(self.client.api_version, '1.35'): + raise OperationFailedError( + 'Impossible to perform platform-targeted builds for API version < 1.35' + ) try: - output = self.client.pull(repo, tag=tag, stream=True) + output = self.client.pull(repo, **kwargs) if silent: with open(os.devnull, 'w') as devnull: return progress_stream.get_digest_from_pull( diff --git a/compose/volume.py b/compose/volume.py index 0b148620..6bf18404 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -124,19 +124,7 @@ class ProjectVolumes(object): ) volume.create() else: - driver = volume.inspect()['Driver'] - if volume.driver is not None and driver != volume.driver: - raise ConfigurationError( - 'Configuration for volume {0} specifies driver ' - '{1}, but a volume with the same name uses a ' - 'different driver ({3}). If you wish to use the ' - 'new configuration, please remove the existing ' - 'volume "{2}" first:\n' - '$ docker volume rm {2}'.format( - volume.name, volume.driver, volume.full_name, - volume.inspect()['Driver'] - ) - ) + check_remote_volume_config(volume.inspect(), volume) except NotFound: raise ConfigurationError( 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) @@ -152,3 +140,43 @@ class ProjectVolumes(object): else: volume_spec.source = self.volumes[volume_spec.source].full_name return volume_spec + + +class VolumeConfigChangedError(ConfigurationError): + def __init__(self, local, property_name, local_value, remote_value): + super(VolumeConfigChangedError, self).__init__( + 'Configuration for volume {vol_name} specifies {property_name} ' + '{local_value}, but a volume with the same name uses a different ' + '{property_name} ({remote_value}). If you wish to use the new ' + 'configuration, please remove the existing volume "{full_name}" ' + 'first:\n$ docker volume rm {full_name}'.format( + vol_name=local.name, property_name=property_name, + local_value=local_value, remote_value=remote_value, + full_name=local.full_name + ) + ) + + +def check_remote_volume_config(remote, local): + if local.driver and remote.get('Driver') != local.driver: + raise VolumeConfigChangedError(local, 'driver', local.driver, remote.get('Driver')) + local_opts = local.driver_opts or {} + remote_opts = remote.get('Options') or {} + for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): + if k.startswith('com.docker.'): # These options are set internally + continue + if remote_opts.get(k) != local_opts.get(k): + raise VolumeConfigChangedError( + local, '"{}" driver_opt'.format(k), local_opts.get(k), remote_opts.get(k), + ) + + local_labels = local.labels or {} + remote_labels = remote.get('Labels') or {} + for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): + if k.startswith('com.docker.'): # We are only interested in user-specified labels + continue + if remote_labels.get(k) != local_labels.get(k): + log.warn( + 'Volume {}: label "{}" has changed. It may need to be' + ' recreated.'.format(local.name, k) + ) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index c0a54cce..aba36770 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -88,7 +88,7 @@ __docker-compose_get_services() { shift [[ $kind =~ (stopped|all) ]] && args=($args -a) - lines=(${(f)"$(_call_program commands docker $docker_options ps $args)"}) + lines=(${(f)"$(_call_program commands docker $docker_options ps --format 'table' $args)"}) services=(${(f)"$(_call_program commands docker-compose 2>/dev/null $compose_options ps -q)"}) # Parse header line to find columns diff --git a/contrib/update/update-docker-compose.ps1 b/contrib/update/update-docker-compose.ps1 new file mode 100644 index 00000000..bb033b46 --- /dev/null +++ b/contrib/update/update-docker-compose.ps1 @@ -0,0 +1,116 @@ +# Self-elevate the script if required +# http://www.expta.com/2017/03/how-to-self-elevate-powershell-script.html +If (-Not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { + If ([int](Get-CimInstance -Class Win32_OperatingSystem | Select-Object -ExpandProperty BuildNumber) -ge 6000) { + $CommandLine = "-File `"" + $MyInvocation.MyCommand.Path + "`" " + $MyInvocation.UnboundArguments + Start-Process -FilePath PowerShell.exe -Verb Runas -ArgumentList $CommandLine + Exit + } +} + +$SectionSeparator = "--------------------------------------------------" + +# Update docker-compose if required +Function UpdateDockerCompose() { + Write-Host "Updating docker-compose if required..." + Write-Host $SectionSeparator + + # Find the installed docker-compose.exe location + Try { + $DockerComposePath = Get-Command docker-compose.exe -ErrorAction Stop | ` + Select-Object -First 1 -ExpandProperty Definition + } + Catch { + Write-Host "Error: Could not find path to docker-compose.exe" ` + -ForegroundColor Red + Return $false + } + + # Prefer/enable TLS 1.2 + # https://stackoverflow.com/a/48030563/153079 + [Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls" + + # Query for the latest release version + Try { + $URI = "https://api.github.com/repos/docker/compose/releases/latest" + $LatestComposeVersion = [System.Version](Invoke-RestMethod -Method Get -Uri $URI).tag_name + } + Catch { + Write-Host "Error: Query for the latest docker-compose release version failed" ` + -ForegroundColor Red + Return $false + } + + # Check the installed version and compare with latest release + $UpdateDockerCompose = $false + Try { + $InstalledComposeVersion = ` + [System.Version]((docker-compose.exe version --short) | Out-String) + + If ($InstalledComposeVersion -eq $LatestComposeVersion) { + Write-Host ("Installed docker-compose version ({0}) same as latest ({1})." ` + -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) + } + ElseIf ($InstalledComposeVersion -lt $LatestComposeVersion) { + Write-Host ("Installed docker-compose version ({0}) older than latest ({1})." ` + -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) + $UpdateDockerCompose = $true + } + Else { + Write-Host ("Installed docker-compose version ({0}) newer than latest ({1})." ` + -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) ` + -ForegroundColor Yellow + } + } + Catch { + Write-Host ` + "Warning: Couldn't get docker-compose version, assuming an update is required..." ` + -ForegroundColor Yellow + $UpdateDockerCompose = $true + } + + If (-Not $UpdateDockerCompose) { + # Nothing to do! + Return $false + } + + # Download the latest version of docker-compose.exe + Try { + $RemoteFileName = "docker-compose-Windows-x86_64.exe" + $URI = ("https://github.com/docker/compose/releases/download/{0}/{1}" ` + -f $LatestComposeVersion.ToString(), $RemoteFileName) + Invoke-WebRequest -UseBasicParsing -Uri $URI ` + -OutFile $DockerComposePath + Return $true + } + Catch { + Write-Host ("Error: Failed to download the latest version of docker-compose`n{0}" ` + -f $_.Exception.Message) -ForegroundColor Red + Return $false + } + + Return $false +} + +If (UpdateDockerCompose) { + Write-Host "Updated to latest-version of docker-compose, running update again to verify.`n" + If (UpdateDockerCompose) { + Write-Host "Error: Should not have updated twice." -ForegroundColor Red + } +} + +# Assuming elevation popped up a new powershell window, pause so the user can see what happened +# https://stackoverflow.com/a/22362868/153079 +Function Pause ($Message = "Press any key to continue . . . ") { + If ((Test-Path variable:psISE) -and $psISE) { + $Shell = New-Object -ComObject "WScript.Shell" + $Shell.Popup("Click OK to continue.", 0, "Script Paused", 0) + } + Else { + Write-Host "`n$SectionSeparator" + Write-Host -NoNewline $Message + [void][System.Console]::ReadKey($true) + Write-Host + } +} +Pause diff --git a/docker-compose.spec b/docker-compose.spec index b2b4f5f1..b8c3a419 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -43,6 +43,11 @@ exe = EXE(pyz, 'DATA' ), ( + 'compose/config/config_schema_v2.4.json', + 'compose/config/config_schema_v2.4.json', + 'DATA' + ), + ( 'compose/config/config_schema_v3.0.json', 'compose/config/config_schema_v3.0.json', 'DATA' diff --git a/docs/issue_template.md b/docs/issue_template.md new file mode 100644 index 00000000..774f27e2 --- /dev/null +++ b/docs/issue_template.md @@ -0,0 +1,50 @@ +<!-- +Welcome to the docker-compose issue tracker! Before creating an issue, please heed the following: + +1. This tracker should only be used to report bugs and request features / enhancements to docker-compose + - For questions and general support, use https://forums.docker.com + - For documentation issues, use https://github.com/docker/docker.github.io + - For issues with the `docker stack` commands and the version 3 of the Compose file, use + https://github.com/docker/cli +2. Use the search function before creating a new issue. Duplicates will be closed and directed to + the original discussion. +3. When making a bug report, make sure you provide all required information. The easier it is for + maintainers to reproduce, the faster it'll be fixed. +--> + +## Description of the issue + +## Context information (for bug reports) + +``` +Output of "docker-compose version" +``` + +``` +Output of "docker version" +``` + +``` +Output of "docker-compose config" +``` + + +## Steps to reproduce the issue + +1. +2. +3. + +### Observed result + +### Expected result + +### Stacktrace / full error message + +``` +(if applicable) +``` + +## Additional information + +OS version / distribution, `docker-compose` install method, etc. diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md new file mode 100644 index 00000000..15526af0 --- /dev/null +++ b/docs/pull_request_template.md @@ -0,0 +1,13 @@ +<!-- +Welcome to the docker-compose issue tracker, and thank you for your interest +in contributing to the project! Please make sure you've read the guidelines +in CONTRIBUTING.md before submitting your pull request. Contributions that +do not comply and contributions with failing tests will not be reviewed! +--> + +<!-- Please make sure an issue describing the problem the PR is trying to + solve exists, or create it before submitting a PR. The maintainers will + validate if the issue should be addressed or if it is out of scope for the + project. +--> +Resolves # diff --git a/requirements.txt b/requirements.txt index e5a26bba..7dce4024 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.4 +docker==3.2.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/script/run/run.sh b/script/run/run.sh index 0cc7c146..fb4cb8b4 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.20.1" +VERSION="1.21.0-rc1" IMAGE="docker/compose:$VERSION" diff --git a/script/test/versions.py b/script/test/versions.py index 46872ed9..f699f268 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -73,6 +73,11 @@ class Version(namedtuple('_Version', 'major minor patch rc edition')): return '.'.join(map(str, self[:3])) + edition + rc +BLACKLIST = [ # List of versions known to be broken and should not be used + Version.parse('18.03.0-ce-rc2'), +] + + def group_versions(versions): """Group versions by `major.minor` releases. @@ -117,7 +122,9 @@ def get_default(versions): def get_versions(tags): for tag in tags: try: - yield Version.parse(tag['name']) + v = Version.parse(tag['name']) + if v not in BLACKLIST: + yield v except ValueError: print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) @@ -36,7 +36,7 @@ install_requires = [ 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.4, < 4.0', + 'docker >= 3.2.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7a0f22fc..07570580 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,6 +177,13 @@ class CLITestCase(DockerClientTestCase): returncode=0 ) + def test_shorthand_host_opt_interactive(self): + self.dispatch( + ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), + 'run', 'another', 'ls'], + returncode=0 + ) + def test_host_not_reachable(self): result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) assert "Couldn't connect to Docker daemon" in result.stderr @@ -491,16 +498,16 @@ class CLITestCase(DockerClientTestCase): def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) - assert 'simplecomposefile_simple_1' in result.stdout + assert 'simple-composefile_simple_1' in result.stdout def test_ps_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' self.dispatch(['up', '-d']) result = self.dispatch(['ps']) - assert 'multiplecomposefiles_simple_1' in result.stdout - assert 'multiplecomposefiles_another_1' in result.stdout - assert 'multiplecomposefiles_yetanother_1' not in result.stdout + assert 'multiple-composefiles_simple_1' in result.stdout + assert 'multiple-composefiles_another_1' in result.stdout + assert 'multiple-composefiles_yetanother_1' not in result.stdout def test_ps_alternate_composefile(self): config_path = os.path.abspath( @@ -511,9 +518,9 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['-f', 'compose2.yml', 'up', '-d']) result = self.dispatch(['-f', 'compose2.yml', 'ps']) - assert 'multiplecomposefiles_simple_1' not in result.stdout - assert 'multiplecomposefiles_another_1' not in result.stdout - assert 'multiplecomposefiles_yetanother_1' in result.stdout + assert 'multiple-composefiles_simple_1' not in result.stdout + assert 'multiple-composefiles_another_1' not in result.stdout + assert 'multiple-composefiles_yetanother_1' in result.stdout def test_ps_services_filter_option(self): self.base_dir = 'tests/fixtures/ps-services-filter' @@ -545,13 +552,11 @@ class CLITestCase(DockerClientTestCase): def test_pull(self): result = self.dispatch(['pull']) - assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling another (busybox:latest)...', - 'Pulling simple (busybox:latest)...', - ] + assert 'Pulling simple' in result.stderr + assert 'Pulling another' in result.stderr def test_pull_with_digest(self): - result = self.dispatch(['-f', 'digest.yml', 'pull']) + result = self.dispatch(['-f', 'digest.yml', 'pull', '--no-parallel']) assert 'Pulling simple (busybox:latest)...' in result.stderr assert ('Pulling digest (busybox@' @@ -561,7 +566,7 @@ class CLITestCase(DockerClientTestCase): def test_pull_with_ignore_pull_failures(self): result = self.dispatch([ '-f', 'ignore-pull-failures.yml', - 'pull', '--ignore-pull-failures'] + 'pull', '--ignore-pull-failures', '--no-parallel'] ) assert 'Pulling simple (busybox:latest)...' in result.stderr @@ -576,7 +581,7 @@ class CLITestCase(DockerClientTestCase): def test_pull_with_parallel_failure(self): result = self.dispatch([ - '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], + '-f', 'ignore-pull-failures.yml', 'pull'], returncode=1 ) @@ -593,14 +598,14 @@ class CLITestCase(DockerClientTestCase): def test_pull_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' - result = self.dispatch(['pull', 'web']) + result = self.dispatch(['pull', '--no-parallel', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ 'Pulling web (busybox:latest)...', ] def test_pull_with_include_deps(self): self.base_dir = 'tests/fixtures/links-composefile' - result = self.dispatch(['pull', '--include-deps', 'web']) + result = self.dispatch(['pull', '--no-parallel', '--include-deps', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ 'Pulling db (busybox:latest)...', 'Pulling web (busybox:latest)...', @@ -902,18 +907,18 @@ class CLITestCase(DockerClientTestCase): assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 2 result = self.dispatch(['down', '--rmi=local', '--volumes']) - assert 'Stopping v2full_web_1' in result.stderr - assert 'Stopping v2full_other_1' in result.stderr - assert 'Stopping v2full_web_run_2' in result.stderr - assert 'Removing v2full_web_1' in result.stderr - assert 'Removing v2full_other_1' in result.stderr - assert 'Removing v2full_web_run_1' in result.stderr - assert 'Removing v2full_web_run_2' in result.stderr - assert 'Removing volume v2full_data' in result.stderr - assert 'Removing image v2full_web' in result.stderr + assert 'Stopping v2-full_web_1' in result.stderr + assert 'Stopping v2-full_other_1' in result.stderr + assert 'Stopping v2-full_web_run_2' in result.stderr + assert 'Removing v2-full_web_1' in result.stderr + assert 'Removing v2-full_other_1' in result.stderr + assert 'Removing v2-full_web_run_1' in result.stderr + assert 'Removing v2-full_web_run_2' in result.stderr + assert 'Removing volume v2-full_data' in result.stderr + assert 'Removing image v2-full_web' in result.stderr assert 'Removing image busybox' not in result.stderr - assert 'Removing network v2full_default' in result.stderr - assert 'Removing network v2full_front' in result.stderr + assert 'Removing network v2-full_default' in result.stderr + assert 'Removing network v2-full_front' in result.stderr def test_down_timeout(self): self.dispatch(['up', '-d'], None) @@ -1559,6 +1564,16 @@ class CLITestCase(DockerClientTestCase): assert stdout == "operator\n" assert stderr == "" + @v3_only() + def test_exec_workdir(self): + self.base_dir = 'tests/fixtures/links-composefile' + os.environ['COMPOSE_API_VERSION'] = '1.35' + self.dispatch(['up', '-d', 'console']) + assert len(self.project.containers()) == 1 + + stdout, stderr = self.dispatch(['exec', '-T', '--workdir', '/etc', 'console', 'ls']) + assert 'passwd' in stdout + @v2_2_only() def test_exec_service_with_environment_overridden(self): name = 'service' @@ -1990,39 +2005,39 @@ class CLITestCase(DockerClientTestCase): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'running')) os.kill(proc.pid, signal.SIGINT) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'exited')) def test_run_handles_sigterm(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'running')) os.kill(proc.pid, signal.SIGTERM) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'exited')) def test_run_handles_sighup(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'running')) os.kill(proc.pid, signal.SIGHUP) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'exited')) @mock.patch.dict(os.environ) @@ -2224,7 +2239,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['up', '-d', 'another']) wait_on_condition(ContainerStateCondition( self.project.client, - 'logscomposefile_another_1', + 'logs-composefile_another_1', 'exited')) self.dispatch(['kill', 'simple']) @@ -2233,8 +2248,8 @@ class CLITestCase(DockerClientTestCase): assert 'hello' in result.stdout assert 'test' in result.stdout - assert 'logscomposefile_another_1 exited with code 0' in result.stdout - assert 'logscomposefile_simple_1 exited with code 137' in result.stdout + assert 'logs-composefile_another_1 exited with code 0' in result.stdout + assert 'logs-composefile_simple_1 exited with code 137' in result.stdout def test_logs_default(self): self.base_dir = 'tests/fixtures/logs-composefile' @@ -2481,7 +2496,7 @@ class CLITestCase(DockerClientTestCase): container, = self.project.containers() expected_template = ' container {} {}' - expected_meta_info = ['image=busybox:latest', 'name=simplecomposefile_simple_1'] + expected_meta_info = ['image=busybox:latest', 'name=simple-composefile_simple_1'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] @@ -2601,13 +2616,13 @@ class CLITestCase(DockerClientTestCase): result = wait_on_process(proc, returncode=1) - assert 'exitcodefrom_another_1 exited with code 1' in result.stdout + assert 'exit-code-from_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 + assert 'simple-composefile_simple_1' in result.stdout def test_images_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' @@ -2615,8 +2630,8 @@ class CLITestCase(DockerClientTestCase): result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'multiplecomposefiles_another_1' in result.stdout - assert 'multiplecomposefiles_simple_1' in result.stdout + assert 'multiple-composefiles_another_1' in result.stdout + assert 'multiple-composefiles_simple_1' in result.stdout @mock.patch.dict(os.environ) def test_images_tagless_image(self): @@ -2636,7 +2651,7 @@ class CLITestCase(DockerClientTestCase): self.project.get_service('foo').create_container() result = self.dispatch(['images']) assert '<none>' in result.stdout - assert 'taglessimage_foo_1' in result.stdout + assert 'tagless-image_foo_1' in result.stdout def test_up_with_override_yaml(self): self.base_dir = 'tests/fixtures/override-yaml-files' diff --git a/tests/integration/network_test.py b/tests/integration/network_test.py index 2ff610fb..a2493fda 100644 --- a/tests/integration/network_test.py +++ b/tests/integration/network_test.py @@ -1,7 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals +import pytest + from .testcases import DockerClientTestCase +from compose.config.errors import ConfigurationError from compose.const import LABEL_NETWORK from compose.const import LABEL_PROJECT from compose.network import Network @@ -15,3 +18,20 @@ class NetworkTest(DockerClientTestCase): labels = net_data['Labels'] assert labels[LABEL_NETWORK] == net.name assert labels[LABEL_PROJECT] == net.project + + def test_network_external_default_ensure(self): + net = Network( + self.client, 'composetest', 'foonet', + external=True + ) + + with pytest.raises(ConfigurationError): + net.ensure() + + def test_network_external_overlay_ensure(self): + net = Network( + self.client, 'composetest', 'foonet', + driver='overlay', external=True + ) + + assert net.ensure() is None diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 0acb8028..3960d12e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import json import os import random +import shutil import tempfile import py @@ -1538,6 +1539,52 @@ class ProjectTest(DockerClientTestCase): ) in str(e.value) @v2_only() + @no_cluster('inspect volume by name defect on Swarm Classic') + def test_initialize_volumes_updated_driver_opts(self): + vol_name = '{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) + tmpdir = tempfile.mkdtemp(prefix='compose_test_') + self.addCleanup(shutil.rmtree, tmpdir) + driver_opts = {'o': 'bind', 'device': tmpdir, 'type': 'none'} + + config_data = build_config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], + volumes={ + vol_name: { + 'driver': 'local', + 'driver_opts': driver_opts + } + }, + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.volumes.initialize() + + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name + assert volume_data['Driver'] == 'local' + assert volume_data['Options'] == driver_opts + + driver_opts['device'] = '/opt/data/localdata' + project = Project.from_config( + name='composetest', + config_data=config_data, + client=self.client + ) + with pytest.raises(config.ConfigurationError) as e: + project.volumes.initialize() + assert 'Configuration for volume {0} specifies "device" driver_opt {1}'.format( + vol_name, driver_opts['device'] + ) in str(e.value) + + @v2_only() def test_initialize_volumes_updated_blank_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6e86a02d..d8f4d094 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -122,10 +122,19 @@ class ServiceTest(DockerClientTestCase): assert container.get('HostConfig.CpuShares') == 73 def test_create_container_with_cpu_quota(self): - service = self.create_service('db', cpu_quota=40000) + service = self.create_service('db', cpu_quota=40000, cpu_period=150000) container = service.create_container() container.start() assert container.get('HostConfig.CpuQuota') == 40000 + assert container.get('HostConfig.CpuPeriod') == 150000 + + @pytest.mark.xfail(raises=OperationFailedError, reason='not supported by kernel') + def test_create_container_with_cpu_rt(self): + service = self.create_service('db', cpu_rt_runtime=40000, cpu_rt_period=150000) + container = service.create_container() + container.start() + assert container.get('HostConfig.CpuRealtimeRuntime') == 40000 + assert container.get('HostConfig.CpuRealtimePeriod') == 150000 @v2_2_only() def test_create_container_with_cpu_count(self): @@ -1096,6 +1105,38 @@ class ServiceTest(DockerClientTestCase): service.build() assert service.image() + def test_build_with_gzip(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('\n'.join([ + 'FROM busybox', + 'COPY . /src', + 'RUN cat /src/hello.txt' + ])) + with open(os.path.join(base_dir, 'hello.txt'), 'w') as f: + f.write('hello world\n') + + service = self.create_service('build_gzip', build={ + 'context': text_type(base_dir), + }) + service.build(gzip=True) + assert service.image() + + @v2_1_only() + def test_build_with_isolation(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('build_isolation', build={ + 'context': text_type(base_dir), + 'isolation': 'default', + }) + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index b46a3ee2..1a2dfbcf 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -154,3 +154,11 @@ class TestCallDocker(object): assert fake_call.call_args[0][0] == [ 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' ] + + def test_with_host_option_shorthand_equal(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {'--host': '=tcp://mydocker.net:2333'}) + + assert fake_call.call_args[0][0] == [ + 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' + ] diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 47eaabf9..7c8a1423 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -30,12 +30,12 @@ class CLITestCase(unittest.TestCase): test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') with test_dir.as_cwd(): project_name = get_project_name('.') - assert 'simplecomposefile' == project_name + assert 'simple-composefile' == project_name def test_project_name_with_explicit_base_dir(self): base_dir = 'tests/fixtures/simple-composefile' project_name = get_project_name(base_dir) - assert 'simplecomposefile' == project_name + assert 'simple-composefile' == project_name def test_project_name_with_explicit_uppercase_base_dir(self): base_dir = 'tests/fixtures/UpperCaseDir' @@ -45,7 +45,7 @@ class CLITestCase(unittest.TestCase): def test_project_name_with_explicit_project_name(self): name = 'explicit-project-name' project_name = get_project_name(None, project_name=name) - assert 'explicitprojectname' == project_name + assert 'explicit-project-name' == project_name @mock.patch.dict(os.environ) def test_project_name_from_environment_new_var(self): @@ -59,7 +59,7 @@ class CLITestCase(unittest.TestCase): with mock.patch.dict(os.environ): os.environ['COMPOSE_PROJECT_NAME'] = '' project_name = get_project_name(base_dir) - assert 'simplecomposefile' == project_name + assert 'simple-composefile' == project_name @mock.patch.dict(os.environ) def test_project_name_with_environment_file(self): @@ -80,7 +80,7 @@ class CLITestCase(unittest.TestCase): def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir) - assert project.name == 'longerfilenamecomposefile' + assert project.name == 'longer-filename-composefile' assert project.client assert project.services diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7a9bb944..8a75648a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3,6 +3,7 @@ from __future__ import absolute_import from __future__ import print_function from __future__ import unicode_literals +import codecs import os import shutil import tempfile @@ -1623,6 +1624,21 @@ class ConfigTest(unittest.TestCase): assert 'line 3, column 32' in exc.exconly() + def test_load_yaml_with_bom(self): + tmpdir = py.test.ensuretemp('bom_yaml') + self.addCleanup(tmpdir.remove) + bom_yaml = tmpdir.join('docker-compose.yml') + with codecs.open(str(bom_yaml), 'w', encoding='utf-8') as f: + f.write('''\ufeff + version: '2.3' + volumes: + park_bom: + ''') + assert config.load_yaml(str(bom_yaml)) == { + 'version': '2.3', + 'volumes': {'park_bom': None} + } + def test_validate_extra_hosts_invalid(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details({ @@ -4927,6 +4943,18 @@ class SerializeTest(unittest.TestCase): serialized_config = yaml.load(serialize_config(config_dict)) assert '8080:80/tcp' in serialized_config['services']['web']['ports'] + def test_serialize_ports_with_ext_ip(self): + config_dict = config.Config(version=V3_5, services=[ + { + 'ports': [types.ServicePort('80', '8080', None, None, '127.0.0.1')], + 'image': 'alpine', + 'name': 'web' + } + ], volumes={}, networks={}, secrets={}, configs={}) + + serialized_config = yaml.load(serialize_config(config_dict)) + assert '127.0.0.1:8080:80/tcp' in serialized_config['services']['web']['ports'] + def test_serialize_configs(self): service_dict = { 'image': 'example/web', diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 2ba698fb..0d0e7d28 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -420,3 +420,15 @@ def test_interpolate_unicode_values(): interpol("$FOO") == '十六夜 咲夜' interpol("${BAR}") == '十六夜 咲夜' + + +def test_interpolate_no_fallthrough(): + # Test regression on docker/compose#5829 + variable_mapping = { + 'TEST:-': 'hello', + 'TEST-': 'hello', + } + interpol = Interpolator(TemplateWithDefaults, variable_mapping).interpolate + + assert interpol('${TEST:-}') == '' + assert interpol('${TEST-}') == '' diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b4994a99..eb620972 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -13,6 +13,7 @@ from compose.config.config import Config from compose.config.types import VolumeFromSpec from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V2_4 as V2_4 from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import NoSuchService @@ -561,3 +562,29 @@ class ProjectTest(unittest.TestCase): def test_no_such_service_unicode(self): assert NoSuchService('十六夜 咲夜'.encode('utf-8')).msg == 'No such service: 十六夜 咲夜' assert NoSuchService('十六夜 咲夜').msg == 'No such service: 十六夜 咲夜' + + def test_project_platform_value(self): + service_config = { + 'name': 'web', + 'image': 'busybox:latest', + } + config_data = Config( + version=V2_4, services=[service_config], networks={}, volumes={}, secrets=None, configs=None + ) + + project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) + assert project.get_service('web').options.get('platform') is None + + project = Project.from_config( + name='test', client=self.mock_client, config_data=config_data, default_platform='windows' + ) + assert project.get_service('web').options.get('platform') == 'windows' + + service_config['platform'] = 'linux/s390x' + project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) + assert project.get_service('web').options.get('platform') == 'linux/s390x' + + project = Project.from_config( + name='test', client=self.mock_client, config_data=config_data, default_platform='windows' + ) + assert project.get_service('web').options.get('platform') == 'linux/s390x' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 9128b955..4ccc4865 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,7 @@ import docker import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError +from docker.errors import NotFound from .. import mock from .. import unittest @@ -20,6 +21,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import SECRETS_PATH from compose.container import Container +from compose.errors import OperationFailedError from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter from compose.service import build_ulimits @@ -399,7 +401,8 @@ class ServiceTest(unittest.TestCase): self.mock_client.pull.assert_called_once_with( 'someimage', tag='sometag', - stream=True) + stream=True, + platform=None) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') def test_pull_image_no_tag(self): @@ -408,7 +411,8 @@ class ServiceTest(unittest.TestCase): self.mock_client.pull.assert_called_once_with( 'ababab', tag='latest', - stream=True) + stream=True, + platform=None) @mock.patch('compose.service.log', autospec=True) def test_pull_image_digest(self, mock_log): @@ -417,9 +421,30 @@ class ServiceTest(unittest.TestCase): self.mock_client.pull.assert_called_once_with( 'someimage', tag='sha256:1234', - stream=True) + stream=True, + platform=None) mock_log.info.assert_called_once_with('Pulling foo (someimage@sha256:1234)...') + @mock.patch('compose.service.log', autospec=True) + def test_pull_image_with_platform(self, mock_log): + self.mock_client.api_version = '1.35' + service = Service( + 'foo', client=self.mock_client, image='someimage:sometag', platform='windows/x86_64' + ) + service.pull() + assert self.mock_client.pull.call_count == 1 + call_args = self.mock_client.pull.call_args + assert call_args[1]['platform'] == 'windows/x86_64' + + @mock.patch('compose.service.log', autospec=True) + def test_pull_image_with_platform_unsupported_api(self, mock_log): + self.mock_client.api_version = '1.33' + service = Service( + 'foo', client=self.mock_client, image='someimage:sometag', platform='linux/arm' + ) + with pytest.raises(OperationFailedError): + service.pull() + @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -471,23 +496,8 @@ class ServiceTest(unittest.TestCase): _, args, _ = mock_log.warn.mock_calls[0] assert 'was built because it did not already exist' in args[0] - self.mock_client.build.assert_called_once_with( - tag='default_foo', - dockerfile=None, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs={}, - labels=None, - cache_from=None, - network_mode=None, - target=None, - shmsize=None, - extra_hosts=None, - container_limits={'memory': None}, - ) + assert self.mock_client.build.call_count == 1 + self.mock_client.build.call_args[1]['tag'] == 'default_foo' def test_ensure_image_exists_no_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) @@ -513,23 +523,8 @@ class ServiceTest(unittest.TestCase): service.ensure_image_exists(do_build=BuildAction.force) assert not mock_log.warn.called - self.mock_client.build.assert_called_once_with( - tag='default_foo', - dockerfile=None, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs={}, - labels=None, - cache_from=None, - network_mode=None, - target=None, - shmsize=None, - extra_hosts=None, - container_limits={'memory': None}, - ) + assert self.mock_client.build.call_count == 1 + self.mock_client.build.call_args[1]['tag'] == 'default_foo' def test_build_does_not_pull(self): self.mock_client.build.return_value = [ @@ -542,6 +537,19 @@ class ServiceTest(unittest.TestCase): assert self.mock_client.build.call_count == 1 assert not self.mock_client.build.call_args[1]['pull'] + def test_build_does_with_platform(self): + self.mock_client.api_version = '1.35' + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build={'context': '.'}, platform='linux') + service.build() + + assert self.mock_client.build.call_count == 1 + call_args = self.mock_client.build.call_args + assert call_args[1]['platform'] == 'linux' + def test_build_with_override_build_args(self): self.mock_client.build.return_value = [ b'{"stream": "Successfully built 12345"}', @@ -559,6 +567,33 @@ class ServiceTest(unittest.TestCase): assert called_build_args['arg1'] == build_args['arg1'] assert called_build_args['arg2'] == 'arg2' + def test_build_with_isolation_from_service_config(self): + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build={'context': '.'}, isolation='hyperv') + service.build() + + assert self.mock_client.build.call_count == 1 + called_build_args = self.mock_client.build.call_args[1] + assert called_build_args['isolation'] == 'hyperv' + + def test_build_isolation_from_build_override_service_config(self): + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service( + 'foo', client=self.mock_client, build={'context': '.', 'isolation': 'default'}, + isolation='hyperv' + ) + service.build() + + assert self.mock_client.build.call_count == 1 + called_build_args = self.mock_client.build.call_args[1] + assert called_build_args['isolation'] == 'default' + def test_config_dict(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} service = Service( @@ -888,6 +923,38 @@ class ServiceTest(unittest.TestCase): 'ftp_proxy': override_options['environment']['FTP_PROXY'], })) + def test_create_when_removed_containers_are_listed(self): + # This is aimed at simulating a race between the API call to list the + # containers, and the ones to inspect each of the listed containers. + # It can happen that a container has been removed after we listed it. + + # containers() returns a container that is about to be removed + self.mock_client.containers.return_value = [ + {'Id': 'rm_cont_id', 'Name': 'rm_cont', 'Image': 'img_id'}, + ] + + # inspect_container() will raise a NotFound when trying to inspect + # rm_cont_id, which at this point has been removed + def inspect(name): + if name == 'rm_cont_id': + raise NotFound(message='Not Found') + + if name == 'new_cont_id': + return {'Id': 'new_cont_id'} + + raise NotImplementedError("incomplete mock") + + self.mock_client.inspect_container.side_effect = inspect + + self.mock_client.inspect_image.return_value = {'Id': 'imageid'} + + self.mock_client.create_container.return_value = {'Id': 'new_cont_id'} + + # We should nonetheless be able to create a new container + service = Service('foo', client=self.mock_client) + + assert service.create_container().id == 'new_cont_id' + class TestServiceNetwork(unittest.TestCase): def setUp(self): |