From 39af6b653b1ca95463c699ad62f69c86adc79e95 Mon Sep 17 00:00:00 2001 From: Michael Gilliland Date: Mon, 28 Dec 2015 16:35:05 -0500 Subject: [PATCH 001/580] Update `volumes_from` docs to state default [Proof of read-write](https://github.com/docker/compose/blob/cfb1b37da22242dc67d5123772b3fa4518458504/compose/config/types.py#L26). I found myself wondering what the default was a couple of times, and finally decided to change it :) Signed-off-by: Michael Gilliland --- docs/compose-file.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 2a6028b8..b3ef8938 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -378,7 +378,8 @@ information. ### volumes_from Mount all of the volumes from another service or container, optionally -specifying read-only access(``ro``) or read-write(``rw``). +specifying read-only access (``ro``) or read-write (``rw``). If no access level is specified, +then read-write will be used. volumes_from: - service_name From dc1104649f1207cfc4bc0402b0aee1243442f6b9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 22:28:20 -0500 Subject: [PATCH 002/580] Validate that an extended config file has the same version as the base. Signed-off-by: Daniel Nephin --- compose/config/config.py | 40 ++++++++++++++++---------------- tests/unit/config/config_test.py | 37 +++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b3894225..ac5e8d17 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -188,10 +188,10 @@ def find(base_dir, filenames): [ConfigFile.from_filename(f) for f in filenames]) -def validate_config_version(config_details): - main_file = config_details.config_files[0] +def validate_config_version(config_files): + main_file = config_files[0] validate_top_level_object(main_file) - for next_file in config_details.config_files[1:]: + for next_file in config_files[1:]: validate_top_level_object(next_file) if main_file.version != next_file.version: @@ -254,7 +254,7 @@ def load(config_details): Return a fully interpolated, extended and validated configuration. """ - validate_config_version(config_details) + validate_config_version(config_details.config_files) processed_files = [ process_config_file(config_file) @@ -267,9 +267,8 @@ def load(config_details): networks = load_mapping(config_details.config_files, 'get_networks', 'Network') service_dicts = load_services( config_details.working_dir, - main_file.filename, - [file.get_service_dicts() for file in config_details.config_files], - main_file.version) + main_file, + [file.get_service_dicts() for file in config_details.config_files]) return Config(main_file.version, service_dicts, volumes, networks) @@ -303,21 +302,21 @@ def load_mapping(config_files, get_func, entity_type): return mapping -def load_services(working_dir, filename, service_configs, version): +def load_services(working_dir, config_file, service_configs): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( working_dir, - filename, + config_file.filename, service_name, service_dict) - resolver = ServiceExtendsResolver(service_config, version) + resolver = ServiceExtendsResolver(service_config, config_file) service_dict = process_service(resolver.run()) - validate_service(service_dict, service_config.name, version) + validate_service(service_dict, service_config.name, config_file.version) service_dict = finalize_service( service_config._replace(config=service_dict), service_names, - version) + config_file.version) return service_dict def build_services(service_config): @@ -333,7 +332,7 @@ def load_services(working_dir, filename, service_configs, version): name: merge_service_dicts_from_files( base.get(name, {}), override.get(name, {}), - version) + config_file.version) for name in all_service_names } @@ -373,11 +372,11 @@ def process_config_file(config_file, service_name=None): class ServiceExtendsResolver(object): - def __init__(self, service_config, version, already_seen=None): + def __init__(self, service_config, config_file, already_seen=None): self.service_config = service_config self.working_dir = service_config.working_dir self.already_seen = already_seen or [] - self.version = version + self.config_file = config_file @property def signature(self): @@ -404,8 +403,10 @@ class ServiceExtendsResolver(object): config_path = self.get_extended_config_path(extends) service_name = extends['service'] + extends_file = ConfigFile.from_filename(config_path) + validate_config_version([self.config_file, extends_file]) extended_file = process_config_file( - ConfigFile.from_filename(config_path), + extends_file, service_name=service_name) service_config = extended_file.config[service_name] return config_path, service_config, service_name @@ -417,7 +418,7 @@ class ServiceExtendsResolver(object): extended_config_path, service_name, service_dict), - self.version, + self.config_file, already_seen=self.already_seen + [self.signature]) service_config = resolver.run() @@ -425,13 +426,12 @@ class ServiceExtendsResolver(object): validate_extended_service_dict( other_service_dict, extended_config_path, - service_name, - ) + service_name) return merge_service_dicts( other_service_dict, self.service_config.config, - self.version) + self.config_file.version) def get_extended_config_path(self, extends_options): """Service we are extending either has a value for 'file' set, which we diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e24dc904..cc205136 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -25,14 +25,15 @@ V1 = 1 def make_service_dict(name, service_dict, working_dir, filename=None): + """Test helper function to construct a ServiceExtendsResolver """ - Test helper function to construct a ServiceExtendsResolver - """ - resolver = config.ServiceExtendsResolver(config.ServiceConfig( - working_dir=working_dir, - filename=filename, - name=name, - config=service_dict), version=1) + resolver = config.ServiceExtendsResolver( + config.ServiceConfig( + working_dir=working_dir, + filename=filename, + name=name, + config=service_dict), + config.ConfigFile(filename=filename, config={})) return config.process_service(resolver.run()) @@ -1888,6 +1889,28 @@ class ExtendsTest(unittest.TestCase): assert config == expected + def test_extends_with_mixed_versions_is_error(self): + tmpdir = py.test.ensuretemp('test_extends_with_mixed_version') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: 2 + services: + web: + extends: + file: base.yml + service: base + image: busybox + """) + tmpdir.join('base.yml').write(""" + base: + volumes: ['/foo'] + ports: ['3000:3000'] + """) + + with pytest.raises(ConfigurationError) as exc: + load_from_filename(str(tmpdir.join('docker-compose.yml'))) + assert 'Version mismatch' in exc.exconly() + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From 0f1a798f28b124bd9d1d5b36c3c71bd7a9999edf Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 10:14:55 -0500 Subject: [PATCH 003/580] Increase the timeout for all acceptance tests. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d910473a..2cce7803 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -47,7 +47,7 @@ def wait_on_process(proc, returncode=0): return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) -def wait_on_condition(condition, delay=0.1, timeout=20): +def wait_on_condition(condition, delay=0.1, timeout=40): start_time = time.time() while not condition(): if time.time() - start_time > timeout: @@ -631,14 +631,14 @@ class CLITestCase(DockerClientTestCase): wait_on_condition(ContainerCountCondition(self.project, 2)) os.kill(proc.pid, signal.SIGINT) - wait_on_condition(ContainerCountCondition(self.project, 0), timeout=30) + wait_on_condition(ContainerCountCondition(self.project, 0)) def test_up_handles_sigterm(self): proc = start_process(self.base_dir, ['up', '-t', '2']) wait_on_condition(ContainerCountCondition(self.project, 2)) os.kill(proc.pid, signal.SIGTERM) - wait_on_condition(ContainerCountCondition(self.project, 0), timeout=30) + wait_on_condition(ContainerCountCondition(self.project, 0)) def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' From ab927b986fc5317a0ceb42de1546afe5326f942a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 19:04:01 -0500 Subject: [PATCH 004/580] Test against 1.10rc1. Signed-off-by: Daniel Nephin --- script/test-versions | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/script/test-versions b/script/test-versions index 24412b91..2e9c9167 100755 --- a/script/test-versions +++ b/script/test-versions @@ -18,7 +18,7 @@ get_versions="docker run --rm if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$($get_versions default)" elif [ "$DOCKER_VERSIONS" == "all" ]; then - DOCKER_VERSIONS="1.9.1 1.10.0-dev" + DOCKER_VERSIONS=$($get_versions -n 2 recent) fi @@ -38,11 +38,7 @@ for version in $DOCKER_VERSIONS; do trap "on_exit" EXIT - if [[ $version == *"-dev" ]]; then - repo="dnephin/dind" - else - repo="dockerswarm/dind" - fi + repo="dockerswarm/dind" docker run \ -d \ From e7180982aa0555f47c3e8f4a42c6f5d862465e86 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Sat, 16 Jan 2016 13:22:02 +0100 Subject: [PATCH 005/580] Add zsh completion for 'docker-compose up --abort-on-container-exit' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 01e5f896..173666eb 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -298,12 +298,13 @@ __docker-compose_subcommand() { (up) _arguments \ $opts_help \ - '-d[Detached mode: Run containers in the background, print new container names.]' \ + '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names.]' \ '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ "--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ "--no-recreate[If containers already exist, don't recreate them.]" \ "--no-build[Don't build an image, even if it's missing]" \ + "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ '*:services:__docker-compose_services_all' && ret=0 ;; From f6561f1290b16d4c438683f7bd78a2127529ed79 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Sat, 16 Jan 2016 13:47:48 +0100 Subject: [PATCH 006/580] Add zsh completion for 'docker-compose down' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 01e5f896..bb9ffa8a 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -203,6 +203,12 @@ __docker-compose_subcommand() { '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ '--services[Print the service names, one per line.]' && ret=0 ;; + (down) + _arguments \ + $opts_help \ + "--rmi[Remove images, type may be one of: 'all' to remove all images, or 'local' to remove only images that don't have an custom name set by the 'image' field]:type:(all local)" \ + '(-v --volumes)'{-v,--volumes}"[Remove data volumes]" && ret=0 + ;; (events) _arguments \ $opts_help \ From d54190167ac665ae2b56a5031a18bc210e40faa0 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Sat, 16 Jan 2016 13:53:16 +0100 Subject: [PATCH 007/580] Fix zsh completion to ensure we have enough commands to store in the cache Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 01e5f896..a8a60b59 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -179,7 +179,7 @@ __docker-compose_commands() { local -a lines lines=(${(f)"$(_call_program commands docker-compose 2>&1)"}) _docker_compose_subcommands=(${${${lines[$((${lines[(i)Commands:]} + 1)),${lines[(I) *]}]}## #}/ ##/:}) - _store_cache docker_compose_subcommands _docker_compose_subcommands + (( $#_docker_compose_subcommands > 0 )) && _store_cache docker_compose_subcommands _docker_compose_subcommands fi _describe -t docker-compose-commands "docker-compose command" _docker_compose_subcommands } From 53d56ea2456813c910a6500fbad5841286c94db1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 16:15:52 +0000 Subject: [PATCH 008/580] Quote network names in error messages Signed-off-by: Aanand Prasad --- compose/network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index eaad770c..34159fd6 100644 --- a/compose/network.py +++ b/compose/network.py @@ -44,11 +44,11 @@ class Network(object): data = self.inspect() if self.driver and data['Driver'] != self.driver: raise ConfigurationError( - 'Network {} needs to be recreated - driver has changed' + 'Network "{}" needs to be recreated - driver has changed' .format(self.full_name)) if data['Options'] != (self.driver_opts or {}): raise ConfigurationError( - 'Network {} needs to be recreated - options have changed' + 'Network "{}" needs to be recreated - options have changed' .format(self.full_name)) except NotFound: driver_name = 'the default driver' From e7673bf920162b63173631c6ede97e0e22cb4508 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 16:16:24 +0000 Subject: [PATCH 009/580] Allow overriding of config for the default network Signed-off-by: Aanand Prasad --- compose/project.py | 31 ++++++++++--------- tests/acceptance/cli_test.py | 13 ++++++++ .../networks/default-network-config.yml | 13 ++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/networks/default-network-config.yml diff --git a/compose/project.py b/compose/project.py index 1322c990..777e8f82 100644 --- a/compose/project.py +++ b/compose/project.py @@ -58,23 +58,24 @@ class Project(object): use_networking = (config_data.version and config_data.version >= 2) project = cls(name, [], client, use_networking=use_networking) - custom_networks = [] - if config_data.networks: - for network_name, data in config_data.networks.items(): - custom_networks.append( - Network( - client=client, project=name, name=network_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - external_name=data.get('external_name'), - ) - ) + network_config = config_data.networks or {} + custom_networks = [ + Network( + client=client, project=name, name=network_name, + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external_name=data.get('external_name'), + ) + for network_name, data in network_config.items() + ] + + all_networks = custom_networks[:] + if 'default' not in network_config: + all_networks.append(project.default_network) for service_dict in config_data.services: if use_networking: - networks = get_networks( - service_dict, - custom_networks + [project.default_network]) + networks = get_networks(service_dict, all_networks) net = Net(networks[0]) if networks else Net("none") links = [] else: @@ -96,7 +97,7 @@ class Project(object): **service_dict)) project.networks += custom_networks - if project.uses_default_network(): + if 'default' not in network_config and project.uses_default_network(): project.networks.append(project.default_network) if config_data.volumes: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 700e9cdf..f5c16380 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -409,6 +409,7 @@ class CLITestCase(DockerClientTestCase): networks = self.client.networks(names=[self.project.default_network.full_name]) self.assertEqual(len(networks), 1) self.assertEqual(networks[0]['Driver'], 'bridge') + assert 'com.docker.network.bridge.enable_icc' not in networks[0]['Options'] network = self.client.inspect_network(networks[0]['Id']) @@ -425,6 +426,18 @@ class CLITestCase(DockerClientTestCase): for service in services: assert self.lookup(container, service.name) + @v2_only() + def test_up_with_default_network_config(self): + filename = 'default-network-config.yml' + + self.base_dir = 'tests/fixtures/networks' + self._project = get_project(self.base_dir, [filename]) + + self.dispatch(['-f', filename, 'up', '-d'], None) + + networks = self.client.networks(names=[self.project.default_network.full_name]) + assert networks[0]['Options']['com.docker.network.bridge.enable_icc'] == 'false' + @v2_only() def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' diff --git a/tests/fixtures/networks/default-network-config.yml b/tests/fixtures/networks/default-network-config.yml new file mode 100644 index 00000000..275fae98 --- /dev/null +++ b/tests/fixtures/networks/default-network-config.yml @@ -0,0 +1,13 @@ +version: 2 +services: + simple: + image: busybox:latest + command: top + another: + image: busybox:latest + command: top +networks: + default: + driver: bridge + driver_opts: + "com.docker.network.bridge.enable_icc": "false" From 6b105a6e9297f6ee8c16f331a9e8fa32c366902a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 15:04:10 +0000 Subject: [PATCH 010/580] Update networking docs Signed-off-by: Aanand Prasad --- docs/networking.md | 136 +++++++++++++++++++++++++++++---------------- 1 file changed, 89 insertions(+), 47 deletions(-) diff --git a/docs/networking.md b/docs/networking.md index 91ac0b73..f111f730 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -12,83 +12,125 @@ weight=6 # Networking in Compose -> **Note:** Compose's networking support is experimental, and must be explicitly enabled with the `docker-compose --x-networking` flag. +> **Note:** This document only applies if you're using v2 of the [Compose file format](compose-file.md). Networking features are not supported for legacy Compose files. -Compose sets up a single default +By default Compose sets up a single [network](/engine/reference/commandline/network_create.md) for your app. Each container for a service joins the default network and is both *reachable* by other containers on that network, and *discoverable* by them at a hostname identical to the container name. -> **Note:** Your app's network is given the same name as the "project name", which is based on the name of the directory it lives in. See the [Command line overview](reference/docker-compose.md) for how to override it. +> **Note:** Your app's network is given a name based on the "project name", which is based on the name of the directory it lives in. You can override the project name with either the [`--project-name` flag](reference/docker-compose.md) or the [`COMPOSE_PROJECT_NAME` environment variable](reference/overview.md#compose-project-name). For example, suppose your app is in a directory called `myapp`, and your `docker-compose.yml` looks like this: - web: - build: . - ports: - - "8000:8000" - db: - image: postgres + version: 2 -When you run `docker-compose --x-networking up`, the following happens: + services: + web: + build: . + ports: + - "8000:8000" + db: + image: postgres -1. A network called `myapp` is created. -2. A container is created using `web`'s configuration. It joins the network -`myapp` under the name `myapp_web_1`. -3. A container is created using `db`'s configuration. It joins the network -`myapp` under the name `myapp_db_1`. +When you run `docker-compose up`, the following happens: -Each container can now look up the hostname `myapp_web_1` or `myapp_db_1` and +1. A network called `myapp_default` is created. +2. A container is created using `web`'s configuration. It joins the network + `myapp_default` under the name `web`. +3. A container is created using `db`'s configuration. It joins the network + `myapp_default` under the name `db`. + +Each container can now look up the hostname `web` or `db` and get back the appropriate container's IP address. For example, `web`'s -application code could connect to the URL `postgres://myapp_db_1:5432` and start +application code could connect to the URL `postgres://db:5432` and start using the Postgres database. Because `web` explicitly maps a port, it's also accessible from the outside world via port 8000 on your Docker host's network interface. -> **Note:** in the next release there will be additional aliases for the -> container, including a short name without the project name and container -> index. The full container name will remain as one of the alias for backwards -> compatibility. - ## Updating containers If you make a configuration change to a service and run `docker-compose up` to update it, the old container will be removed and the new one will join the network under a different IP address but the same name. Running containers will be able to look up that name and connect to the new address, but the old address will stop working. If any containers have connections open to the old container, they will be closed. It is a container's responsibility to detect this condition, look up the name again and reconnect. -## Configure how services are published - -By default, containers for each service are published on the network with the -container name. If you want to change the name, or stop containers from being -discoverable at all, you can use the `container_name` option: - - web: - build: . - container_name: "my-web-application" - ## Links -Docker links are a one-way, single-host communication system. They should now be considered deprecated, and you should update your app to use networking instead. In the majority of cases, this will simply involve removing the `links` sections from your `docker-compose.yml`. +Docker links are a one-way, single-host communication system. They should now be considered deprecated, and as part of upgrading your app to the v2 format, you must remove any `links` sections from your `docker-compose.yml` and use service names (e.g. `web`, `db`) as the hostnames to connect to. -## Specifying the network driver - -By default, Compose uses the `bridge` driver when creating the app’s network. The Docker Engine provides one other driver out-of-the-box: `overlay`, which implements secure communication between containers on different hosts (see the next section for how to set up and use the `overlay` driver). Docker also allows you to install [custom network drivers](/engine/extend/plugins_network.md). - -You can specify which one to use with the `--x-network-driver` flag: - - $ docker-compose --x-networking --x-network-driver=overlay up - - +When deploying a Compose application to a Swarm cluster, you can make use of the built-in `overlay` driver to enable multi-host communication between containers with no changes to application code. Consult the [Getting started with multi-host networking](/engine/userguide/networking/get-started-overlay.md) to see how to set up the overlay driver, and then specify `driver: overlay` in your networking config (see the sections below for how to do this). + +## Specifying custom networks + +Instead of just using the default app network, you can specify your own networks with the top-level `networks` key. This lets you create more complex topologies and specify [custom network drivers](/engine/extend/plugins_network.md) and options. You can also use it to connect services to externally-created networks which aren't managed by Compose. + +Each service can specify what networks to connect to with the *service-level* `networks` key, which is a list of names referencing entries under the *top-level* `networks` key. + +Here's an example Compose file defining several networks. The `proxy` service is the gateway to the outside world, via a network called `outside` which is expected to already exist. `proxy` is isolated from the `db` service, because they do not share a network in common - only `app` can talk to both. + + version: 2 + + services: + proxy: + build: ./proxy + networks: + - outside + - front + app: + build: ./app + networks: + - front + - back + db: + image: postgres + networks: + - back + + networks: + front: + # Use the overlay driver for multi-host communication + driver: overlay + back: + # Use a custom driver which takes special options + driver: my-custom-driver + options: + foo: "1" + bar: "2" + outside: + # The 'outside' network is expected to already exist - Compose will not + # attempt to create it + external: true + +## Configuring the default network + +Instead of (or as well as) specifying your own networks, you can also change the settings of the app-wide default network by defining an entry under `networks` named `default`: + + version: 2 + + services: + web: + build: . + ports: + - "8000:8000" + db: + image: postgres + + networks: + default: + # Use the overlay driver for multi-host communication + driver: overlay ## Custom container network modes -Compose allows you to specify a custom network mode for a service with the `net` option - for example, `net: "host"` specifies that its containers should use the same network namespace as the Docker host, and `net: "none"` specifies that they should have no networking capabilities. +The `docker` CLI command allows you to specify a custom network mode for a container with the `--net` option - for example, `--net=host` specifies that the container should use the same network namespace as the Docker host, and `--net=none` specifies that it should have no networking capabilities. -If a service specifies the `net` option, its containers will *not* join the app’s network and will not be able to communicate with other services in the app. +To make use of this in Compose, specify a `networks` list with a single item `host`, `bridge` or `none`: -If *all* services in an app specify the `net` option, a network will not be created at all. + app: + build: ./app + networks: ["host"] + +There is no equivalent to `--net=container:CONTAINER_NAME` in the v2 Compose file format. You should instead use networks to enable communication. From afae3650508f8643f77065cdf7fd160119f07d3b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 17:08:50 +0000 Subject: [PATCH 011/580] Allow custom ipam config Signed-off-by: Aanand Prasad --- compose/network.py | 28 +++++++++++++-- compose/project.py | 1 + tests/integration/project_test.py | 57 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index 34159fd6..4f4f5522 100644 --- a/compose/network.py +++ b/compose/network.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals import logging from docker.errors import NotFound +from docker.utils import create_ipam_config +from docker.utils import create_ipam_pool from .config import ConfigurationError @@ -13,12 +15,13 @@ log = logging.getLogger(__name__) class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - external_name=None): + ipam=None, external_name=None): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts + self.ipam = create_ipam_config_from_dict(ipam) self.external_name = external_name def ensure(self): @@ -61,7 +64,10 @@ class Network(object): ) self.client.create_network( - self.full_name, self.driver, self.driver_opts + name=self.full_name, + driver=self.driver, + options=self.driver_opts, + ipam=self.ipam, ) def remove(self): @@ -80,3 +86,21 @@ class Network(object): if self.external_name: return self.external_name return '{0}_{1}'.format(self.project, self.name) + + +def create_ipam_config_from_dict(ipam_dict): + if not ipam_dict: + return None + + return create_ipam_config( + driver=ipam_dict.get('driver'), + pool_configs=[ + create_ipam_pool( + subnet=config.get('subnet'), + iprange=config.get('ip_range'), + gateway=config.get('gateway'), + aux_addresses=config.get('aux_addresses'), + ) + for config in ipam_dict.get('config', []) + ], + ) diff --git a/compose/project.py b/compose/project.py index 777e8f82..080c4c49 100644 --- a/compose/project.py +++ b/compose/project.py @@ -64,6 +64,7 @@ class Project(object): client=client, project=name, name=network_name, driver=data.get('driver'), driver_opts=data.get('driver_opts'), + ipam=data.get('ipam'), external_name=data.get('external_name'), ) for network_name, data in network_config.items() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d29d9f1e..d3fbb71e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -516,6 +516,63 @@ class ProjectTest(DockerClientTestCase): foo_data = self.client.inspect_network('composetest_foo') self.assertEqual(foo_data['Driver'], 'bridge') + @v2_only() + def test_up_with_ipam_config(self): + config_data = config.Config( + version=2, + services=[], + volumes={}, + networks={ + 'front': { + 'driver': 'bridge', + 'driver_opts': { + "com.docker.network.bridge.enable_icc": "false", + }, + 'ipam': { + 'driver': 'default', + 'config': [{ + "subnet": "172.28.0.0/16", + "ip_range": "172.28.5.0/24", + "gateway": "172.28.5.254", + "aux_addresses": { + "a": "172.28.1.5", + "b": "172.28.1.6", + "c": "172.28.1.7", + }, + }], + }, + }, + }, + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + project.up() + + network = self.client.networks(names=['composetest_front'])[0] + + assert network['Options'] == { + "com.docker.network.bridge.enable_icc": "false" + } + + assert network['IPAM'] == { + 'Driver': 'default', + 'Options': None, + 'Config': [{ + 'Subnet': "172.28.0.0/16", + 'IPRange': "172.28.5.0/24", + 'Gateway': "172.28.5.254", + 'AuxiliaryAddresses': { + 'a': '172.28.1.5', + 'b': '172.28.1.6', + 'c': '172.28.1.7', + }, + }], + } + @v2_only() def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) From 66dd9ae9a49bb4483601a161adf7f45e73fb5710 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 18 Jan 2016 15:55:30 -0500 Subject: [PATCH 012/580] Fix some bugs in release scripts. Signed-off-by: Daniel Nephin --- script/release/contributors | 11 +++++++---- script/release/push-release | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/script/release/contributors b/script/release/contributors index bb9fe871..1e69b143 100755 --- a/script/release/contributors +++ b/script/release/contributors @@ -18,10 +18,13 @@ PREV_RELEASE=$1 VERSION=HEAD URL="https://api.github.com/repos/docker/compose/compare" -curl -sf "$URL/$PREV_RELEASE...$VERSION" | \ +contribs=$(curl -sf "$URL/$PREV_RELEASE...$VERSION" | \ jq -r '.commits[].author.login' | \ sort | \ uniq -c | \ - sort -nr | \ - awk '{print "@"$2","}' | \ - xargs echo + sort -nr) + +echo "Contributions by user: " +echo "$contribs" +echo +echo "$contribs" | awk '{print "@"$2","}' | xargs diff --git a/script/release/push-release b/script/release/push-release index b754d40f..7d9ec0a2 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -60,7 +60,7 @@ sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/maste ./script/write-git-sha python setup.py sdist if [ "$(command -v twine 2> /dev/null)" ]; then - twine upload ./dist/docker-compose-${VERSION}.tar.gz + twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz else python setup.py upload fi From 9ccef1ea916a55d87102f1fba7b3ff5e484882ed Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 19:22:04 +0000 Subject: [PATCH 013/580] Catch TLSParameterErrors from docker-py Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 611997df..b680616e 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -5,9 +5,11 @@ import logging import os from docker import Client +from docker.errors import TLSParameterError from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT +from .errors import UserError log = logging.getLogger(__name__) @@ -20,8 +22,16 @@ def docker_client(version=None): if 'DOCKER_CLIENT_TIMEOUT' in os.environ: log.warn('The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. Please use COMPOSE_HTTP_TIMEOUT instead.') - kwargs = kwargs_from_env(assert_hostname=False) + try: + kwargs = kwargs_from_env(assert_hostname=False) + except TLSParameterError: + raise UserError( + 'TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY and DOCKER_CERT_PATH are set correctly.\n' + 'You might need to run `eval "$(docker-machine env default)"`') + if version: kwargs['version'] = version + kwargs['timeout'] = HTTP_TIMEOUT + return Client(**kwargs) From 746033ed9dd631b1bac68d409c32d17c5be8944f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 19 Jan 2016 10:57:12 +0000 Subject: [PATCH 014/580] Test that you can set the default network to be external Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 23 ++++++++++++++++++++ tests/fixtures/networks/external-default.yml | 12 ++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/fixtures/networks/external-default.yml diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f5c16380..4c278aa4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -528,6 +528,29 @@ class CLITestCase(DockerClientTestCase): container = self.project.containers()[0] assert sorted(list(container.get('NetworkSettings.Networks'))) == sorted(network_names) + @v2_only() + def test_up_with_external_default_network(self): + filename = 'external-default.yml' + + self.base_dir = 'tests/fixtures/networks' + self._project = get_project(self.base_dir, [filename]) + + result = self.dispatch(['-f', filename, 'up', '-d'], returncode=1) + assert 'declared as external, but could not be found' in result.stderr + + networks = [ + n['Name'] for n in self.client.networks() + if n['Name'].startswith('{}_'.format(self.project.name)) + ] + assert not networks + + network_name = 'composetest_external_network' + self.client.create_network(network_name) + + self.dispatch(['-f', filename, 'up', '-d']) + container = self.project.containers()[0] + assert list(container.get('NetworkSettings.Networks')) == [network_name] + @v2_only() def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' diff --git a/tests/fixtures/networks/external-default.yml b/tests/fixtures/networks/external-default.yml new file mode 100644 index 00000000..7b0797e5 --- /dev/null +++ b/tests/fixtures/networks/external-default.yml @@ -0,0 +1,12 @@ +version: 2 +services: + simple: + image: busybox:latest + command: top + another: + image: busybox:latest + command: top +networks: + default: + external: + name: composetest_external_network From 2106481c23429eaf59e653b1bd107c8a809c3cec Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 19 Jan 2016 11:27:27 +0000 Subject: [PATCH 015/580] Fix "name is reserved" with Engine 1.10 RC1 Ensure link aliases are unique (this deduping was previously performed on the server). Signed-off-by: Aanand Prasad --- compose/service.py | 25 ++++++++++++++++--------- tests/integration/service_test.py | 4 ---- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/compose/service.py b/compose/service.py index f5db07fb..f72863c9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -502,24 +502,31 @@ class Service(object): if self.use_networking: return [] - links = [] + links = {} + for service, link_name in self.links: for container in service.containers(): - links.append((container.name, link_name or service.name)) - links.append((container.name, container.name)) - links.append((container.name, container.name_without_project)) + links[link_name or service.name] = container.name + links[container.name] = container.name + links[container.name_without_project] = container.name + if link_to_self: for container in self.containers(): - links.append((container.name, self.name)) - links.append((container.name, container.name)) - links.append((container.name, container.name_without_project)) + links[self.name] = container.name + links[container.name] = container.name + links[container.name_without_project] = container.name + for external_link in self.options.get('external_links') or []: if ':' not in external_link: link_name = external_link else: external_link, link_name = external_link.split(':') - links.append((external_link, link_name)) - return links + links[link_name] = external_link + + return [ + (alias, container_name) + for (container_name, alias) in links.items() + ] def _get_volumes_from(self): return [build_volume_from(spec) for spec in self.volumes_from] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index bce3999b..0e91dcf7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -7,7 +7,6 @@ import tempfile from os import path from docker.errors import APIError -from pytest import mark from six import StringIO from six import text_type @@ -372,7 +371,6 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(db) self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') - @mark.skipif(True, reason="Engine returns error - needs investigating") def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[(db, None)]) @@ -389,7 +387,6 @@ class ServiceTest(DockerClientTestCase): 'db']) ) - @mark.skipif(True, reason="Engine returns error - needs investigating") def test_start_container_creates_links_with_names(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'custom_link_name')]) @@ -433,7 +430,6 @@ class ServiceTest(DockerClientTestCase): c = create_and_start_container(db) self.assertEqual(set(get_links(c)), set([])) - @mark.skipif(True, reason="Engine returns error - needs investigating") def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') From c39489f540a0ee3d027768729fc3895dbd7ecae8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 14:41:21 -0500 Subject: [PATCH 016/580] Don't copy over volumes that were previously host volumes, and are now container volumes. Signed-off-by: Daniel Nephin --- compose/service.py | 4 ++++ tests/integration/service_test.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/compose/service.py b/compose/service.py index f72863c9..ebbb20cf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -911,6 +911,10 @@ def get_container_data_volumes(container, volumes_option): if not mount: continue + # Volume was previously a host volume, now it's a container volume + if not mount.get('Name'): + continue + # Copy existing volume from old container volume = volume._replace(external=mount['Source']) volumes.append(volume) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0e91dcf7..1847c3af 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -343,6 +343,31 @@ class ServiceTest(DockerClientTestCase): ) self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) + def test_execute_convergence_plan_when_host_volume_is_removed(self): + host_path = '/tmp/host-path' + service = self.create_service( + 'db', + build={'context': 'tests/fixtures/dockerfile-with-volume'}, + volumes=[VolumeSpec(host_path, '/data', 'rw')]) + + old_container = create_and_start_container(service) + assert ( + [mount['Destination'] for mount in old_container.get('Mounts')] == + ['/data'] + ) + service.options['volumes'] = [] + + with mock.patch('compose.service.log', autospec=True) as mock_log: + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container])) + + assert not mock_log.warn.called + assert ( + [mount['Destination'] for mount in new_container.get('Mounts')], + ['/data'] + ) + assert new_container.get_mount('/data')['Source'] != host_path + def test_execute_convergence_plan_without_start(self): service = self.create_service( 'db', From 85619842be6a5c1d5a8068aeb28e158ec32c41ca Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 18 Jan 2016 14:02:25 -0500 Subject: [PATCH 017/580] Add migration script. Signed-off-by: Daniel Nephin --- .../migrate-compose-file-v1-to-v2.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100755 contrib/migration/migrate-compose-file-v1-to-v2.py diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py new file mode 100755 index 00000000..a961b0bd --- /dev/null +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +""" +Migrate a Compose file from the V1 format in Compose 1.5 to the V2 format +supported by Compose 1.6+ +""" +from __future__ import absolute_import +from __future__ import unicode_literals + +import argparse +import logging +import sys + +import ruamel.yaml + + +log = logging.getLogger('migrate') + + +def migrate(content): + data = ruamel.yaml.load(content, ruamel.yaml.RoundTripLoader) + + service_names = data.keys() + for name, service in data.items(): + # remove links and external links + service.pop('links', None) + external_links = service.pop('external_links', None) + if external_links: + log.warn( + "Service {name} has external_links: {ext}, which are no longer " + "supported. See https://docs.docker.com/compose/networking/ " + "for options on how to connect external containers to the " + "compose network.".format(name=name, ext=external_links)) + + # net is now networks + if 'net' in service: + service['networks'] = [service.pop('net')] + + # create build section + if 'dockerfile' in service: + service['build'] = { + 'context': service.pop('build'), + 'dockerfile': service.pop('dockerfile'), + } + + # create logging section + if 'log_driver' in service: + service['logging'] = {'driver': service.pop('log_driver')} + if 'log_opt' in service: + service['logging']['options'] = service.pop('log_opt') + + # volumes_from prefix with 'container:' + for idx, volume_from in enumerate(service.get('volumes_from', [])): + if volume_from.split(':', 1)[0] not in service_names: + service['volumes_from'][idx] = 'container:%s' % volume_from + + data['services'] = {name: data.pop(name) for name in data.keys()} + data['version'] = 2 + return data + + +def write(stream, new_format, indent, width): + ruamel.yaml.dump( + new_format, + stream, + Dumper=ruamel.yaml.RoundTripDumper, + indent=indent, + width=width) + + +def parse_opts(args): + parser = argparse.ArgumentParser() + parser.add_argument("filename", help="Compose file filename.") + parser.add_argument("-i", "--in-place", action='store_true') + parser.add_argument( + "--indent", type=int, default=2, + help="Number of spaces used to indent the output yaml.") + parser.add_argument( + "--width", type=int, default=80, + help="Number of spaces used as the output width.") + return parser.parse_args() + + +def main(args): + logging.basicConfig() + + opts = parse_opts(args) + + with open(opts.filename, 'r') as fh: + new_format = migrate(fh.read()) + + if opts.in_place: + output = open(opts.filename, 'w') + else: + output = sys.stdout + write(output, new_format, opts.indent, opts.width) + + +if __name__ == "__main__": + main(sys.argv) From 0bce467782c83b9065a9efaadc1405ba8862116a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:41:45 -0500 Subject: [PATCH 018/580] Implement depends_on to define an order for services in the v2 format. Signed-off-by: Daniel Nephin --- compose/config/config.py | 15 ++++++++++--- compose/config/service_schema_v2.json | 1 + compose/config/sort_services.py | 9 +++++--- compose/config/validation.py | 8 +++++++ compose/service.py | 3 ++- tests/unit/config/config_test.py | 28 ++++++++++++++++++++++++- tests/unit/config/sort_services_test.py | 14 +++++++++++++ 7 files changed, 70 insertions(+), 8 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ac5e8d17..6bba53b8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -27,6 +27,7 @@ from .types import VolumeFromSpec from .types import VolumeSpec from .validation import validate_against_fields_schema from .validation import validate_against_service_schema +from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_top_level_object from .validation import validate_top_level_service_objects @@ -312,9 +313,10 @@ def load_services(working_dir, config_file, service_configs): resolver = ServiceExtendsResolver(service_config, config_file) service_dict = process_service(resolver.run()) - validate_service(service_dict, service_config.name, config_file.version) + service_config = service_config._replace(config=service_dict) + validate_service(service_config, service_names, config_file.version) service_dict = finalize_service( - service_config._replace(config=service_dict), + service_config, service_names, config_file.version) return service_dict @@ -481,6 +483,10 @@ def validate_extended_service_dict(service_dict, filename, service): raise ConfigurationError( "%s services with 'net: container' cannot be extended" % error_prefix) + if 'depends_on' in service_dict: + raise ConfigurationError( + "%s services with 'depends_on' cannot be extended" % error_prefix) + def validate_ulimits(ulimit_config): for limit_name, soft_hard_values in six.iteritems(ulimit_config): @@ -491,13 +497,16 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) -def validate_service(service_dict, service_name, version): +def validate_service(service_config, service_names, version): + service_dict, service_name = service_config.config, service_config.name validate_against_service_schema(service_dict, service_name, version) validate_paths(service_dict) if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) + validate_depends_on(service_config, service_names) + if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( "Service '{name}' contains uppercase characters which are not valid " diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 8623507a..d4ec575a 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -42,6 +42,7 @@ "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, "cpuset": {"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"}, diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 05552122..ac0fa458 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -33,7 +33,8 @@ def sort_service_dicts(services): service for service in services if (name in get_service_names(service.get('links', [])) or name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or - name == get_service_name_from_net(service.get('net'))) + name == get_service_name_from_net(service.get('net')) or + name in service.get('depends_on', [])) ] def visit(n): @@ -42,8 +43,10 @@ def sort_service_dicts(services): raise DependencyError('A service can not link to itself: %s' % n['name']) if n['name'] in n.get('volumes_from', []): raise DependencyError('A service can not mount itself as volume: %s' % n['name']) - else: - raise DependencyError('Circular import between %s' % ' and '.join(temporary_marked)) + if n['name'] in n.get('depends_on', []): + raise DependencyError('A service can not depend on itself: %s' % n['name']) + raise DependencyError('Circular dependency between %s' % ' and '.join(temporary_marked)) + if n in unmarked: temporary_marked.add(n['name']) for m in get_service_dependents(n, services): diff --git a/compose/config/validation.py b/compose/config/validation.py index 639e8bed..6cce1105 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -123,6 +123,14 @@ def validate_extends_file_path(service_name, extends_options, filename): ) +def validate_depends_on(service_config, service_names): + for dependency in service_config.config.get('depends_on', []): + if dependency not in service_names: + raise ConfigurationError( + "Service '{s.name}' depends on service '{dep}' which is " + "undefined.".format(s=service_config, dep=dependency)) + + def get_unsupported_config_msg(service_name, error_key): msg = "Unsupported config option for '{}' service: '{}'".format(service_name, error_key) if error_key in DOCKER_CONFIG_HINTS: diff --git a/compose/service.py b/compose/service.py index f72863c9..1dfda06a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -471,7 +471,8 @@ class Service(object): net_name = self.net.service_name return (self.get_linked_service_names() + self.get_volumes_from_names() + - ([net_name] if net_name else [])) + ([net_name] if net_name else []) + + self.options.get('depends_on', [])) def get_linked_service_names(self): return [service.name for (service, _) in self.links] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index cc205136..0416d5b7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -894,9 +894,35 @@ class ConfigTest(unittest.TestCase): 'ext': {'external': True, 'driver': 'foo'} } }) - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load(config_details) + def test_depends_on_orders_services(self): + config_details = build_config_details({ + 'version': 2, + 'services': { + 'one': {'image': 'busybox', 'depends_on': ['three', 'two']}, + 'two': {'image': 'busybox', 'depends_on': ['three']}, + 'three': {'image': 'busybox'}, + }, + }) + actual = config.load(config_details) + assert ( + [service['name'] for service in actual.services] == + ['three', 'two', 'one'] + ) + + def test_depends_on_unknown_service_errors(self): + config_details = build_config_details({ + 'version': 2, + 'services': { + 'one': {'image': 'busybox', 'depends_on': ['three']}, + }, + }) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert "Service 'one' depends on service 'three'" in exc.exconly() + class PortsTest(unittest.TestCase): INVALID_PORTS_TYPES = [ diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index ebe444fe..3279ece4 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import pytest + from compose.config.errors import DependencyError from compose.config.sort_services import sort_service_dicts from compose.config.types import VolumeFromSpec @@ -240,3 +242,15 @@ class SortServiceTest(unittest.TestCase): self.assertIn('web', e.msg) else: self.fail('Should have thrown an DependencyError') + + def test_sort_service_dicts_depends_on_self(self): + services = [ + { + 'depends_on': ['web'], + 'name': 'web' + }, + ] + + with pytest.raises(DependencyError) as exc: + sort_service_dicts(services) + assert 'A service can not depend on itself: web' in exc.exconly() From 146587643c32c076b93b1fa67cd531b5b2003227 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:47:57 -0500 Subject: [PATCH 019/580] Move ulimits validation to validation.py and improve the error message. Signed-off-by: Daniel Nephin --- compose/config/config.py | 14 ++------------ compose/config/validation.py | 12 ++++++++++++ tests/unit/config/config_test.py | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 6bba53b8..72ad50af 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,6 +31,7 @@ from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_top_level_object from .validation import validate_top_level_service_objects +from .validation import validate_ulimits DOCKER_CONFIG_KEYS = [ @@ -488,23 +489,12 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'depends_on' cannot be extended" % error_prefix) -def validate_ulimits(ulimit_config): - for limit_name, soft_hard_values in six.iteritems(ulimit_config): - if isinstance(soft_hard_values, dict): - if not soft_hard_values['soft'] <= soft_hard_values['hard']: - raise ConfigurationError( - "ulimit_config \"{}\" cannot contain a 'soft' value higher " - "than 'hard' value".format(ulimit_config)) - - def validate_service(service_config, service_names, version): service_dict, service_name = service_config.config, service_config.name validate_against_service_schema(service_dict, service_name, version) validate_paths(service_dict) - if 'ulimits' in service_dict: - validate_ulimits(service_dict['ulimits']) - + validate_ulimits(service_config) validate_depends_on(service_config, service_names) if not service_dict.get('image') and has_uppercase(service_name): diff --git a/compose/config/validation.py b/compose/config/validation.py index 6cce1105..ecf8d4f9 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -110,6 +110,18 @@ def validate_top_level_object(config_file): type(config_file.config))) +def validate_ulimits(service_config): + ulimit_config = service_config.config.get('ulimits', {}) + for limit_name, soft_hard_values in six.iteritems(ulimit_config): + if isinstance(soft_hard_values, dict): + if not soft_hard_values['soft'] <= soft_hard_values['hard']: + raise ConfigurationError( + "Service '{s.name}' has invalid ulimit '{ulimit}'. " + "'soft' value can not be greater than 'hard' value ".format( + s=service_config, + ulimit=ulimit_config)) + + def validate_extends_file_path(service_name, extends_options, filename): """ The service to be extended must either be defined in the config key 'file', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0416d5b7..3c3c6326 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -700,7 +700,7 @@ class ConfigTest(unittest.TestCase): assert "'hard' is a required property" in exc.exconly() def test_config_ulimits_soft_greater_than_hard_error(self): - expected = "cannot contain a 'soft' value higher than 'hard' value" + expected = "'soft' value can not be greater than 'hard' value" with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( From 5aadf5a187b436ddb81772058dbedeaeea804d95 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:52:17 -0500 Subject: [PATCH 020/580] Update tests in sort_services_test.py to use pytest. Signed-off-by: Daniel Nephin --- tests/unit/config/sort_services_test.py | 95 +++++++++++-------------- 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index 3279ece4..f5990664 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -6,10 +6,9 @@ import pytest from compose.config.errors import DependencyError from compose.config.sort_services import sort_service_dicts from compose.config.types import VolumeFromSpec -from tests import unittest -class SortServiceTest(unittest.TestCase): +class TestSortService(object): def test_sort_service_dicts_1(self): services = [ { @@ -25,10 +24,10 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 3) - self.assertEqual(sorted_services[0]['name'], 'grunt') - self.assertEqual(sorted_services[1]['name'], 'redis') - self.assertEqual(sorted_services[2]['name'], 'web') + assert len(sorted_services) == 3 + assert sorted_services[0]['name'] == 'grunt' + assert sorted_services[1]['name'] == 'redis' + assert sorted_services[2]['name'] == 'web' def test_sort_service_dicts_2(self): services = [ @@ -46,10 +45,10 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 3) - self.assertEqual(sorted_services[0]['name'], 'redis') - self.assertEqual(sorted_services[1]['name'], 'postgres') - self.assertEqual(sorted_services[2]['name'], 'web') + assert len(sorted_services) == 3 + assert sorted_services[0]['name'] == 'redis' + assert sorted_services[1]['name'] == 'postgres' + assert sorted_services[2]['name'] == 'web' def test_sort_service_dicts_3(self): services = [ @@ -67,10 +66,10 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 3) - self.assertEqual(sorted_services[0]['name'], 'child') - self.assertEqual(sorted_services[1]['name'], 'parent') - self.assertEqual(sorted_services[2]['name'], 'grandparent') + assert len(sorted_services) == 3 + assert sorted_services[0]['name'] == 'child' + assert sorted_services[1]['name'] == 'parent' + assert sorted_services[2]['name'] == 'grandparent' def test_sort_service_dicts_4(self): services = [ @@ -88,10 +87,10 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 3) - self.assertEqual(sorted_services[0]['name'], 'child') - self.assertEqual(sorted_services[1]['name'], 'parent') - self.assertEqual(sorted_services[2]['name'], 'grandparent') + assert len(sorted_services) == 3 + assert sorted_services[0]['name'] == 'child' + assert sorted_services[1]['name'] == 'parent' + assert sorted_services[2]['name'] == 'grandparent' def test_sort_service_dicts_5(self): services = [ @@ -109,10 +108,10 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 3) - self.assertEqual(sorted_services[0]['name'], 'child') - self.assertEqual(sorted_services[1]['name'], 'parent') - self.assertEqual(sorted_services[2]['name'], 'grandparent') + assert len(sorted_services) == 3 + assert sorted_services[0]['name'] == 'child' + assert sorted_services[1]['name'] == 'parent' + assert sorted_services[2]['name'] == 'grandparent' def test_sort_service_dicts_6(self): services = [ @@ -130,10 +129,10 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 3) - self.assertEqual(sorted_services[0]['name'], 'child') - self.assertEqual(sorted_services[1]['name'], 'parent') - self.assertEqual(sorted_services[2]['name'], 'grandparent') + assert len(sorted_services) == 3 + assert sorted_services[0]['name'] == 'child' + assert sorted_services[1]['name'] == 'parent' + assert sorted_services[2]['name'] == 'grandparent' def test_sort_service_dicts_7(self): services = [ @@ -155,11 +154,11 @@ class SortServiceTest(unittest.TestCase): ] sorted_services = sort_service_dicts(services) - self.assertEqual(len(sorted_services), 4) - self.assertEqual(sorted_services[0]['name'], 'one') - self.assertEqual(sorted_services[1]['name'], 'two') - self.assertEqual(sorted_services[2]['name'], 'three') - self.assertEqual(sorted_services[3]['name'], 'four') + assert len(sorted_services) == 4 + assert sorted_services[0]['name'] == 'one' + assert sorted_services[1]['name'] == 'two' + assert sorted_services[2]['name'] == 'three' + assert sorted_services[3]['name'] == 'four' def test_sort_service_dicts_circular_imports(self): services = [ @@ -173,13 +172,10 @@ class SortServiceTest(unittest.TestCase): }, ] - try: + with pytest.raises(DependencyError) as exc: sort_service_dicts(services) - except DependencyError as e: - self.assertIn('redis', e.msg) - self.assertIn('web', e.msg) - else: - self.fail('Should have thrown an DependencyError') + assert 'redis' in exc.exconly() + assert 'web' in exc.exconly() def test_sort_service_dicts_circular_imports_2(self): services = [ @@ -196,13 +192,10 @@ class SortServiceTest(unittest.TestCase): } ] - try: + with pytest.raises(DependencyError) as exc: sort_service_dicts(services) - except DependencyError as e: - self.assertIn('redis', e.msg) - self.assertIn('web', e.msg) - else: - self.fail('Should have thrown an DependencyError') + assert 'redis' in exc.exconly() + assert 'web' in exc.exconly() def test_sort_service_dicts_circular_imports_3(self): services = [ @@ -220,13 +213,10 @@ class SortServiceTest(unittest.TestCase): } ] - try: + with pytest.raises(DependencyError) as exc: sort_service_dicts(services) - except DependencyError as e: - self.assertIn('a', e.msg) - self.assertIn('b', e.msg) - else: - self.fail('Should have thrown an DependencyError') + assert 'a' in exc.exconly() + assert 'b' in exc.exconly() def test_sort_service_dicts_self_imports(self): services = [ @@ -236,12 +226,9 @@ class SortServiceTest(unittest.TestCase): }, ] - try: + with pytest.raises(DependencyError) as exc: sort_service_dicts(services) - except DependencyError as e: - self.assertIn('web', e.msg) - else: - self.fail('Should have thrown an DependencyError') + assert 'web' in exc.exconly() def test_sort_service_dicts_depends_on_self(self): services = [ From 3b1a0e6fc931dcef6147b2370b1b7779fd13d2e4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:03:41 -0500 Subject: [PATCH 021/580] Add stop signal to the docs. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 32 ++++++++++++++++++++------------ docs/index.md | 3 +-- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index ecd135f1..a13b39e4 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -469,9 +469,17 @@ port (a random host port will be chosen). Override the default labeling scheme for each container. - security_opt: - - label:user:USER - - label:role:ROLE + security_opt: + - label:user:USER + - label:role:ROLE + +### stop_signal + +Sets an alternative signal to stop the container. By default `stop` uses +SIGTERM. Setting an alternative signal using `stop_signal` will cause +`stop` to send that signal instead. + + stop_signal: SIGUSR1 ### ulimits @@ -479,11 +487,11 @@ Override the default ulimits for a container. You can either specify a single limit as an integer or soft/hard limits as a mapping. - ulimits: - nproc: 65535 - nofile: - soft: 20000 - hard: 40000 + ulimits: + nproc: 65535 + nofile: + soft: 20000 + hard: 40000 ### volumes, volume\_driver @@ -564,7 +572,7 @@ subcommand documentation for more information. Specify which volume driver should be used for this volume. Defaults to `local`. An exception will be raised if the driver is not available. - driver: foobar + driver: foobar ### driver_opts @@ -572,9 +580,9 @@ Specify a list of options as key-value pairs to pass to the driver for this volume. Those options are driver dependent - consult the driver's documentation for more information. Optional. - driver_opts: - foo: "bar" - baz: 1 + driver_opts: + foo: "bar" + baz: 1 ## Variable substitution diff --git a/docs/index.md b/docs/index.md index 887df99d..9cb594a7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,8 +45,7 @@ A `docker-compose.yml` looks like this: redis: image: redis volumes: - logvolume01: - driver: default + logvolume01: {} For more information about the Compose file, see the [Compose file reference](compose-file.md) From 907c3ce42b6f1033546e1cb6cd17aa801b035463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jure=20=C5=BDvelc?= Date: Wed, 20 Jan 2016 13:01:04 +0100 Subject: [PATCH 022/580] =?UTF-8?q?Fix=20for=20extending=20services=20writ?= =?UTF-8?q?ten=20in=20v2=20format.=20Signed-off-by:=20Jure=20=C5=BDvelc=20?= =?UTF-8?q??= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose/config/config.py | 12 ++++++++---- tests/unit/config/config_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ac5e8d17..2f513fb5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -134,6 +134,9 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): return 1 return version + def get_service(self, name): + return self.get_service_dicts()[name] + def get_service_dicts(self): return self.config if self.version == 1 else self.config.get('services', {}) @@ -351,19 +354,19 @@ def process_config_file(config_file, service_name=None): if config_file.version == 2: processed_config = dict(config_file.config) - processed_config['services'] = interpolated_config + processed_config['services'] = services = interpolated_config processed_config['volumes'] = interpolate_environment_variables( config_file.get_volumes(), 'volume') processed_config['networks'] = interpolate_environment_variables( config_file.get_networks(), 'network') if config_file.version == 1: - processed_config = interpolated_config + processed_config = services = interpolated_config config_file = config_file._replace(config=processed_config) validate_against_fields_schema(config_file) - if service_name and service_name not in processed_config: + if service_name and service_name not in services: raise ConfigurationError( "Cannot extend service '{}' in {}: Service not found".format( service_name, config_file.filename)) @@ -408,7 +411,8 @@ class ServiceExtendsResolver(object): extended_file = process_config_file( extends_file, service_name=service_name) - service_config = extended_file.config[service_name] + service_config = extended_file.get_service(service_name) + return config_path, service_config, service_name def resolve_extends(self, extended_config_path, service_dict, service_name): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index cc205136..b5164ade 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1911,6 +1911,30 @@ class ExtendsTest(unittest.TestCase): load_from_filename(str(tmpdir.join('docker-compose.yml'))) assert 'Version mismatch' in exc.exconly() + def test_extends_with_defined_version_passes(self): + tmpdir = py.test.ensuretemp('test_extends_with_defined_version') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: 2 + services: + web: + extends: + file: base.yml + service: base + image: busybox + """) + tmpdir.join('base.yml').write(""" + version: 2 + services: + base: + volumes: ['/foo'] + ports: ['3000:3000'] + command: top + """) + + service = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + self.assertEquals(service[0]['command'], "top") + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From 5a249bd9f52ab28600564ff4753b17163268cbb5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 11:38:01 +0000 Subject: [PATCH 023/580] Fix Windows build failures when installing dependencies from git Signed-off-by: Aanand Prasad --- script/build-windows.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 index 28011b1d..4a2bc1f7 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -41,6 +41,9 @@ Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } # Create virtualenv virtualenv .\venv +# pip and pyinstaller generate lots of warnings, so we need to ignore them +$ErrorActionPreference = "Continue" + # Install dependencies .\venv\Scripts\pip install pypiwin32==219 .\venv\Scripts\pip install -r requirements.txt @@ -50,8 +53,6 @@ virtualenv .\venv git rev-parse --short HEAD | out-file -encoding ASCII compose\GITSHA # Build binary -# pyinstaller has lots of warnings, so we need to run with ErrorAction = Continue -$ErrorActionPreference = "Continue" .\venv\Scripts\pyinstaller .\docker-compose.spec $ErrorActionPreference = "Stop" From ee63075a347d89ef2afe3ae5c76357cf8ba46e08 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 20 Jan 2016 17:08:24 +0000 Subject: [PATCH 024/580] Support links in v2 files Signed-off-by: Aanand Prasad --- compose/config/service_schema_v2.json | 1 + compose/project.py | 3 +-- compose/service.py | 13 +++++++------ tests/acceptance/cli_test.py | 19 +++++++------------ tests/fixtures/networks/docker-compose.yml | 2 ++ tests/unit/service_test.py | 8 -------- 6 files changed, 18 insertions(+), 28 deletions(-) diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index d4ec575a..94046d5b 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -88,6 +88,7 @@ "image": {"type": "string"}, "ipc": {"type": "string"}, "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { "type": "object", diff --git a/compose/project.py b/compose/project.py index 080c4c49..0fea875a 100644 --- a/compose/project.py +++ b/compose/project.py @@ -78,12 +78,11 @@ class Project(object): if use_networking: networks = get_networks(service_dict, all_networks) net = Net(networks[0]) if networks else Net("none") - links = [] else: networks = [] net = project.get_net(service_dict) - links = project.get_links(service_dict) + links = project.get_links(service_dict) volumes_from = get_volumes_from(project, service_dict) project.services.append( diff --git a/compose/service.py b/compose/service.py index 1dfda06a..ea4e57d0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -426,10 +426,11 @@ class Service(object): def connect_container_to_networks(self, container): for network in self.networks: - log.debug('Connecting "{}" to "{}"'.format(container.name, network)) self.client.connect_container_to_network( container.id, network, - aliases=[self.name]) + aliases=[self.name], + links=self._get_links(False), + ) def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): for c in self.duplicate_containers(): @@ -500,9 +501,6 @@ class Service(object): return 1 if not numbers else max(numbers) + 1 def _get_links(self, link_to_self): - if self.use_networking: - return [] - links = {} for service, link_name in self.links: @@ -645,7 +643,10 @@ class Service(object): def _get_container_networking_config(self): return self.client.create_networking_config({ - network_name: self.client.create_endpoint_config(aliases=[self.name]) + network_name: self.client.create_endpoint_config( + aliases=[self.name], + links=self._get_links(False), + ) for network_name in self.networks if network_name not in ['host', 'bridge'] }) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 4c278aa4..1806e707 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -461,6 +461,10 @@ class CLITestCase(DockerClientTestCase): app_container = self.project.get_service('app').containers()[0] db_container = self.project.get_service('db').containers()[0] + for net_name in [front_name, back_name]: + links = app_container.get('NetworkSettings.Networks.{}.Links'.format(net_name)) + assert '{}:database'.format(db_container.name) in links + # db and app joined the back network assert sorted(back_network['Containers']) == sorted([db_container.id, app_container.id]) @@ -474,6 +478,9 @@ class CLITestCase(DockerClientTestCase): # app can see db assert self.lookup(app_container, "db") + # app has aliased db to "database" + assert self.lookup(app_container, "database") + @v2_only() def test_up_missing_network(self): self.base_dir = 'tests/fixtures/networks' @@ -566,18 +573,6 @@ class CLITestCase(DockerClientTestCase): for name in ['bar', 'foo'] ] - @v2_only() - def test_up_with_links_is_invalid(self): - self.base_dir = 'tests/fixtures/v2-simple' - - result = self.dispatch( - ['-f', 'links-invalid.yml', 'up', '-d'], - returncode=1) - - # TODO: fix validation error messages for v2 files - # assert "Unsupported config option for service 'simple': 'links'" in result.stderr - assert "Unsupported config option" in result.stderr - def test_up_with_links_v1(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'web'], None) diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml index f1b79df0..5351c0f0 100644 --- a/tests/fixtures/networks/docker-compose.yml +++ b/tests/fixtures/networks/docker-compose.yml @@ -9,6 +9,8 @@ services: image: busybox command: top networks: ["front", "back"] + links: + - "db:database" db: image: busybox command: top diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c9244a47..4d9aec65 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -536,14 +536,6 @@ class ServiceTest(unittest.TestCase): ports=["127.0.0.1:1000-2000:2000-3000"]) self.assertEqual(service.specifies_host_port(), True) - def test_get_links_with_networking(self): - service = Service( - 'foo', - image='foo', - links=[(Service('one'), 'one')], - use_networking=True) - self.assertEqual(service._get_links(link_to_self=True), []) - def test_image_name_from_config(self): image_name = 'example/web:latest' service = Service('foo', image=image_name) From 755c49b5000d7be2990a11a54a16ca05b2dbf5f5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 15:19:55 +0000 Subject: [PATCH 025/580] Fix scale when containers exit immediately Signed-off-by: Aanand Prasad --- compose/service.py | 8 +++----- tests/integration/service_test.py | 5 +++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1dfda06a..bc155cb3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -228,11 +228,9 @@ class Service(object): sorted_running_containers = sorted( running_containers, key=attrgetter('number')) - parallel_stop( - sorted_running_containers[-num_to_stop:], - dict(timeout=timeout)) - - parallel_remove(self.containers(stopped=True), {}) + containers_to_stop = sorted_running_containers[-num_to_stop:] + parallel_stop(containers_to_stop, dict(timeout=timeout)) + parallel_remove(containers_to_stop, {}) def create_container(self, one_off=False, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0e91dcf7..379e51ea 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -746,6 +746,11 @@ class ServiceTest(DockerClientTestCase): for container in containers: self.assertEqual(list(container.inspect()['HostConfig']['PortBindings'].keys()), ['8000/tcp']) + def test_scale_with_immediate_exit(self): + service = self.create_service('web', image='busybox', command='true') + service.scale(2) + assert len(service.containers(stopped=True)) == 2 + def test_network_mode_none(self): service = self.create_service('web', net=Net('none')) container = create_and_start_container(service) From 642e71b4c7b16a7175bd95e4989004ccb9f73d08 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 15:28:40 +0000 Subject: [PATCH 026/580] Stop and remove containers in parallel when scaling down Signed-off-by: Aanand Prasad --- compose/service.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index bc155cb3..7859db76 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,9 +27,7 @@ from .const import LABEL_SERVICE from .const import LABEL_VERSION from .container import Container from .parallel import parallel_execute -from .parallel import parallel_remove from .parallel import parallel_start -from .parallel import parallel_stop from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash @@ -180,6 +178,10 @@ class Service(object): service.start_container(container) return container + def stop_and_remove(container): + container.stop(timeout=timeout) + container.remove() + running_containers = self.containers(stopped=False) num_running = len(running_containers) @@ -225,12 +227,17 @@ class Service(object): if desired_num < num_running: num_to_stop = num_running - desired_num + sorted_running_containers = sorted( running_containers, key=attrgetter('number')) - containers_to_stop = sorted_running_containers[-num_to_stop:] - parallel_stop(containers_to_stop, dict(timeout=timeout)) - parallel_remove(containers_to_stop, {}) + + parallel_execute( + sorted_running_containers[-num_to_stop:], + stop_and_remove, + lambda c: c.name, + "Stopping and removing", + ) def create_container(self, one_off=False, From a14906fd35f5c12b33631456bb7bc3dfd0a0be36 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 16:49:50 +0000 Subject: [PATCH 027/580] Fix 'run' behaviour with networks - Test that one-off containers join all networks - Don't set any aliases Signed-off-by: Aanand Prasad --- compose/service.py | 17 ++++++++++--- tests/acceptance/cli_test.py | 49 ++++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index 8afc59c1..ebe7978c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -430,10 +430,12 @@ class Service(object): return container def connect_container_to_networks(self, container): + one_off = (container.labels.get(LABEL_ONE_OFF) == "True") + for network in self.networks: self.client.connect_container_to_network( container.id, network, - aliases=[self.name], + aliases=self._get_aliases(one_off=one_off), links=self._get_links(False), ) @@ -505,6 +507,12 @@ class Service(object): numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 + def _get_aliases(self, one_off): + if one_off: + return [] + + return [self.name] + def _get_links(self, link_to_self): links = {} @@ -610,7 +618,8 @@ class Service(object): override_options, one_off=one_off) - container_options['networking_config'] = self._get_container_networking_config() + container_options['networking_config'] = self._get_container_networking_config( + one_off=one_off) return container_options @@ -646,10 +655,10 @@ class Service(object): cpu_quota=options.get('cpu_quota'), ) - def _get_container_networking_config(self): + def _get_container_networking_config(self, one_off=False): return self.client.create_networking_config({ network_name: self.client.create_endpoint_config( - aliases=[self.name], + aliases=self._get_aliases(one_off=one_off), links=self._get_links(False), ) for network_name in self.networks diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 1806e707..6ae04ee5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -903,14 +903,47 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(container.name, name) @v2_only() - def test_run_with_networking(self): - self.base_dir = 'tests/fixtures/v2-simple' - self.dispatch(['run', 'simple', 'true'], None) - service = self.project.get_service('simple') - container, = service.containers(stopped=True, one_off=True) - networks = self.client.networks(names=[self.project.default_network.full_name]) - self.assertEqual(len(networks), 1) - self.assertEqual(container.human_readable_command, u'true') + def test_run_interactive_connects_to_network(self): + self.base_dir = 'tests/fixtures/networks' + + self.dispatch(['up', '-d']) + self.dispatch(['run', 'app', 'nslookup', 'app']) + self.dispatch(['run', 'app', 'nslookup', 'db']) + + containers = self.project.get_service('app').containers( + stopped=True, one_off=True) + assert len(containers) == 2 + + for container in containers: + networks = container.get('NetworkSettings.Networks') + + assert sorted(list(networks)) == [ + '{}_{}'.format(self.project.name, name) + for name in ['back', 'front'] + ] + + for _, config in networks.items(): + assert not config['Aliases'] + + @v2_only() + def test_run_detached_connects_to_network(self): + self.base_dir = 'tests/fixtures/networks' + self.dispatch(['up', '-d']) + self.dispatch(['run', '-d', 'app', 'top']) + + container = self.project.get_service('app').containers(one_off=True)[0] + networks = container.get('NetworkSettings.Networks') + + assert sorted(list(networks)) == [ + '{}_{}'.format(self.project.name, name) + for name in ['back', 'front'] + ] + + for _, config in networks.items(): + assert not config['Aliases'] + + assert self.lookup(container, 'app') + assert self.lookup(container, 'db') def test_run_handles_sigint(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) From 09dbc7b4cbbd27a36838bd60f62d6ff740da97f6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 18:04:50 +0000 Subject: [PATCH 028/580] Stop connecting to all networks on container creation This relies on an Engine behaviour which is a bug, not an intentional feature - we have to connect to networks one at a time Signed-off-by: Aanand Prasad --- compose/service.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index ebe7978c..18322846 100644 --- a/compose/service.py +++ b/compose/service.py @@ -656,13 +656,17 @@ class Service(object): ) def _get_container_networking_config(self, one_off=False): + if self.net.mode in ['host', 'bridge']: + return None + + if self.net.mode not in self.networks: + return None + return self.client.create_networking_config({ - network_name: self.client.create_endpoint_config( + self.net.mode: self.client.create_endpoint_config( aliases=self._get_aliases(one_off=one_off), links=self._get_links(False), ) - for network_name in self.networks - if network_name not in ['host', 'bridge'] }) def build(self, no_cache=False, pull=False, force_rm=False): From f3e55568d1a7c111398876bec84da8ed8b42da7f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 18:34:18 +0000 Subject: [PATCH 029/580] Fix interactive run with networking Make sure we connect the container to all required networks *after* starting the container and *before* hijacking the terminal. Signed-off-by: Aanand Prasad --- compose/cli/main.py | 8 +++++--- requirements.txt | 2 +- tests/unit/cli_test.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 4be8536f..7409107d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -41,7 +41,7 @@ from .utils import yesno if not IS_WINDOWS_PLATFORM: - import dockerpty + from dockerpty.pty import PseudoTerminal log = logging.getLogger(__name__) console_handler = logging.StreamHandler(sys.stderr) @@ -709,8 +709,10 @@ def run_one_off_container(container_options, project, service, options): signals.set_signal_handler_to_shutdown() try: try: - dockerpty.start(project.client, container.id, interactive=not options['-T']) - service.connect_container_to_networks(container) + pty = PseudoTerminal(project.client, container.id, interactive=not options['-T']) + sockets = pty.sockets() + service.start_container(container) + pty.start(sockets) exit_code = container.wait() except signals.ShutdownException: project.client.stop(container.id) diff --git a/requirements.txt b/requirements.txt index 563baa10..45e18528 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ PyYAML==3.11 cached-property==1.2.0 -dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 +git+https://github.com/d11wtq/dockerpty.git@29b1394108b017ef3e3deaf00604a9eb99880d5e#egg=dockerpty git+https://github.com/docker/docker-py.git@master#egg=docker-py jsonschema==2.5.1 requests==2.7.0 diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index a5767097..f9e3fb8f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -72,8 +72,8 @@ class CLITestCase(unittest.TestCase): TopLevelCommand().dispatch(['help', 'nonexistent'], None) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") - @mock.patch('compose.cli.main.dockerpty', autospec=True) - def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): + @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) + def test_run_with_environment_merged_with_options_list(self, mock_pseudo_terminal): command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) mock_project = mock.Mock(client=mock_client) From 0554c6e6fd04b670a7ffbc00b5d9f259e80f3482 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 19 Jan 2016 13:23:25 +0000 Subject: [PATCH 030/580] Update Compose file documentation for version 2 - Explain each version in its own section - Explain how to upgrade from version 1 to 2 - Note which keys are restricted to particular versions - A few corrections to the docs for version-specific keys Signed-off-by: Aanand Prasad --- docs/compose-file.md | 518 ++++++++++++++++++++++++++++++++++--------- docs/networking.md | 20 +- 2 files changed, 422 insertions(+), 116 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index ecd135f1..ba6b4cb4 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -12,79 +12,33 @@ parent="smn_compose_ref" # Compose file reference -The compose file is a [YAML](http://yaml.org/) file where all the top level -keys are the name of a service, and the values are the service definition. -The default path for a compose file is `./docker-compose.yml`. +The Compose file is a [YAML](http://yaml.org/) file defining +[services](#service-configuration-reference), +[networks](#network-configuration-reference) and +[volumes](#volume-configuration-reference). +The default path for a Compose file is `./docker-compose.yml`. -Each service defined in `docker-compose.yml` must specify exactly one of -`image` or `build`. Other keys are optional, and are analogous to their -`docker run` command-line counterparts. +A service definition contains configuration which will be applied to each +container started for that service, much like passing command-line parameters to +`docker run`. Likewise, network and volume definitions are analogous to +`docker network create` and `docker volume create`. As with `docker run`, options specified in the Dockerfile (e.g., `CMD`, `EXPOSE`, `VOLUME`, `ENV`) are respected by default - you don't need to specify them again in `docker-compose.yml`. -## Versioning - -It is possible to use different versions of the `compose.yml` format. -Below are the formats currently supported by compose. - - -### Version 1 - -Compose files that do not declare a version are considered "version 1". In -those files, all the [services](#service-configuration-reference) are declared -at the root of the document. - -Version 1 files do not support the declaration of -named [volumes](#volume-configuration-reference) or -[build arguments](#args). - -Example: - - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - - logvolume01:/var/log - links: - - redis - redis: - image: redis - - -### Version 2 - -Compose files using the version 2 syntax must indicate the version number at -the root of the document. All [services](#service-configuration-reference) -must be declared under the `services` key. -Named [volumes](#volume-configuration-reference) must be declared under the -`volumes` key. - -Example: - - version: 2 - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - - logvolume01:/var/log - links: - - redis - redis: - image: redis - volumes: - logvolume01: - driver: default +You can use environment variables in configuration values with a Bash-like +`${VARIABLE}` syntax - see [variable substitution](#variable-substitution) for +full details. ## Service configuration reference +> **Note:** There are two versions of the Compose file format – version 1 (the +> legacy format, which does not support volumes or networks) and version 2 (the +> most up-to-date). For more information, see the [Versioning](#versioning) +> section. + This section contains a list of all configuration options supported by a service definition. @@ -92,28 +46,30 @@ definition. Configuration options that are applied at build time. -In version 1 this must be given as a string representing the context. +`build` can be specified either as a string containing a path to the build +context, or an object with the path specified under [context](#context) and +optionally [dockerfile](#dockerfile) and [args](#args). - build: . + build: ./dir -In version 2 this can alternatively be given as an object with extra options. + build: + context: ./dir + dockerfile: Dockerfile-alternate + args: + buildno: 1 - version: 2 - services: - web: - build: . - - version: 2 - services: - web: - build: - context: . - dockerfile: Dockerfile-alternate - args: - buildno: 1 +> **Note**: In the [version 1 file format](#version-1), `build` is different in +> two ways: +> +> - Only the string form (`build: .`) is allowed - not the object form. +> - Using `build` together with `image` is not allowed. Attempting to do so +> results in an error. #### context +> [Version 2 file format](#version-2) only. In version 1, just use +> [build](#build). + Either a path to a directory containing a Dockerfile, or a url to a git repository. When the value supplied is a relative path, it is interpreted as relative to the @@ -122,29 +78,34 @@ sent to the Docker daemon. Compose will build and tag it with a generated name, and use that image thereafter. - build: /path/to/build/dir - build: - context: /path/to/build/dir - -Using `context` together with `image` is not allowed. Attempting to do so results in -an error. + context: ./dir #### dockerfile Alternate Dockerfile. Compose will use an alternate file to build with. A build path must also be -specified using the `build` key. +specified. build: - context: /path/to/build/dir + context: . dockerfile: Dockerfile-alternate -Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. +> **Note**: In the [version 1 file format](#version-1), `dockerfile` is +> different in two ways: +> +> - It appears alongside `build`, not as a sub-option: +> +> build: . +> dockerfile: Dockerfile-alternate +> - Using `dockerfile` together with `image` is not allowed. Attempting to do +> so results in an error. #### args +> [Version 2 file format](#version-2) only. + Add build arguments. You can use either an array or a dictionary. Any boolean values; true, false, yes, no, need to be enclosed in quotes to ensure they are not converted to True or False by the YML parser. @@ -152,8 +113,6 @@ they are not converted to True or False by the YML parser. Build arguments with only a key are resolved to their environment value on the machine Compose is running on. -> **Note:** Introduced in version 2 of the compose file format. - build: args: buildno: 1 @@ -376,6 +335,9 @@ It's recommended that you use reverse-DNS notation to prevent your labels from c ### links +> [Version 1 file format](#version-1) only. In version 2 files, use +> [networking](networking.md) for communication between containers. + Link to containers in another service. Either specify both the service name and the link alias (`SERVICE:ALIAS`), or just the service name (which will also be used for the alias). @@ -397,13 +359,15 @@ reference](env.md) for details. ### logging -Logging configuration for the service. This configuration replaces the previous -`log_driver` and `log_opt` keys. +> [Version 2 file format](#version-2) only. In version 1, use +> [log_driver](#log_driver) and [log_opt](#log_opt). + +Logging configuration for the service. logging: - driver: log_driver - options: - syslog-address: "tcp://192.168.0.42:123" + driver: syslog + options: + syslog-address: "tcp://192.168.0.42:123" The `driver` name specifies a logging driver for the service's containers, as with the ``--log-driver`` option for docker run @@ -421,15 +385,36 @@ The default value is json-file. Specify logging options for the logging driver with the ``options`` key, as with the ``--log-opt`` option for `docker run`. - -Logging options are key value pairs. An example of `syslog` options: +Logging options are key-value pairs. An example of `syslog` options: driver: "syslog" options: syslog-address: "tcp://192.168.0.42:123" +### log_driver + +> [Version 1 file format](#version-1) only. In version 2, use +> [logging](#logging). + +Specify a log driver. The default is `json-file`. + + log_driver: syslog + +### log_opt + +> [Version 1 file format](#version-1) only. In version 2, use +> [logging](#logging). + +Specify logging options as key-value pairs. An example of `syslog` options: + + log_opt: + syslog-address: "tcp://192.168.0.42:123" + ### net +> [Version 1 file format](#version-1) only. In version 2, use +> [networks](#networks). + Networking mode. Use the same values as the docker client `--net` parameter. net: "bridge" @@ -437,6 +422,22 @@ Networking mode. Use the same values as the docker client `--net` parameter. net: "container:[name or id]" net: "host" +### networks + +> [Version 2 file format](#version-2) only. In version 1, use [net](#net). + +Networks to join, referencing entries under the +[top-level `networks` key](#network-configuration-reference). + + networks: + - some-network + - other-network + +The values `bridge`, `host` and `none` can also be used, and are equivalent to +`net: "bridge"`, `net: "host"` or `net: "none"` in version 1. + +There is no equivalent to `net: "container:[name or id]"`. + ### pid pid: "host" @@ -487,24 +488,37 @@ limit as an integer or soft/hard limits as a mapping. ### volumes, volume\_driver -Mount paths as volumes, optionally specifying a path on the host machine -(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). - - volumes: - - /var/lib/mysql - - ./cache:/tmp/cache - - ~/configs:/etc/configs/:ro +Mount paths or named volumes, optionally specifying a path on the host machine +(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). Named volumes can +be specified with the +[top-level `volumes` key](#volume-configuration-reference), but this is +optional - the Docker Engine will create the volume if it doesn't exist. You can mount a relative path on the host, which will expand relative to the directory of the Compose configuration file being used. Relative paths should always begin with `.` or `..`. + volumes: + # Just specify a path and let the Engine create a volume + - /var/lib/mysql + + # Specify an absolute path mapping + - /opt/data:/var/lib/mysql + + # Path on the host, relative to the Compose file + - ./cache:/tmp/cache + + # User-relative path + - ~/configs:/etc/configs/:ro + + # Named volume + - datavolume:/var/lib/mysql + If you use a volume name (instead of a volume path), you may also specify a `volume_driver`. volume_driver: mydriver - > Note: No path expansion will be done if you have also specified a > `volume_driver`. @@ -519,8 +533,18 @@ specifying read-only access(``ro``) or read-write(``rw``). volumes_from: - service_name - - container_name - - service_name:rw + - service_name:ro + - container:container_name + - container:container_name:rw + +> **Note:** The `container:...` formats are only supported in the +> [version 2 file format](#version-2). In [version 1](#version-1), you can use +> container names without marking them as such: +> +> - service_name +> - service_name:ro +> - container_name +> - container_name:rw ### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, working\_dir @@ -562,20 +586,296 @@ subcommand documentation for more information. ### driver Specify which volume driver should be used for this volume. Defaults to -`local`. An exception will be raised if the driver is not available. +`local`. The Docker Engine will return an error if the driver is not available. driver: foobar ### driver_opts Specify a list of options as key-value pairs to pass to the driver for this -volume. Those options are driver dependent - consult the driver's +volume. Those options are driver-dependent - consult the driver's documentation for more information. Optional. driver_opts: foo: "bar" baz: 1 +## external + +If set to `true`, specifies that this volume has been created outside of +Compose. + +In the example below, instead of attemping to create a volume called +`[projectname]_data`, Compose will look for an existing volume simply +called `data` and mount it into the `db` service's containers. + + version: 2 + + services: + db: + image: postgres + volumes: + - data:/var/lib/postgres/data + + volumes: + data: + external: true + +You can also specify the name of the volume separately from the name used to +refer to it within the Compose file: + + volumes + data: + external: + name: actual-name-of-volume + + +## Network configuration reference + +The top-level `networks` key lets you specify networks to be created. For a full +explanation of Compose's use of Docker networking features, see the +[Networking guide](networking.md). + +### driver + +Specify which driver should be used for this network. + +The default driver depends on how the Docker Engine you're using is configured, +but in most instances it will be `bridge` on a single host and `overlay` on a +Swarm. + +The Docker Engine will return an error if the driver is not available. + + driver: overlay + +### driver_opts + +Specify a list of options as key-value pairs to pass to the driver for this +network. Those options are driver-dependent - consult the driver's +documentation for more information. Optional. + + driver_opts: + foo: "bar" + baz: 1 + +### ipam + +Specify custom IPAM config. This is an object with several properties, each of +which is optional: + +- `driver`: Custom IPAM driver, instead of the default. +- `config`: A list with zero or more config blocks, each containing any of + the following keys: + - `subnet`: Subnet in CIDR format that represents a network segment + - `ip_range`: Range of IPs from which to allocate container IPs + - `gateway`: IPv4 or IPv6 gateway for the master subnet + - `aux_addresses`: Auxiliary IPv4 or IPv6 addresses used by Network driver, + as a mapping from hostname to IP + +A full example: + + ipam: + driver: default + config: + - subnet: 172.28.0.0/16 + ip_range: 172.28.5.0/24 + gateway: 172.28.5.254 + aux_addresses: + host1: 172.28.1.5 + host2: 172.28.1.6 + host3: 172.28.1.7 + +### external + +If set to `true`, specifies that this network has been created outside of +Compose. + +In the example below, `proxy` is the gateway to the outside world. Instead of +attemping to create a network called `[projectname]_outside`, Compose will +look for an existing network simply called `outside` and connect the `proxy` +service's containers to it. + + version: 2 + + services: + proxy: + build: ./proxy + networks: + - outside + - default + app: + build: ./app + networks: + - default + + networks + outside: + external: true + +You can also specify the name of the network separately from the name used to +refer to it within the Compose file: + + networks + outside: + external: + name: actual-name-of-network + + +## Versioning + +There are two versions of the Compose file format: + +- Version 1, the legacy format. This is specified by omitting a `version` key at + the root of the YAML. +- Version 2, the recommended format. This is specified with a `version: 2` entry + at the root of the YAML. + +To move your project from version 1 to 2, see the [Upgrading](#upgrading) +section. + +> **Note:** If you're using +> [multiple Compose files](extends.md#different-environments) or +> [extending services](extends.md#extending-services), each file must be of the +> same version - you cannot mix version 1 and 2 in a single project. + +Several things differ depending on which version you use: + +- The structure and permitted configuration keys +- The minimum Docker Engine version you must be running +- Compose's behaviour with regards to networking + +These differences are explained below. + + +### Version 1 + +Compose files that do not declare a version are considered "version 1". In +those files, all the [services](#service-configuration-reference) are declared +at the root of the document. + +Version 1 is supported by **Compose up to 1.6.x**. It will be deprecated in a +future Compose release. + +Version 1 files cannot declare named +[volumes](#volume-configuration-reference), [networks](networking.md) or +[build arguments](#args). They *can*, however, define [links](#links). + +Example: + + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis + + +### Version 2 + +Compose files using the version 2 syntax must indicate the version number at +the root of the document. All [services](#service-configuration-reference) +must be declared under the `services` key. + +Version 2 files are supported by **Compose 1.6.0+** and require a Docker Engine +of version **1.10.0+**. + +Named [volumes](#volume-configuration-reference) can be declared under the +`volumes` key, and [networks](#network-configuration-reference) can be declared +under the `networks` key. + +You cannot define links when using version 2. Instead, you should use +[networking](networking.md) for communication between containers. In most cases, +this will involve less configuration than links. + +Simple example: + + version: 2 + services: + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + redis: + image: redis + +A more extended example, defining volumes and networks: + + version: 2 + services: + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + networks: + - front + - back + redis: + image: redis + volumes: + - data:/var/lib/redis + networks: + - back + volumes: + data: + driver: local + networks: + front: + driver: bridge + back: + driver: bridge + + +### Upgrading + +In the majority of cases, moving from version 1 to 2 is a very simple process: + +1. Indent the whole file by one level and put a `services:` key at the top. +2. Add a `version: 2` line at the top of the file. +3. Delete all `links` entries. + +It's more complicated if you're using particular configuration features: + +- `dockerfile`: This now lives under the `build` key: + + build: + context: . + dockerfile: Dockerfile-alternate + +- `log_driver`, `log_opt`: These now live under the `logging` key: + + logging: + driver: syslog + options: + syslog-address: "tcp://192.168.0.42:123" + +- `links` with aliases: If you've defined a link with an alias such as + `myservice:db`, there's currently no equivalent to this in version 2. You + will have to refer to the service using its name (in this example, + `myservice`). + +- `external_links`: Links are deprecated, so you should use + [external networks](networking.md#using-externally-created-networks) to + communicate with containers outside the app. + +- `net`: If you're using `host`, `bridge` or `none`, this is now replaced by + `networks`: + + net: host -> networks: ["host"] + net: bridge -> networks: ["bridge"] + net: none -> networks: ["none"] + + If you're using `net: "container:"`, there is no equivalent to this in + version 2 - you should use [Docker networks](networking.md) for + communication instead. + ## Variable substitution diff --git a/docs/networking.md b/docs/networking.md index f111f730..a5b49c1f 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -12,7 +12,7 @@ weight=6 # Networking in Compose -> **Note:** This document only applies if you're using v2 of the [Compose file format](compose-file.md). Networking features are not supported for legacy Compose files. +> **Note:** This document only applies if you're using [version 2 of the Compose file format](compose-file.md#versioning). Networking features are not supported for version 1 (legacy) Compose files. By default Compose sets up a single [network](/engine/reference/commandline/network_create.md) for your app. Each @@ -69,7 +69,7 @@ Instead of just using the default app network, you can specify your own networks Each service can specify what networks to connect to with the *service-level* `networks` key, which is a list of names referencing entries under the *top-level* `networks` key. -Here's an example Compose file defining several networks. The `proxy` service is the gateway to the outside world, via a network called `outside` which is expected to already exist. `proxy` is isolated from the `db` service, because they do not share a network in common - only `app` can talk to both. +Here's an example Compose file defining two custom networks. The `proxy` service is isolated from the `db` service, because they do not share a network in common - only `app` can talk to both. version: 2 @@ -77,7 +77,6 @@ Here's an example Compose file defining several networks. The `proxy` service is proxy: build: ./proxy networks: - - outside - front app: build: ./app @@ -99,10 +98,6 @@ Here's an example Compose file defining several networks. The `proxy` service is options: foo: "1" bar: "2" - outside: - # The 'outside' network is expected to already exist - Compose will not - # attempt to create it - external: true ## Configuring the default network @@ -123,6 +118,17 @@ Instead of (or as well as) specifying your own networks, you can also change the # Use the overlay driver for multi-host communication driver: overlay +## Using a pre-existing network + +If you want your containers to join a pre-existing network, use the [`external` option](compose-file.md#network-configuration-reference): + + networks: + default: + external: + name: my-pre-existing-network + +Instead of attemping to create a network called `[projectname]_default`, Compose will look for a network called `my-pre-existing-network` and connect your app's containers to it. + ## Custom container network modes The `docker` CLI command allows you to specify a custom network mode for a container with the `--net` option - for example, `--net=host` specifies that the container should use the same network namespace as the Docker host, and `--net=none` specifies that it should have no networking capabilities. From 9a3378930f81c71c0c3b4d3cdc112043b558cba5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 13:55:40 +0000 Subject: [PATCH 031/580] Document depends_on Signed-off-by: Aanand Prasad --- docs/compose-file.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index ba6b4cb4..d1c4f36f 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -169,6 +169,29 @@ client create option. devices: - "/dev/ttyUSB0:/dev/ttyUSB0" +### depends_on + +Express dependency between services, which has two effects: + +- `docker-compose up` will start services in dependency order. In the following + example, `db` and `redis` will be started before `web`. + +- `docker-compose up SERVICE` will automatically include `SERVICE`'s + dependencies. In the following example, `docker-compose up web` will also + create and start `db` and `redis`. + + version: 2 + services: + web: + build: . + depends_on: + - db + - redis + redis: + image: redis + db: + image: postgres + ### dns Custom DNS servers. Can be a single value or a list. From 2f41f3aa7ec1d5cce60b5b0d0a474110bf23e74d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 13:55:54 +0000 Subject: [PATCH 032/580] Update documentation for links Signed-off-by: Aanand Prasad --- docs/compose-file.md | 62 +++++++++++++++++++++++++++----------------- docs/env.md | 4 ++- docs/networking.md | 13 +++++++++- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index d1c4f36f..5969b855 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -318,6 +318,10 @@ container name and the link alias (`CONTAINER:ALIAS`). - project_db_1:mysql - project_db_1:postgresql +> **Note:** If you're using the [version 2 file format](#version-2), the +> externally-created containers must be connected to at least one of the same +> networks as the service which is linking to them. + ### extra_hosts Add hostname mappings. Use the same values as the docker client `--add-host` parameter. @@ -358,27 +362,24 @@ It's recommended that you use reverse-DNS notation to prevent your labels from c ### links -> [Version 1 file format](#version-1) only. In version 2 files, use -> [networking](networking.md) for communication between containers. - Link to containers in another service. Either specify both the service name and -the link alias (`SERVICE:ALIAS`), or just the service name (which will also be -used for the alias). +a link alias (`SERVICE:ALIAS`), or just the service name. - links: - - db - - db:database - - redis + web: + links: + - db + - db:database + - redis -An entry with the alias' name will be created in `/etc/hosts` inside containers -for this service, e.g: +Containers for the linked service will be reachable at a hostname identical to +the alias, or the service name if no alias was specified. - 172.17.2.186 db - 172.17.2.186 database - 172.17.2.187 redis +Links also express dependency between services in the same way as +[depends_on](#depends-on), so they determine the order of service startup. -Environment variables will also be created - see the [environment variable -reference](env.md) for details. +> **Note:** If you define both links and [networks](#networks), services with +> links between them must share at least one network in common in order to +> communicate. ### logging @@ -862,7 +863,6 @@ In the majority of cases, moving from version 1 to 2 is a very simple process: 1. Indent the whole file by one level and put a `services:` key at the top. 2. Add a `version: 2` line at the top of the file. -3. Delete all `links` entries. It's more complicated if you're using particular configuration features: @@ -879,14 +879,28 @@ It's more complicated if you're using particular configuration features: options: syslog-address: "tcp://192.168.0.42:123" -- `links` with aliases: If you've defined a link with an alias such as - `myservice:db`, there's currently no equivalent to this in version 2. You - will have to refer to the service using its name (in this example, - `myservice`). +- `links` with environment variables: As documented in the + [environment variables reference](env.md), environment variables created by + links have been deprecated for some time. In the new Docker network system, + they have been removed. You should either connect directly to the + appropriate hostname or set the relevant environment variable yourself, + using the link hostname: -- `external_links`: Links are deprecated, so you should use - [external networks](networking.md#using-externally-created-networks) to - communicate with containers outside the app. + web: + links: + - db + environment: + - DB_PORT=tcp://db:5432 + +- `external_links`: Compose uses Docker networks when running version 2 + projects, so links behave slightly differently. In particular, two + containers must be connected to at least one network in common in order to + communicate, even if explicitly linked together. + + Either connect the external container to your app's + [default network](networking.md), or connect both the external container and + your service's containers to an + [external network](networking.md#using-a-pre-existing-network). - `net`: If you're using `host`, `bridge` or `none`, this is now replaced by `networks`: diff --git a/docs/env.md b/docs/env.md index c0e03a4e..7b7e1bc7 100644 --- a/docs/env.md +++ b/docs/env.md @@ -11,7 +11,9 @@ weight=3 # Compose environment variables reference -**Note:** Environment variables are no longer the recommended method for connecting to linked services. Instead, you should use the link name (by default, the name of the linked service) as the hostname to connect to. See the [docker-compose.yml documentation](compose-file.md#links) for details. +> **Note:** Environment variables are no longer the recommended method for connecting to linked services. Instead, you should use the link name (by default, the name of the linked service) as the hostname to connect to. See the [docker-compose.yml documentation](compose-file.md#links) for details. +> +> Environment variables will only be populated if you're using the [legacy version 1 Compose file format](compose-file.md#versioning). Compose uses [Docker links] to expose services' containers to one another. Each linked container injects a set of environment variables, each of which begins with the uppercase name of the container. diff --git a/docs/networking.md b/docs/networking.md index a5b49c1f..1bdd7fe0 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -57,7 +57,18 @@ If any containers have connections open to the old container, they will be close ## Links -Docker links are a one-way, single-host communication system. They should now be considered deprecated, and as part of upgrading your app to the v2 format, you must remove any `links` sections from your `docker-compose.yml` and use service names (e.g. `web`, `db`) as the hostnames to connect to. +Links allow you to define extra aliases by which a service is reachable from another service. They are not required to enable services to communicate - by default, any service can reach any other service at that service's name. In the following example, `db` is reachable from `web` at the hostnames `db` and `database`: + + version: 2 + services: + web: + build: . + links: + - "db:database" + db: + image: postgres + +See the [links reference](compose-file.md#links) for more information. ## Multi-host networking From 59493dd7aadc119a2e45bf632a66152931097fd2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 13:58:01 +0000 Subject: [PATCH 033/580] Add links to networks key references Signed-off-by: Aanand Prasad --- docs/networking.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/networking.md b/docs/networking.md index 1bdd7fe0..bcfd39e5 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -110,6 +110,11 @@ Here's an example Compose file defining two custom networks. The `proxy` service foo: "1" bar: "2" +For full details of the network configuration options available, see the following references: + +- [Top-level `networks` key](compose-file.md#network-configuration-reference) +- [Service-level `networks` key](compose-file.md#networks) + ## Configuring the default network Instead of (or as well as) specifying your own networks, you can also change the settings of the app-wide default network by defining an entry under `networks` named `default`: From fec8cc9f8029534e3efdeed5fe5152c8d5814f18 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 20 Jan 2016 11:07:28 -0800 Subject: [PATCH 034/580] Update documentation for `external` param Signed-off-by: Joffrey F Conflicts: docs/compose-file.md --- docs/compose-file.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 5969b855..67adeb82 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -627,7 +627,11 @@ documentation for more information. Optional. ## external If set to `true`, specifies that this volume has been created outside of -Compose. +Compose. `docker-compose up` will not attempt to create it, and will raise +an error if it doesn't exist. + +`external` cannot be used in conjunction with other volume configuration keys +(`driver`, `driver_opts`). In the example below, instead of attemping to create a volume called `[projectname]_data`, Compose will look for an existing volume simply @@ -712,7 +716,11 @@ A full example: ### external If set to `true`, specifies that this network has been created outside of -Compose. +Compose. `docker-compose up` will not attempt to create it, and will raise +an error if it doesn't exist. + +`external` cannot be used in conjunction with other network configuration keys +(`driver`, `driver_opts`, `ipam`). In the example below, `proxy` is the gateway to the outside world. Instead of attemping to create a network called `[projectname]_outside`, Compose will From 6e73fb38ea55ec6280c3ca5478f879770ab9907b Mon Sep 17 00:00:00 2001 From: Alf Lervag Date: Tue, 22 Dec 2015 11:43:48 +0100 Subject: [PATCH 035/580] Fixes #2448 Signed-off-by: Alf Lervag Conflicts: compose/cli/main.py requirements.txt --- compose/cli/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 7409107d..14febe25 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -709,7 +709,12 @@ def run_one_off_container(container_options, project, service, options): signals.set_signal_handler_to_shutdown() try: try: - pty = PseudoTerminal(project.client, container.id, interactive=not options['-T']) + pty = PseudoTerminal( + project.client, + container.id, + interactive=not options['-T'], + logs=False, + ) sockets = pty.sockets() service.start_container(container) pty.start(sockets) From da2b6329ae0f1b3c42055b4a25b9fbe03cc1a560 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 19:15:15 +0000 Subject: [PATCH 036/580] Add test for logs=False Signed-off-by: Aanand Prasad Conflicts: compose/cli/main.py --- tests/unit/cli_test.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index f9e3fb8f..fd52a3c1 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -71,6 +71,37 @@ class CLITestCase(unittest.TestCase): with self.assertRaises(NoSuchCommand): TopLevelCommand().dispatch(['help', 'nonexistent'], None) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") + @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) + def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal): + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock(client=mock_client) + mock_project.get_service.return_value = Service( + 'service', + client=mock_client, + environment=['FOO=ONE', 'BAR=TWO'], + image='someimage') + + with pytest.raises(SystemExit): + command.run(mock_project, { + 'SERVICE': 'service', + 'COMMAND': None, + '-e': ['BAR=NEW', 'OTHER=bär'.encode('utf-8')], + '--user': None, + '--no-deps': None, + '-d': False, + '-T': None, + '--entrypoint': None, + '--service-ports': None, + '--publish': [], + '--rm': None, + '--name': None, + }) + + _, _, call_kwargs = mock_pseudo_terminal.mock_calls[0] + assert call_kwargs['logs'] is False + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) def test_run_with_environment_merged_with_options_list(self, mock_pseudo_terminal): From 77b435f4fe9a8d02a6aab60d5264274ec59756fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jan 2016 18:07:14 -0800 Subject: [PATCH 037/580] Use latest docker-py rc Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45e18528..ed3b8686 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 +docker-py==1.7.0rc2 docopt==0.6.1 enum34==1.0.4 git+https://github.com/d11wtq/dockerpty.git@29b1394108b017ef3e3deaf00604a9eb99880d5e#egg=dockerpty -git+https://github.com/docker/docker-py.git@master#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 From 9e67eae311324fb4f9d307e6b4158caff0b32d3e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jan 2016 18:03:58 -0800 Subject: [PATCH 038/580] Match named volumes in service definitions with declared volumes Raise ConfigurationError for undeclared named volumes Test new behavior Signed-off-by: Joffrey F --- compose/config/types.py | 6 +++++ compose/project.py | 45 ++++++++++++++++++++++--------- tests/integration/project_test.py | 36 +++++++++++++++++++++++++ tests/unit/project_test.py | 43 +++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index b872cba9..2bb2519d 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -163,3 +163,9 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): def repr(self): external = self.external + ':' if self.external else '' return '{ext}{v.internal}:{v.mode}'.format(ext=external, v=self) + + @property + def is_named_volume(self): + return self.external and not ( + self.external.startswith('.') or self.external.startswith('/') + ) diff --git a/compose/project.py b/compose/project.py index 080c4c49..7415e824 100644 --- a/compose/project.py +++ b/compose/project.py @@ -74,6 +74,17 @@ class Project(object): if 'default' not in network_config: all_networks.append(project.default_network) + if config_data.volumes: + for vol_name, data in config_data.volumes.items(): + project.volumes.append( + Volume( + client=client, project=name, name=vol_name, + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external_name=data.get('external_name') + ) + ) + for service_dict in config_data.services: if use_networking: networks = get_networks(service_dict, all_networks) @@ -86,6 +97,9 @@ class Project(object): volumes_from = get_volumes_from(project, service_dict) + if config_data.version == 2: + match_named_volumes(service_dict, project.volumes) + project.services.append( Service( client=client, @@ -95,23 +109,13 @@ class Project(object): links=links, net=net, volumes_from=volumes_from, - **service_dict)) + **service_dict) + ) project.networks += custom_networks if 'default' not in network_config and project.uses_default_network(): project.networks.append(project.default_network) - if config_data.volumes: - for vol_name, data in config_data.volumes.items(): - project.volumes.append( - Volume( - client=client, project=name, name=vol_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - external_name=data.get('external_name') - ) - ) - return project @property @@ -473,6 +477,23 @@ def get_networks(service_dict, network_definitions): return networks +def match_named_volumes(service_dict, project_volumes): + for volume_spec in service_dict.get('volumes', []): + if volume_spec.is_named_volume: + declared_volume = next( + (v for v in project_volumes if v.name == volume_spec.external), + None + ) + if not declared_volume: + raise ConfigurationError( + 'Named volume "{0}" is used in service "{1}" but no' + ' declaration was found in the volumes section.'.format( + volume_spec.repr(), service_dict.get('name') + ) + ) + volume_spec._replace(external=declared_volume.full_name) + + def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) if not volumes_from: diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d3fbb71e..aa0dd33f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -813,3 +813,39 @@ class ProjectTest(DockerClientTestCase): assert 'Volume {0} declared as external'.format( vol_name ) in str(e.exception) + + @v2_only() + def test_project_up_named_volumes_in_binds(self): + vol_name = '{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) + + base_file = config.ConfigFile( + 'base.yml', + { + 'version': 2, + 'services': { + 'simple': { + 'image': 'busybox:latest', + 'command': 'top', + 'volumes': ['{0}:/data'.format(vol_name)] + }, + }, + 'volumes': { + vol_name: {'driver': 'local'} + } + + }) + config_details = config.ConfigDetails('.', [base_file]) + config_data = config.load(config_details) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + service = project.services[0] + self.assertEqual(service.name, 'simple') + volumes = service.options.get('volumes') + self.assertEqual(len(volumes), 1) + self.assertEqual(volumes[0].external, full_vol_name) + project.up() + engine_volumes = self.client.volumes() + self.assertIsNone(next(v for v in engine_volumes if v['Name'] == vol_name)) + self.assertIsNotNone(next(v for v in engine_volumes if v['Name'] == full_vol_name)) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index ffd4455f..f587d697 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,8 +7,10 @@ import docker from .. import mock from .. import unittest +from compose.config import ConfigurationError from compose.config.config import Config from compose.config.types import VolumeFromSpec +from compose.config.types import VolumeSpec from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project @@ -476,3 +478,44 @@ class ProjectTest(unittest.TestCase): ), ) self.assertEqual([c.id for c in project.containers()], ['1']) + + def test_undeclared_volume_v2(self): + config = Config( + version=2, + services=[ + { + 'name': 'web', + 'image': 'busybox:latest', + 'volumes': [VolumeSpec.parse('data0028:/data:ro')], + }, + ], + networks=None, + volumes=None, + ) + with self.assertRaises(ConfigurationError): + Project.from_config('composetest', config, None) + + config = Config( + version=2, + services=[ + { + 'name': 'web', + 'image': 'busybox:latest', + 'volumes': [VolumeSpec.parse('./data0028:/data:ro')], + }, + ], networks=None, volumes=None, + ) + Project.from_config('composetest', config, None) + + def test_undeclared_volume_v1(self): + config = Config( + version=1, + services=[ + { + 'name': 'web', + 'image': 'busybox:latest', + 'volumes': [VolumeSpec.parse('data0028:/data:ro')], + }, + ], networks=None, volumes=None, + ) + Project.from_config('composetest', config, None) From 3a72edb906e165410d566d37408e9ff569b382a7 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Fri, 22 Jan 2016 01:18:15 +0200 Subject: [PATCH 039/580] Network fields schema validation Signed-off-by: Dimitar Bonev --- compose/config/fields_schema_v2.json | 29 +++++++++++++++++++- docs/networking.md | 2 +- tests/unit/config/config_test.py | 41 ++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index b0f304e8..c001df68 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -41,7 +41,34 @@ "definitions": { "network": { "id": "#/definitions/network", - "type": "object" + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array" + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false }, "volume": { "id": "#/definitions/volume", diff --git a/docs/networking.md b/docs/networking.md index bcfd39e5..1e662dd2 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -106,7 +106,7 @@ Here's an example Compose file defining two custom networks. The `proxy` service back: # Use a custom driver which takes special options driver: my-custom-driver - options: + driver_opts: foo: "1" bar: "2" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index eb8ed2c7..fe609820 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -88,11 +88,26 @@ class ConfigTest(unittest.TestCase): 'driver': 'default', 'driver_opts': {'beep': 'boop'} } + }, + 'networks': { + 'default': { + 'driver': 'bridge', + 'driver_opts': {'beep': 'boop'} + }, + 'with_ipam': { + 'ipam': { + 'driver': 'default', + 'config': [ + {'subnet': '172.28.0.0/16'} + ] + } + } } }, 'working_dir', 'filename.yml') ) service_dicts = config_data.services volume_dict = config_data.volumes + networks_dict = config_data.networks self.assertEqual( service_sort(service_dicts), service_sort([ @@ -113,6 +128,20 @@ class ConfigTest(unittest.TestCase): 'driver_opts': {'beep': 'boop'} } }) + self.assertEqual(networks_dict, { + 'default': { + 'driver': 'bridge', + 'driver_opts': {'beep': 'boop'} + }, + 'with_ipam': { + 'ipam': { + 'driver': 'default', + 'config': [ + {'subnet': '172.28.0.0/16'} + ] + } + } + }) def test_named_volume_config_empty(self): config_details = build_config_details({ @@ -191,6 +220,18 @@ class ConfigTest(unittest.TestCase): ) ) + def test_load_throws_error_with_invalid_network_fields(self): + with self.assertRaises(ConfigurationError): + config.load( + build_config_details({ + 'version': 2, + 'services': {'web': 'busybox:latest'}, + 'networks': { + 'invalid': {'foo', 'bar'} + } + }, 'working_dir', 'filename.yml') + ) + def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: From 48377a354f7527a60a8be3efb9ca17ec23816acd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Jan 2016 16:05:21 -0800 Subject: [PATCH 040/580] is_named_volume also tests for home paths ~ Fix bug with VolumeSpec not being updated Fix integration test Signed-off-by: Joffrey F --- compose/config/types.py | 4 +--- compose/project.py | 7 +++++-- tests/integration/project_test.py | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 2bb2519d..2e648e5a 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -166,6 +166,4 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): @property def is_named_volume(self): - return self.external and not ( - self.external.startswith('.') or self.external.startswith('/') - ) + return self.external and not self.external.startswith(('.', '/', '~')) diff --git a/compose/project.py b/compose/project.py index 7415e824..bed55925 100644 --- a/compose/project.py +++ b/compose/project.py @@ -478,7 +478,8 @@ def get_networks(service_dict, network_definitions): def match_named_volumes(service_dict, project_volumes): - for volume_spec in service_dict.get('volumes', []): + service_volumes = service_dict.get('volumes', []) + for volume_spec in service_volumes: if volume_spec.is_named_volume: declared_volume = next( (v for v in project_volumes if v.name == volume_spec.external), @@ -491,7 +492,9 @@ def match_named_volumes(service_dict, project_volumes): volume_spec.repr(), service_dict.get('name') ) ) - volume_spec._replace(external=declared_volume.full_name) + service_volumes[service_volumes.index(volume_spec)] = ( + volume_spec._replace(external=declared_volume.full_name) + ) def get_volumes_from(project, service_dict): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index aa0dd33f..586f9444 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -846,6 +846,7 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(volumes), 1) self.assertEqual(volumes[0].external, full_vol_name) project.up() - engine_volumes = self.client.volumes() - self.assertIsNone(next(v for v in engine_volumes if v['Name'] == vol_name)) - self.assertIsNotNone(next(v for v in engine_volumes if v['Name'] == full_vol_name)) + engine_volumes = self.client.volumes()['Volumes'] + container = service.get_container() + assert [mount['Name'] for mount in container.get('Mounts')] == [full_vol_name] + assert next((v for v in engine_volumes if v['Name'] == vol_name), None) is None From 139c7f7830ede9ea66b0b12750ff7555ab4dca91 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Jan 2016 17:42:24 -0800 Subject: [PATCH 041/580] Move named volumes matching to config validation phase Signed-off-by: Joffrey F --- compose/config/config.py | 6 ++++ compose/config/validation.py | 12 ++++++++ compose/project.py | 46 ++++++++++------------------- tests/unit/config/config_test.py | 50 ++++++++++++++++++++++++++++++++ tests/unit/project_test.py | 43 --------------------------- 5 files changed, 83 insertions(+), 74 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 72ad50af..62e9929f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -25,6 +25,7 @@ from .types import parse_extra_hosts from .types import parse_restart_spec from .types import VolumeFromSpec from .types import VolumeSpec +from .validation import match_named_volumes from .validation import validate_against_fields_schema from .validation import validate_against_service_schema from .validation import validate_depends_on @@ -271,6 +272,11 @@ def load(config_details): config_details.working_dir, main_file, [file.get_service_dicts() for file in config_details.config_files]) + + if main_file.version >= 2: + for service_dict in service_dicts: + match_named_volumes(service_dict, volumes) + return Config(main_file.version, service_dicts, volumes, networks) diff --git a/compose/config/validation.py b/compose/config/validation.py index ecf8d4f9..5c2d69ec 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -77,6 +77,18 @@ def format_boolean_in_environment(instance): return True +def match_named_volumes(service_dict, project_volumes): + service_volumes = service_dict.get('volumes', []) + for volume_spec in service_volumes: + if volume_spec.is_named_volume and volume_spec.external not in project_volumes: + raise ConfigurationError( + 'Named volume "{0}" is used in service "{1}" but no' + ' declaration was found in the volumes section.'.format( + volume_spec.repr(), service_dict.get('name') + ) + ) + + def validate_top_level_service_objects(filename, service_dicts): """Perform some high level validation of the service name and value. diff --git a/compose/project.py b/compose/project.py index bed55925..48947eaf 100644 --- a/compose/project.py +++ b/compose/project.py @@ -42,7 +42,7 @@ class Project(object): self.use_networking = use_networking self.network_driver = network_driver self.networks = networks or [] - self.volumes = volumes or [] + self.volumes = volumes or {} def labels(self, one_off=False): return [ @@ -76,13 +76,11 @@ class Project(object): if config_data.volumes: for vol_name, data in config_data.volumes.items(): - project.volumes.append( - Volume( - client=client, project=name, name=vol_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - external_name=data.get('external_name') - ) + project.volumes[vol_name] = Volume( + client=client, project=name, name=vol_name, + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external_name=data.get('external_name') ) for service_dict in config_data.services: @@ -98,7 +96,13 @@ class Project(object): volumes_from = get_volumes_from(project, service_dict) if config_data.version == 2: - match_named_volumes(service_dict, project.volumes) + service_volumes = service_dict.get('volumes', []) + for volume_spec in service_volumes: + if volume_spec.is_named_volume: + declared_volume = project.volumes[volume_spec.external] + service_volumes[service_volumes.index(volume_spec)] = ( + volume_spec._replace(external=declared_volume.full_name) + ) project.services.append( Service( @@ -244,7 +248,7 @@ class Project(object): def initialize_volumes(self): try: - for volume in self.volumes: + for volume in self.volumes.values(): if volume.external: log.debug( 'Volume {0} declared as external. No new ' @@ -299,7 +303,7 @@ class Project(object): network.remove() def remove_volumes(self): - for volume in self.volumes: + for volume in self.volumes.values(): volume.remove() def initialize_networks(self): @@ -477,26 +481,6 @@ def get_networks(service_dict, network_definitions): return networks -def match_named_volumes(service_dict, project_volumes): - service_volumes = service_dict.get('volumes', []) - for volume_spec in service_volumes: - if volume_spec.is_named_volume: - declared_volume = next( - (v for v in project_volumes if v.name == volume_spec.external), - None - ) - if not declared_volume: - raise ConfigurationError( - 'Named volume "{0}" is used in service "{1}" but no' - ' declaration was found in the volumes section.'.format( - volume_spec.repr(), service_dict.get('name') - ) - ) - service_volumes[service_volumes.index(volume_spec)] = ( - volume_spec._replace(external=declared_volume.full_name) - ) - - def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) if not volumes_from: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3c3c6326..10f6aad8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -523,6 +523,56 @@ class ConfigTest(unittest.TestCase): ] assert service_sort(service_dicts) == service_sort(expected) + def test_undeclared_volume_v2(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': ['data0028:/data:ro'], + }, + }, + } + ) + details = config.ConfigDetails('.', [base_file]) + with self.assertRaises(ConfigurationError): + config.load(details) + + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': ['./data0028:/data:ro'], + }, + }, + } + ) + details = config.ConfigDetails('.', [base_file]) + config_data = config.load(details) + volume = config_data.services[0].get('volumes')[0] + assert not volume.is_named_volume + + def test_undeclared_volume_v1(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'web': { + 'image': 'busybox:latest', + 'volumes': ['data0028:/data:ro'], + }, + } + ) + details = config.ConfigDetails('.', [base_file]) + config_data = config.load(details) + volume = config_data.services[0].get('volumes')[0] + assert volume.external == 'data0028' + assert volume.is_named_volume + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f587d697..ffd4455f 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,10 +7,8 @@ import docker from .. import mock from .. import unittest -from compose.config import ConfigurationError from compose.config.config import Config from compose.config.types import VolumeFromSpec -from compose.config.types import VolumeSpec from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project @@ -478,44 +476,3 @@ class ProjectTest(unittest.TestCase): ), ) self.assertEqual([c.id for c in project.containers()], ['1']) - - def test_undeclared_volume_v2(self): - config = Config( - version=2, - services=[ - { - 'name': 'web', - 'image': 'busybox:latest', - 'volumes': [VolumeSpec.parse('data0028:/data:ro')], - }, - ], - networks=None, - volumes=None, - ) - with self.assertRaises(ConfigurationError): - Project.from_config('composetest', config, None) - - config = Config( - version=2, - services=[ - { - 'name': 'web', - 'image': 'busybox:latest', - 'volumes': [VolumeSpec.parse('./data0028:/data:ro')], - }, - ], networks=None, volumes=None, - ) - Project.from_config('composetest', config, None) - - def test_undeclared_volume_v1(self): - config = Config( - version=1, - services=[ - { - 'name': 'web', - 'image': 'busybox:latest', - 'volumes': [VolumeSpec.parse('data0028:/data:ro')], - }, - ], networks=None, volumes=None, - ) - Project.from_config('composetest', config, None) From 6540efb3d380e7ae50dd94493a43382f31e1e004 Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sat, 23 Jan 2016 15:58:06 -0500 Subject: [PATCH 042/580] If an env var is passthrough but not defined on the host don't set it. This doesn't change too much code and keeps the generators. Signed-off-by: jrabbit --- compose/config/config.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 961d36bb..2e4e036c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -464,12 +464,18 @@ def resolve_environment(service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) - return dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + d = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + if '_' in d.keys(): + del d['_'] + return d def resolve_build_args(build): args = parse_build_arguments(build.get('args')) - return dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) + d = dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) + if '_' in d.keys(): + del d['_'] + return d def validate_extended_service_dict(service_dict, filename, service): @@ -730,7 +736,7 @@ def resolve_env_var(key, val): elif key in os.environ: return key, os.environ[key] else: - return key, '' + return "_", None def env_vars_from_file(filename): From 7ab9509ce65167dc81dd14f34cddfb5ecff1329d Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sat, 23 Jan 2016 16:19:17 -0500 Subject: [PATCH 043/580] Mangle the tests. They pass for better or worse! Signed-off-by: jrabbit --- tests/unit/config/config_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index eb8ed2c7..b4f92d76 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1443,7 +1443,7 @@ class EnvTest(unittest.TestCase): } self.assertEqual( resolve_environment(service_dict), - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3'}, ) def test_resolve_environment_from_env_file(self): @@ -1484,7 +1484,6 @@ class EnvTest(unittest.TestCase): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' }, ) @@ -1503,7 +1502,7 @@ class EnvTest(unittest.TestCase): } self.assertEqual( resolve_build_args(build), - {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': ''}, + {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2'}, ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') From 001903771260069c475738efbbcb830dd9cf8227 Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sun, 24 Jan 2016 15:25:06 -0500 Subject: [PATCH 044/580] Modify service_test.py::ServiceTest::test_resolve_env to reflect new behavior Signed-off-by: jrabbit --- tests/integration/service_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 379e51ea..bf3ff610 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -860,7 +860,6 @@ class ServiceTest(DockerClientTestCase): 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' }.items(): self.assertEqual(env[k], v) From 7f3a319ecc2c110730753bb2799c5151b5731111 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 25 Jan 2016 10:15:14 +0100 Subject: [PATCH 045/580] bash completion for `docker-compose create` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2b020c37..8ba9548f 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -107,6 +107,18 @@ _docker_compose_config() { } +_docker_compose_create() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--force-recreate --help --no-build --no-recreate" -- "$cur" ) ) + ;; + *) + __docker_compose_services_all + ;; + esac +} + + _docker_compose_docker_compose() { case "$prev" in --file|-f) @@ -409,6 +421,7 @@ _docker_compose() { local commands=( build config + create down events help From 381d58bc661e013041a354de70b1464c4eecc79b Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Mon, 25 Jan 2016 10:27:21 +0100 Subject: [PATCH 046/580] Add zsh completion for 'docker-compose create' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 43017022..f67bc9f6 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -203,6 +203,14 @@ __docker-compose_subcommand() { '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ '--services[Print the service names, one per line.]' && ret=0 ;; + (create) + _arguments \ + $opts_help \ + "(--no-recreate --no-build)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ + "(--force-recreate)--no-build[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ + "(--force-recreate)--no-recreate[Don't build an image, even if it's missing]" \ + '*:services:__docker-compose_services_all' && ret=0 + ;; (down) _arguments \ $opts_help \ From 73a0d8307596bfe5954729d71672436024c54589 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 25 Jan 2016 10:34:29 +0100 Subject: [PATCH 047/580] Fix computation of service list in bash completion The previous approach assumed that the service list could be extracted from a single file. It did not follow extends and overrides. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2b020c37..2c18bc04 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -17,6 +17,10 @@ # . ~/.docker-compose-completion.sh +__docker_compose_q() { + docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} "$@" +} + # suppress trailing whitespace __docker_compose_nospace() { # compopt is not available in ancient bash versions @@ -39,7 +43,7 @@ __docker_compose_compose_file() { # Extracts all service names from the compose file. ___docker_compose_all_services_in_compose_file() { - awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-$(__docker_compose_compose_file)}" 2>/dev/null + __docker_compose_q config --services } # All services, even those without an existing container @@ -49,8 +53,12 @@ __docker_compose_services_all() { # All services that have an entry with the given key in their compose_file section ___docker_compose_services_with_key() { - # flatten sections to one line, then filter lines containing the key and return section name. - awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' "${compose_file:-$(__docker_compose_compose_file)}" 2>/dev/null | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' + # flatten sections under "services" to one line, then filter lines containing the key and return section name + __docker_compose_q config \ + | sed -n -e '/^services:/,/^[^ ]/p' \ + | sed -n 's/^ //p' \ + | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ + | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' } # All services that are defined by a Dockerfile reference @@ -67,11 +75,9 @@ __docker_compose_services_from_image() { # by a boolean expression passed in as argument. __docker_compose_services_with() { local containers names - containers="$(docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} ps -q)" - names=( $(docker 2>/dev/null inspect --format "{{if ${1:-true}}} {{ .Name }} {{end}}" $containers) ) - names=( ${names[@]%_*} ) # strip trailing numbers - names=( ${names[@]#*_} ) # strip project name - COMPREPLY=( $(compgen -W "${names[*]}" -- "$cur") ) + containers="$(__docker_compose_q ps -q)" + names=$(docker 2>/dev/null inspect -f "{{if ${1:-true}}}{{range \$k, \$v := .Config.Labels}}{{if eq \$k \"com.docker.compose.service\"}}{{\$v}}{{end}}{{end}}{{end}}" $containers) + COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } # The services for which at least one paused container exists From 313c584185f3999eb7a9ad0b7792a251b0afcbf8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 16:14:21 +0000 Subject: [PATCH 048/580] Alias containers by short id Signed-off-by: Aanand Prasad --- compose/service.py | 31 +++++++++---------------------- tests/acceptance/cli_test.py | 8 ++++++-- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/compose/service.py b/compose/service.py index 18322846..166fb0b2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -430,12 +430,16 @@ class Service(object): return container def connect_container_to_networks(self, container): - one_off = (container.labels.get(LABEL_ONE_OFF) == "True") + connected_networks = container.get('NetworkSettings.Networks') for network in self.networks: + if network in connected_networks: + self.client.disconnect_container_from_network( + container.id, network) + self.client.connect_container_to_network( container.id, network, - aliases=self._get_aliases(one_off=one_off), + aliases=self._get_aliases(container), links=self._get_links(False), ) @@ -507,11 +511,11 @@ class Service(object): numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 - def _get_aliases(self, one_off): - if one_off: + def _get_aliases(self, container): + if container.labels.get(LABEL_ONE_OFF) == "True": return [] - return [self.name] + return [self.name, container.short_id] def _get_links(self, link_to_self): links = {} @@ -618,9 +622,6 @@ class Service(object): override_options, one_off=one_off) - container_options['networking_config'] = self._get_container_networking_config( - one_off=one_off) - return container_options def _get_container_host_config(self, override_options, one_off=False): @@ -655,20 +656,6 @@ class Service(object): cpu_quota=options.get('cpu_quota'), ) - def _get_container_networking_config(self, one_off=False): - if self.net.mode in ['host', 'bridge']: - return None - - if self.net.mode not in self.networks: - return None - - return self.client.create_networking_config({ - self.net.mode: self.client.create_endpoint_config( - aliases=self._get_aliases(one_off=one_off), - links=self._get_links(False), - ) - }) - def build(self, no_cache=False, pull=False, force_rm=False): log.info('Building %s' % self.name) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6ae04ee5..7bc72305 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -420,8 +420,12 @@ class CLITestCase(DockerClientTestCase): container = containers[0] self.assertIn(container.id, network['Containers']) - networks = list(container.get('NetworkSettings.Networks')) - self.assertEqual(networks, [network['Name']]) + networks = container.get('NetworkSettings.Networks') + self.assertEqual(list(networks), [network['Name']]) + + self.assertEqual( + sorted(networks[network['Name']]['Aliases']), + sorted([service.name, container.short_id])) for service in services: assert self.lookup(container, service.name) From 5fe0b57e5c078e7ba3c7dd8605e4968559c6fa24 Mon Sep 17 00:00:00 2001 From: Tobias Munk Date: Mon, 25 Jan 2016 19:04:03 +0100 Subject: [PATCH 049/580] fixed documentation about traversing yml files Signed-off-by: Tobias Munk --- docs/reference/docker-compose.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index c19e4284..44a466e3 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -85,12 +85,12 @@ stdin. When stdin is used all paths in the configuration are relative to the current working directory. The `-f` flag is optional. If you don't provide this flag on the command line, -Compose traverses the working directory and its subdirectories looking for a +Compose traverses the working directory and its parent directories looking for a `docker-compose.yml` and a `docker-compose.override.yml` file. You must -supply at least the `docker-compose.yml` file. If both files are present, -Compose combines the two files into a single configuration. The configuration -in the `docker-compose.override.yml` file is applied over and in addition to -the values in the `docker-compose.yml` file. +supply at least the `docker-compose.yml` file. If both files are present on the +same directory level, Compose combines the two files into a single configuration. +The configuration in the `docker-compose.override.yml` file is applied over and +in addition to the values in the `docker-compose.yml` file. See also the `COMPOSE_FILE` [environment variable](overview.md#compose-file). From e566a4dc1c722b997890c2ab83bb5ad1e8b8f852 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 12:45:30 +0000 Subject: [PATCH 050/580] Implement network_mode in v2 Signed-off-by: Aanand Prasad --- compose/config/config.py | 18 ++- compose/config/service_schema_v2.json | 1 + compose/config/sort_services.py | 12 +- compose/config/validation.py | 19 +++ compose/project.py | 43 +++--- docs/compose-file.md | 49 +++++-- docs/networking.md | 12 -- tests/acceptance/cli_test.py | 35 ++++- tests/fixtures/extends/invalid-net-v2.yml | 12 ++ tests/fixtures/networks/bridge.yml | 9 ++ tests/fixtures/networks/network-mode.yml | 27 ++++ .../fixtures/networks/predefined-networks.yml | 17 --- tests/integration/project_test.py | 99 +++++++++++-- tests/unit/config/config_test.py | 131 +++++++++++++++++- tests/unit/config/sort_services_test.py | 4 +- tests/unit/project_test.py | 4 +- 16 files changed, 405 insertions(+), 87 deletions(-) create mode 100644 tests/fixtures/extends/invalid-net-v2.yml create mode 100644 tests/fixtures/networks/bridge.yml create mode 100644 tests/fixtures/networks/network-mode.yml delete mode 100644 tests/fixtures/networks/predefined-networks.yml diff --git a/compose/config/config.py b/compose/config/config.py index 8e7d96e2..c1391c2f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,6 +19,7 @@ from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .sort_services import get_container_name_from_net from .sort_services import get_service_name_from_net from .sort_services import sort_service_dicts from .types import parse_extra_hosts @@ -30,6 +31,7 @@ from .validation import validate_against_fields_schema from .validation import validate_against_service_schema from .validation import validate_depends_on from .validation import validate_extends_file_path +from .validation import validate_network_mode from .validation import validate_top_level_object from .validation import validate_top_level_service_objects from .validation import validate_ulimits @@ -490,10 +492,15 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'volumes_from' cannot be extended" % error_prefix) if 'net' in service_dict: - if get_service_name_from_net(service_dict['net']) is not None: + if get_container_name_from_net(service_dict['net']): raise ConfigurationError( "%s services with 'net: container' cannot be extended" % error_prefix) + if 'network_mode' in service_dict: + if get_service_name_from_net(service_dict['network_mode']): + raise ConfigurationError( + "%s services with 'network_mode: service' cannot be extended" % error_prefix) + if 'depends_on' in service_dict: raise ConfigurationError( "%s services with 'depends_on' cannot be extended" % error_prefix) @@ -505,6 +512,7 @@ def validate_service(service_config, service_names, version): validate_paths(service_dict) validate_ulimits(service_config) + validate_network_mode(service_config, service_names) validate_depends_on(service_config, service_names) if not service_dict.get('image') and has_uppercase(service_name): @@ -565,6 +573,14 @@ def finalize_service(service_config, service_names, version): service_dict['volumes'] = [ VolumeSpec.parse(v) for v in service_dict['volumes']] + if 'net' in service_dict: + network_mode = service_dict.pop('net') + container_name = get_container_name_from_net(network_mode) + if container_name and container_name in service_names: + service_dict['network_mode'] = 'service:{}'.format(container_name) + else: + service_dict['network_mode'] = network_mode + if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 94046d5b..56c0cbf5 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -103,6 +103,7 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, + "network_mode": {"type": "string"}, "networks": { "type": "array", diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index ac0fa458..cf38a603 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -5,10 +5,18 @@ from compose.config.errors import DependencyError def get_service_name_from_net(net_config): + return get_source_name_from_net(net_config, 'service') + + +def get_container_name_from_net(net_config): + return get_source_name_from_net(net_config, 'container') + + +def get_source_name_from_net(net_config, source_type): if not net_config: return - if not net_config.startswith('container:'): + if not net_config.startswith(source_type+':'): return _, net_name = net_config.split(':', 1) @@ -33,7 +41,7 @@ def sort_service_dicts(services): service for service in services if (name in get_service_names(service.get('links', [])) or name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or - name == get_service_name_from_net(service.get('net')) or + name == get_service_name_from_net(service.get('network_mode')) or name in service.get('depends_on', [])) ] diff --git a/compose/config/validation.py b/compose/config/validation.py index 5c2d69ec..dfc34d57 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ from jsonschema import RefResolver from jsonschema import ValidationError from .errors import ConfigurationError +from .sort_services import get_service_name_from_net log = logging.getLogger(__name__) @@ -147,6 +148,24 @@ def validate_extends_file_path(service_name, extends_options, filename): ) +def validate_network_mode(service_config, service_names): + network_mode = service_config.config.get('network_mode') + if not network_mode: + return + + if 'networks' in service_config.config: + raise ConfigurationError("'network_mode' and 'networks' cannot be combined") + + dependency = get_service_name_from_net(network_mode) + if not dependency: + return + + if dependency not in service_names: + raise ConfigurationError( + "Service '{s.name}' uses the network stack of service '{dep}' which " + "is undefined.".format(s=service_config, dep=dependency)) + + def validate_depends_on(service_config, service_names): for dependency in service_config.config.get('depends_on', []): if dependency not in service_names: diff --git a/compose/project.py b/compose/project.py index b51fcd7e..ef913d63 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,7 @@ from docker.errors import NotFound from . import parallel from .config import ConfigurationError +from .config.sort_services import get_container_name_from_net from .config.sort_services import get_service_name_from_net from .const import DEFAULT_TIMEOUT from .const import IMAGE_EVENTS @@ -86,12 +87,11 @@ class Project(object): for service_dict in config_data.services: if use_networking: networks = get_networks(service_dict, all_networks) - net = Net(networks[0]) if networks else Net("none") else: networks = [] - net = project.get_net(service_dict) links = project.get_links(service_dict) + net = project.get_net(service_dict, networks) volumes_from = get_volumes_from(project, service_dict) if config_data.version == 2: @@ -197,27 +197,27 @@ class Project(object): del service_dict['links'] return links - def get_net(self, service_dict): - net = service_dict.pop('net', None) + def get_net(self, service_dict, networks): + net = service_dict.pop('network_mode', None) if not net: + if self.use_networking: + return Net(networks[0]) if networks else Net('none') return Net(None) - net_name = get_service_name_from_net(net) - if not net_name: - return Net(net) + service_name = get_service_name_from_net(net) + if service_name: + return ServiceNet(self.get_service(service_name)) - try: - return ServiceNet(self.get_service(net_name)) - except NoSuchService: - pass - try: - return ContainerNet(Container.from_id(self.client, net_name)) - except APIError: - raise ConfigurationError( - 'Service "%s" is trying to use the network of "%s", ' - 'which is not the name of a service or container.' % ( - service_dict['name'], - net_name)) + container_name = get_container_name_from_net(net) + if container_name: + try: + return ContainerNet(Container.from_id(self.client, container_name)) + except APIError: + raise ConfigurationError( + "Service '{name}' uses the network stack of container '{dep}' which " + "does not exist.".format(name=service_dict['name'], dep=container_name)) + + return Net(net) def start(self, service_names=None, **options): containers = [] @@ -465,9 +465,12 @@ class Project(object): def get_networks(service_dict, network_definitions): + if 'network_mode' in service_dict: + return [] + networks = [] for name in service_dict.pop('networks', ['default']): - if name in ['bridge', 'host']: + if name in ['bridge']: networks.append(name) else: matches = [n for n in network_definitions if n.name == name] diff --git a/docs/compose-file.md b/docs/compose-file.md index b2675ac9..6b61755f 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -437,14 +437,29 @@ Specify logging options as key-value pairs. An example of `syslog` options: ### net > [Version 1 file format](#version-1) only. In version 2, use -> [networks](#networks). +> [network_mode](#network_mode). -Networking mode. Use the same values as the docker client `--net` parameter. +Network mode. Use the same values as the docker client `--net` parameter. +The `container:...` form can take a service name instead of a container name or +id. net: "bridge" - net: "none" - net: "container:[name or id]" net: "host" + net: "none" + net: "container:[service name or container name/id]" + +### network_mode + +> [Version 2 file format](#version-1) only. In version 1, use [net](#net). + +Network mode. Use the same values as the docker client `--net` parameter, plus +the special form `service:[service name]`. + + network_mode: "bridge" + network_mode: "host" + network_mode: "none" + network_mode: "service:[service name]" + network_mode: "container:[container name/id]" ### networks @@ -457,8 +472,8 @@ Networks to join, referencing entries under the - some-network - other-network -The values `bridge`, `host` and `none` can also be used, and are equivalent to -`net: "bridge"`, `net: "host"` or `net: "none"` in version 1. +The value `bridge` can also be used to make containers join the pre-defined +`bridge` network. There is no equivalent to `net: "container:[name or id]"`. @@ -918,16 +933,22 @@ It's more complicated if you're using particular configuration features: your service's containers to an [external network](networking.md#using-a-pre-existing-network). -- `net`: If you're using `host`, `bridge` or `none`, this is now replaced by - `networks`: +- `net`: This is now replaced by [network_mode](#network_mode): - net: host -> networks: ["host"] - net: bridge -> networks: ["bridge"] - net: none -> networks: ["none"] + net: host -> network_mode: host + net: bridge -> network_mode: bridge + net: none -> network_mode: none - If you're using `net: "container:"`, there is no equivalent to this in - version 2 - you should use [Docker networks](networking.md) for - communication instead. + If you're using `net: "container:[service name]"`, you must now use + `network_mode: "service:[service name]"` instead. + + net: "container:web" -> network_mode: "service:web" + + If you're using `net: "container:[container name/id]"`, the value does not + need to change. + + net: "container:cont-name" -> network_mode: "container:cont-name" + net: "container:abc12345" -> network_mode: "container:abc12345" ## Variable substitution diff --git a/docs/networking.md b/docs/networking.md index 1e662dd2..93533e9d 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -144,15 +144,3 @@ If you want your containers to join a pre-existing network, use the [`external` name: my-pre-existing-network Instead of attemping to create a network called `[projectname]_default`, Compose will look for a network called `my-pre-existing-network` and connect your app's containers to it. - -## Custom container network modes - -The `docker` CLI command allows you to specify a custom network mode for a container with the `--net` option - for example, `--net=host` specifies that the container should use the same network namespace as the Docker host, and `--net=none` specifies that it should have no networking capabilities. - -To make use of this in Compose, specify a `networks` list with a single item `host`, `bridge` or `none`: - - app: - build: ./app - networks: ["host"] - -There is no equivalent to `--net=container:CONTAINER_NAME` in the v2 Compose file format. You should instead use networks to enable communication. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7bc72305..4b560efa 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -496,8 +496,29 @@ class CLITestCase(DockerClientTestCase): assert 'Service "web" uses an undefined network "foo"' in result.stderr @v2_only() - def test_up_predefined_networks(self): - filename = 'predefined-networks.yml' + def test_up_with_bridge_network_plus_default(self): + filename = 'bridge.yml' + + self.base_dir = 'tests/fixtures/networks' + self._project = get_project(self.base_dir, [filename]) + + self.dispatch(['-f', filename, 'up', '-d'], None) + + container = self.project.containers()[0] + + assert sorted(list(container.get('NetworkSettings.Networks'))) == sorted([ + 'bridge', + self.project.default_network.full_name, + ]) + + @v2_only() + def test_up_with_network_mode(self): + c = self.client.create_container('busybox', 'top', name='composetest_network_mode_container') + self.addCleanup(self.client.remove_container, c, force=True) + self.client.start(c) + container_mode_source = 'container:{}'.format(c['Id']) + + filename = 'network-mode.yml' self.base_dir = 'tests/fixtures/networks' self._project = get_project(self.base_dir, [filename]) @@ -515,6 +536,16 @@ class CLITestCase(DockerClientTestCase): assert list(container.get('NetworkSettings.Networks')) == [name] assert container.get('HostConfig.NetworkMode') == name + service_mode_source = 'container:{}'.format( + self.project.get_service('bridge').containers()[0].id) + service_mode_container = self.project.get_service('service').containers()[0] + assert not service_mode_container.get('NetworkSettings.Networks') + assert service_mode_container.get('HostConfig.NetworkMode') == service_mode_source + + container_mode_container = self.project.get_service('container').containers()[0] + assert not container_mode_container.get('NetworkSettings.Networks') + assert container_mode_container.get('HostConfig.NetworkMode') == container_mode_source + @v2_only() def test_up_external_networks(self): filename = 'external-networks.yml' diff --git a/tests/fixtures/extends/invalid-net-v2.yml b/tests/fixtures/extends/invalid-net-v2.yml new file mode 100644 index 00000000..0a04f468 --- /dev/null +++ b/tests/fixtures/extends/invalid-net-v2.yml @@ -0,0 +1,12 @@ +version: 2 +services: + myweb: + build: '.' + extends: + service: web + command: top + web: + build: '.' + network_mode: "service:net" + net: + build: '.' diff --git a/tests/fixtures/networks/bridge.yml b/tests/fixtures/networks/bridge.yml new file mode 100644 index 00000000..95098372 --- /dev/null +++ b/tests/fixtures/networks/bridge.yml @@ -0,0 +1,9 @@ +version: 2 + +services: + web: + image: busybox + command: top + networks: + - bridge + - default diff --git a/tests/fixtures/networks/network-mode.yml b/tests/fixtures/networks/network-mode.yml new file mode 100644 index 00000000..7ab63df8 --- /dev/null +++ b/tests/fixtures/networks/network-mode.yml @@ -0,0 +1,27 @@ +version: 2 + +services: + bridge: + image: busybox + command: top + network_mode: bridge + + service: + image: busybox + command: top + network_mode: "service:bridge" + + container: + image: busybox + command: top + network_mode: "container:composetest_network_mode_container" + + host: + image: busybox + command: top + network_mode: host + + none: + image: busybox + command: top + network_mode: none diff --git a/tests/fixtures/networks/predefined-networks.yml b/tests/fixtures/networks/predefined-networks.yml deleted file mode 100644 index d0fac377..00000000 --- a/tests/fixtures/networks/predefined-networks.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 - -services: - bridge: - image: busybox - command: top - networks: ["bridge"] - - host: - image: busybox - command: top - networks: ["host"] - - none: - image: busybox - command: top - networks: [] diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 586f9444..0945ebb8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,10 +4,12 @@ from __future__ import unicode_literals import random import py +import pytest from docker.errors import NotFound from .testcases import DockerClientTestCase from compose.config import config +from compose.config import ConfigurationError from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT @@ -104,7 +106,71 @@ class ProjectTest(DockerClientTestCase): db = project.get_service('db') self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) - def test_net_from_service(self): + @v2_only() + def test_network_mode_from_service(self): + project = Project.from_config( + name='composetest', + client=self.client, + config_data=build_service_dicts({ + 'version': 2, + 'services': { + 'net': { + 'image': 'busybox:latest', + 'command': ["top"] + }, + 'web': { + 'image': 'busybox:latest', + 'network_mode': 'service:net', + 'command': ["top"] + }, + }, + }), + ) + + project.up() + + web = project.get_service('web') + net = project.get_service('net') + self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) + + @v2_only() + def test_network_mode_from_container(self): + def get_project(): + return Project.from_config( + name='composetest', + config_data=build_service_dicts({ + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox:latest', + 'network_mode': 'container:composetest_net_container' + }, + }, + }), + client=self.client, + ) + + with pytest.raises(ConfigurationError) as excinfo: + get_project() + + assert "container 'composetest_net_container' which does not exist" in excinfo.exconly() + + net_container = Container.create( + self.client, + image='busybox:latest', + name='composetest_net_container', + command='top', + labels={LABEL_PROJECT: 'composetest'}, + ) + net_container.start() + + project = get_project() + project.up() + + web = project.get_service('web') + self.assertEqual(web.net.mode, 'container:' + net_container.id) + + def test_net_from_service_v1(self): project = Project.from_config( name='composetest', config_data=build_service_dicts({ @@ -127,7 +193,24 @@ class ProjectTest(DockerClientTestCase): net = project.get_service('net') self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) - def test_net_from_container(self): + def test_net_from_container_v1(self): + def get_project(): + return Project.from_config( + name='composetest', + config_data=build_service_dicts({ + 'web': { + 'image': 'busybox:latest', + 'net': 'container:composetest_net_container' + }, + }), + client=self.client, + ) + + with pytest.raises(ConfigurationError) as excinfo: + get_project() + + assert "container 'composetest_net_container' which does not exist" in excinfo.exconly() + net_container = Container.create( self.client, image='busybox:latest', @@ -137,17 +220,7 @@ class ProjectTest(DockerClientTestCase): ) net_container.start() - project = Project.from_config( - name='composetest', - config_data=build_service_dicts({ - 'web': { - 'image': 'busybox:latest', - 'net': 'container:composetest_net_container' - }, - }), - client=self.client, - ) - + project = get_project() project.up() web = project.get_service('web') diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 98fe7758..0d8f7224 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1015,6 +1015,126 @@ class ConfigTest(unittest.TestCase): assert "Service 'one' depends on service 'three'" in exc.exconly() +class NetworkModeTest(unittest.TestCase): + def test_network_mode_standard(self): + config_data = config.load(build_config_details({ + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox', + 'command': "top", + 'network_mode': 'bridge', + }, + }, + })) + + assert config_data.services[0]['network_mode'] == 'bridge' + + def test_network_mode_standard_v1(self): + config_data = config.load(build_config_details({ + 'web': { + 'image': 'busybox', + 'command': "top", + 'net': 'bridge', + }, + })) + + assert config_data.services[0]['network_mode'] == 'bridge' + assert 'net' not in config_data.services[0] + + def test_network_mode_container(self): + config_data = config.load(build_config_details({ + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox', + 'command': "top", + 'network_mode': 'container:foo', + }, + }, + })) + + assert config_data.services[0]['network_mode'] == 'container:foo' + + def test_network_mode_container_v1(self): + config_data = config.load(build_config_details({ + 'web': { + 'image': 'busybox', + 'command': "top", + 'net': 'container:foo', + }, + })) + + assert config_data.services[0]['network_mode'] == 'container:foo' + + def test_network_mode_service(self): + config_data = config.load(build_config_details({ + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox', + 'command': "top", + 'network_mode': 'service:foo', + }, + 'foo': { + 'image': 'busybox', + 'command': "top", + }, + }, + })) + + assert config_data.services[1]['network_mode'] == 'service:foo' + + def test_network_mode_service_v1(self): + config_data = config.load(build_config_details({ + 'web': { + 'image': 'busybox', + 'command': "top", + 'net': 'container:foo', + }, + 'foo': { + 'image': 'busybox', + 'command': "top", + }, + })) + + assert config_data.services[1]['network_mode'] == 'service:foo' + + def test_network_mode_service_nonexistent(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load(build_config_details({ + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox', + 'command': "top", + 'network_mode': 'service:foo', + }, + }, + })) + + assert "service 'foo' which is undefined" in excinfo.exconly() + + def test_network_mode_plus_networks_is_invalid(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load(build_config_details({ + 'version': 2, + 'services': { + 'web': { + 'image': 'busybox', + 'command': "top", + 'network_mode': 'bridge', + 'networks': ['front'], + }, + }, + 'networks': { + 'front': None, + } + })) + + assert "'network_mode' and 'networks' cannot be combined" in excinfo.exconly() + + class PortsTest(unittest.TestCase): INVALID_PORTS_TYPES = [ {"1": "8000"}, @@ -1867,11 +1987,18 @@ class ExtendsTest(unittest.TestCase): load_from_filename('tests/fixtures/extends/invalid-volumes.yml') def test_invalid_net_in_extended_service(self): - expected_error_msg = "services with 'net: container' cannot be extended" + with pytest.raises(ConfigurationError) as excinfo: + load_from_filename('tests/fixtures/extends/invalid-net-v2.yml') - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + assert 'network_mode: service' in excinfo.exconly() + assert 'cannot be extended' in excinfo.exconly() + + with pytest.raises(ConfigurationError) as excinfo: load_from_filename('tests/fixtures/extends/invalid-net.yml') + assert 'net: container' in excinfo.exconly() + assert 'cannot be extended' in excinfo.exconly() + @mock.patch.dict(os.environ) def test_load_config_runs_interpolation_in_extended_service(self): os.environ.update(HOSTNAME_VALUE="penguin") diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index f5990664..c39ac022 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -100,7 +100,7 @@ class TestSortService(object): }, { 'name': 'parent', - 'net': 'container:child' + 'network_mode': 'service:child' }, { 'name': 'child' @@ -137,7 +137,7 @@ class TestSortService(object): def test_sort_service_dicts_7(self): services = [ { - 'net': 'container:three', + 'network_mode': 'service:three', 'name': 'four' }, { diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index ffd4455f..3ad131f3 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -365,7 +365,7 @@ class ProjectTest(unittest.TestCase): { 'name': 'test', 'image': 'busybox:latest', - 'net': 'container:aaa' + 'network_mode': 'container:aaa' }, ], networks=None, @@ -398,7 +398,7 @@ class ProjectTest(unittest.TestCase): { 'name': 'test', 'image': 'busybox:latest', - 'net': 'container:aaa' + 'network_mode': 'service:aaa' }, ], networks=None, From a9c623fdf2e36531d3811c676f41317daf9c7b34 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 14:26:36 +0000 Subject: [PATCH 051/580] Test that net can be extended Signed-off-by: Aanand Prasad --- tests/fixtures/extends/common.yml | 1 + tests/fixtures/extends/docker-compose.yml | 1 + tests/unit/config/config_test.py | 3 +++ 3 files changed, 5 insertions(+) diff --git a/tests/fixtures/extends/common.yml b/tests/fixtures/extends/common.yml index 358ef5bc..b2d86aa4 100644 --- a/tests/fixtures/extends/common.yml +++ b/tests/fixtures/extends/common.yml @@ -1,6 +1,7 @@ web: image: busybox command: /bin/true + net: host environment: - FOO=1 - BAR=1 diff --git a/tests/fixtures/extends/docker-compose.yml b/tests/fixtures/extends/docker-compose.yml index c51be49e..8e37d404 100644 --- a/tests/fixtures/extends/docker-compose.yml +++ b/tests/fixtures/extends/docker-compose.yml @@ -11,6 +11,7 @@ myweb: BAR: "2" # add BAZ BAZ: "2" + net: bridge mydb: image: busybox command: top diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0d8f7224..5f8b097b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1762,6 +1762,7 @@ class ExtendsTest(unittest.TestCase): 'name': 'myweb', 'image': 'busybox', 'command': 'top', + 'network_mode': 'bridge', 'links': ['mydb:db'], 'environment': { "FOO": "1", @@ -1779,6 +1780,7 @@ class ExtendsTest(unittest.TestCase): 'name': 'web', 'image': 'busybox', 'command': '/bin/true', + 'network_mode': 'host', 'environment': { "FOO": "2", "BAR": "1", @@ -1797,6 +1799,7 @@ class ExtendsTest(unittest.TestCase): 'name': 'myweb', 'image': 'busybox', 'command': '/bin/true', + 'network_mode': 'host', 'environment': { "FOO": "2", "BAR": "2", From ed1b2048040680ecfde8c5be4c3a66671d5cb068 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 14:27:12 +0000 Subject: [PATCH 052/580] Rename 'net' to 'network mode' in various classes/methods Signed-off-by: Aanand Prasad --- compose/config/config.py | 10 +++--- compose/config/sort_services.py | 18 +++++------ compose/config/validation.py | 4 +-- compose/project.py | 34 ++++++++++---------- compose/service.py | 23 +++++++------- tests/integration/project_test.py | 8 ++--- tests/integration/service_test.py | 8 ++--- tests/unit/project_test.py | 6 ++-- tests/unit/service_test.py | 52 +++++++++++++++---------------- 9 files changed, 81 insertions(+), 82 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index c1391c2f..ffd805ad 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,8 +19,8 @@ from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables -from .sort_services import get_container_name_from_net -from .sort_services import get_service_name_from_net +from .sort_services import get_container_name_from_network_mode +from .sort_services import get_service_name_from_network_mode from .sort_services import sort_service_dicts from .types import parse_extra_hosts from .types import parse_restart_spec @@ -492,12 +492,12 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'volumes_from' cannot be extended" % error_prefix) if 'net' in service_dict: - if get_container_name_from_net(service_dict['net']): + if get_container_name_from_network_mode(service_dict['net']): raise ConfigurationError( "%s services with 'net: container' cannot be extended" % error_prefix) if 'network_mode' in service_dict: - if get_service_name_from_net(service_dict['network_mode']): + if get_service_name_from_network_mode(service_dict['network_mode']): raise ConfigurationError( "%s services with 'network_mode: service' cannot be extended" % error_prefix) @@ -575,7 +575,7 @@ def finalize_service(service_config, service_names, version): if 'net' in service_dict: network_mode = service_dict.pop('net') - container_name = get_container_name_from_net(network_mode) + container_name = get_container_name_from_network_mode(network_mode) if container_name and container_name in service_names: service_dict['network_mode'] = 'service:{}'.format(container_name) else: diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index cf38a603..9d29f329 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -4,22 +4,22 @@ from __future__ import unicode_literals from compose.config.errors import DependencyError -def get_service_name_from_net(net_config): - return get_source_name_from_net(net_config, 'service') +def get_service_name_from_network_mode(network_mode): + return get_source_name_from_network_mode(network_mode, 'service') -def get_container_name_from_net(net_config): - return get_source_name_from_net(net_config, 'container') +def get_container_name_from_network_mode(network_mode): + return get_source_name_from_network_mode(network_mode, 'container') -def get_source_name_from_net(net_config, source_type): - if not net_config: +def get_source_name_from_network_mode(network_mode, source_type): + if not network_mode: return - if not net_config.startswith(source_type+':'): + if not network_mode.startswith(source_type+':'): return - _, net_name = net_config.split(':', 1) + _, net_name = network_mode.split(':', 1) return net_name @@ -41,7 +41,7 @@ def sort_service_dicts(services): service for service in services if (name in get_service_names(service.get('links', [])) or name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or - name == get_service_name_from_net(service.get('network_mode')) or + name == get_service_name_from_network_mode(service.get('network_mode')) or name in service.get('depends_on', [])) ] diff --git a/compose/config/validation.py b/compose/config/validation.py index dfc34d57..05982020 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,7 +15,7 @@ from jsonschema import RefResolver from jsonschema import ValidationError from .errors import ConfigurationError -from .sort_services import get_service_name_from_net +from .sort_services import get_service_name_from_network_mode log = logging.getLogger(__name__) @@ -156,7 +156,7 @@ def validate_network_mode(service_config, service_names): if 'networks' in service_config.config: raise ConfigurationError("'network_mode' and 'networks' cannot be combined") - dependency = get_service_name_from_net(network_mode) + dependency = get_service_name_from_network_mode(network_mode) if not dependency: return diff --git a/compose/project.py b/compose/project.py index ef913d63..e5b6faef 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,8 +10,8 @@ from docker.errors import NotFound from . import parallel from .config import ConfigurationError -from .config.sort_services import get_container_name_from_net -from .config.sort_services import get_service_name_from_net +from .config.sort_services import get_container_name_from_network_mode +from .config.sort_services import get_service_name_from_network_mode from .const import DEFAULT_TIMEOUT from .const import IMAGE_EVENTS from .const import LABEL_ONE_OFF @@ -19,11 +19,11 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .container import Container from .network import Network -from .service import ContainerNet +from .service import ContainerNetworkMode from .service import ConvergenceStrategy -from .service import Net +from .service import NetworkMode from .service import Service -from .service import ServiceNet +from .service import ServiceNetworkMode from .utils import microseconds_from_time_nano from .volume import Volume @@ -91,7 +91,7 @@ class Project(object): networks = [] links = project.get_links(service_dict) - net = project.get_net(service_dict, networks) + network_mode = project.get_network_mode(service_dict, networks) volumes_from = get_volumes_from(project, service_dict) if config_data.version == 2: @@ -110,7 +110,7 @@ class Project(object): use_networking=use_networking, networks=networks, links=links, - net=net, + network_mode=network_mode, volumes_from=volumes_from, **service_dict) ) @@ -197,27 +197,27 @@ class Project(object): del service_dict['links'] return links - def get_net(self, service_dict, networks): - net = service_dict.pop('network_mode', None) - if not net: + def get_network_mode(self, service_dict, networks): + network_mode = service_dict.pop('network_mode', None) + if not network_mode: if self.use_networking: - return Net(networks[0]) if networks else Net('none') - return Net(None) + return NetworkMode(networks[0]) if networks else NetworkMode('none') + return NetworkMode(None) - service_name = get_service_name_from_net(net) + service_name = get_service_name_from_network_mode(network_mode) if service_name: - return ServiceNet(self.get_service(service_name)) + return ServiceNetworkMode(self.get_service(service_name)) - container_name = get_container_name_from_net(net) + container_name = get_container_name_from_network_mode(network_mode) if container_name: try: - return ContainerNet(Container.from_id(self.client, container_name)) + return ContainerNetworkMode(Container.from_id(self.client, container_name)) except APIError: raise ConfigurationError( "Service '{name}' uses the network stack of container '{dep}' which " "does not exist.".format(name=service_dict['name'], dep=container_name)) - return Net(net) + return NetworkMode(network_mode) def start(self, service_names=None, **options): containers = [] diff --git a/compose/service.py b/compose/service.py index 166fb0b2..106d5b26 100644 --- a/compose/service.py +++ b/compose/service.py @@ -47,7 +47,6 @@ DOCKER_START_KEYS = [ 'extra_hosts', 'ipc', 'read_only', - 'net', 'log_driver', 'log_opt', 'mem_limit', @@ -113,7 +112,7 @@ class Service(object): use_networking=False, links=None, volumes_from=None, - net=None, + network_mode=None, networks=None, **options ): @@ -123,7 +122,7 @@ class Service(object): self.use_networking = use_networking self.links = links or [] self.volumes_from = volumes_from or [] - self.net = net or Net(None) + self.network_mode = network_mode or NetworkMode(None) self.networks = networks or [] self.options = options @@ -472,7 +471,7 @@ class Service(object): 'options': self.options, 'image_id': self.image()['Id'], 'links': self.get_link_names(), - 'net': self.net.id, + 'net': self.network_mode.id, 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) @@ -480,7 +479,7 @@ class Service(object): } def get_dependency_names(self): - net_name = self.net.service_name + net_name = self.network_mode.service_name return (self.get_linked_service_names() + self.get_volumes_from_names() + ([net_name] if net_name else []) + @@ -636,7 +635,7 @@ class Service(object): binds=options.get('binds'), volumes_from=self._get_volumes_from(), privileged=options.get('privileged', False), - network_mode=self.net.mode, + network_mode=self.network_mode.mode, devices=options.get('devices'), dns=options.get('dns'), dns_search=options.get('dns_search'), @@ -774,22 +773,22 @@ class Service(object): log.error(six.text_type(e)) -class Net(object): +class NetworkMode(object): """A `standard` network mode (ex: host, bridge)""" service_name = None - def __init__(self, net): - self.net = net + def __init__(self, network_mode): + self.network_mode = network_mode @property def id(self): - return self.net + return self.network_mode mode = id -class ContainerNet(object): +class ContainerNetworkMode(object): """A network mode that uses a container's network stack.""" service_name = None @@ -806,7 +805,7 @@ class ContainerNet(object): return 'container:' + self.container.id -class ServiceNet(object): +class ServiceNetworkMode(object): """A network mode that uses a service's network stack.""" def __init__(self, service): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 0945ebb8..0c8c9a6a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -131,7 +131,7 @@ class ProjectTest(DockerClientTestCase): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) + self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) @v2_only() def test_network_mode_from_container(self): @@ -168,7 +168,7 @@ class ProjectTest(DockerClientTestCase): project.up() web = project.get_service('web') - self.assertEqual(web.net.mode, 'container:' + net_container.id) + self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) def test_net_from_service_v1(self): project = Project.from_config( @@ -191,7 +191,7 @@ class ProjectTest(DockerClientTestCase): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) + self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) def test_net_from_container_v1(self): def get_project(): @@ -224,7 +224,7 @@ class ProjectTest(DockerClientTestCase): project.up() web = project.get_service('web') - self.assertEqual(web.net.mode, 'container:' + net_container.id) + self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) def test_start_pause_unpause_stop_kill_remove(self): web = self.create_service('web') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 379e51ea..cde50b10 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,7 +26,7 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy -from compose.service import Net +from compose.service import NetworkMode from compose.service import Service @@ -752,17 +752,17 @@ class ServiceTest(DockerClientTestCase): assert len(service.containers(stopped=True)) == 2 def test_network_mode_none(self): - service = self.create_service('web', net=Net('none')) + service = self.create_service('web', network_mode=NetworkMode('none')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'none') def test_network_mode_bridged(self): - service = self.create_service('web', net=Net('bridge')) + service = self.create_service('web', network_mode=NetworkMode('bridge')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'bridge') def test_network_mode_host(self): - service = self.create_service('web', net=Net('host')) + service = self.create_service('web', network_mode=NetworkMode('host')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 3ad131f3..21c6be47 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -349,7 +349,7 @@ class ProjectTest(unittest.TestCase): ), ) service = project.get_service('test') - self.assertEqual(service.net.id, None) + self.assertEqual(service.network_mode.id, None) self.assertNotIn('NetworkMode', service._get_container_host_config({})) def test_use_net_from_container(self): @@ -373,7 +373,7 @@ class ProjectTest(unittest.TestCase): ), ) service = project.get_service('test') - self.assertEqual(service.net.mode, 'container:' + container_id) + self.assertEqual(service.network_mode.mode, 'container:' + container_id) def test_use_net_from_service(self): container_name = 'test_aaa_1' @@ -407,7 +407,7 @@ class ProjectTest(unittest.TestCase): ) service = project.get_service('test') - self.assertEqual(service.net.mode, 'container:' + container_name) + self.assertEqual(service.network_mode.mode, 'container:' + container_name) def test_uses_default_network_true(self): project = Project.from_config( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4d9aec65..74e9f0f5 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -15,16 +15,16 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.service import build_ulimits from compose.service import build_volume_binding -from compose.service import ContainerNet +from compose.service import ContainerNetworkMode from compose.service import get_container_data_volumes from compose.service import ImageType from compose.service import merge_volume_bindings from compose.service import NeedsBuildError -from compose.service import Net +from compose.service import NetworkMode from compose.service import NoSuchImageError from compose.service import parse_repository_tag from compose.service import Service -from compose.service import ServiceNet +from compose.service import ServiceNetworkMode from compose.service import warn_on_masked_volume @@ -407,7 +407,7 @@ class ServiceTest(unittest.TestCase): 'foo', image='example.com/foo', client=self.mock_client, - net=ServiceNet(Service('other')), + network_mode=ServiceNetworkMode(Service('other')), links=[(Service('one'), 'one')], volumes_from=[VolumeFromSpec(Service('two'), 'rw', 'service')]) @@ -421,7 +421,7 @@ class ServiceTest(unittest.TestCase): } self.assertEqual(config_dict, expected) - def test_config_dict_with_net_from_container(self): + def test_config_dict_with_network_mode_from_container(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} container = Container( self.mock_client, @@ -430,7 +430,7 @@ class ServiceTest(unittest.TestCase): 'foo', image='example.com/foo', client=self.mock_client, - net=container) + network_mode=ContainerNetworkMode(container)) config_dict = service.config_dict() expected = { @@ -589,20 +589,20 @@ class BuildUlimitsTestCase(unittest.TestCase): class NetTestCase(unittest.TestCase): - def test_net(self): - net = Net('host') - self.assertEqual(net.id, 'host') - self.assertEqual(net.mode, 'host') - self.assertEqual(net.service_name, None) + def test_network_mode(self): + network_mode = NetworkMode('host') + self.assertEqual(network_mode.id, 'host') + self.assertEqual(network_mode.mode, 'host') + self.assertEqual(network_mode.service_name, None) - def test_net_container(self): + def test_network_mode_container(self): container_id = 'abcd' - net = ContainerNet(Container(None, {'Id': container_id})) - self.assertEqual(net.id, container_id) - self.assertEqual(net.mode, 'container:' + container_id) - self.assertEqual(net.service_name, None) + network_mode = ContainerNetworkMode(Container(None, {'Id': container_id})) + self.assertEqual(network_mode.id, container_id) + self.assertEqual(network_mode.mode, 'container:' + container_id) + self.assertEqual(network_mode.service_name, None) - def test_net_service(self): + def test_network_mode_service(self): container_id = 'bbbb' service_name = 'web' mock_client = mock.create_autospec(docker.Client) @@ -611,23 +611,23 @@ class NetTestCase(unittest.TestCase): ] service = Service(name=service_name, client=mock_client) - net = ServiceNet(service) + network_mode = ServiceNetworkMode(service) - self.assertEqual(net.id, service_name) - self.assertEqual(net.mode, 'container:' + container_id) - self.assertEqual(net.service_name, service_name) + self.assertEqual(network_mode.id, service_name) + self.assertEqual(network_mode.mode, 'container:' + container_id) + self.assertEqual(network_mode.service_name, service_name) - def test_net_service_no_containers(self): + def test_network_mode_service_no_containers(self): service_name = 'web' mock_client = mock.create_autospec(docker.Client) mock_client.containers.return_value = [] service = Service(name=service_name, client=mock_client) - net = ServiceNet(service) + network_mode = ServiceNetworkMode(service) - self.assertEqual(net.id, service_name) - self.assertEqual(net.mode, None) - self.assertEqual(net.service_name, service_name) + self.assertEqual(network_mode.id, service_name) + self.assertEqual(network_mode.mode, None) + self.assertEqual(network_mode.service_name, service_name) def build_mount(destination, source, mode='rw'): From c39d5a3f06eb92fbdd5a79de7ec6707683962024 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:51:09 +0000 Subject: [PATCH 053/580] Add note about named volumes to upgrade guide Signed-off-by: Aanand Prasad --- docs/compose-file.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index 6b61755f..b2097e06 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -950,6 +950,27 @@ It's more complicated if you're using particular configuration features: net: "container:cont-name" -> network_mode: "container:cont-name" net: "container:abc12345" -> network_mode: "container:abc12345" +- `volumes` with named volumes: these must now be explicitly declared in a + top-level `volumes` section of your Compose file. If a service mounts a + named volume called `data`, you must declare a `data` volume in your + top-level `volumes` section. The whole file might look like this: + + version: 2 + services: + db: + image: postgres + volumes: + - data:/var/lib/postgresql/data + volumes: + data: {} + + By default, Compose creates a volume whose name is prefixed with your + project name. If you want it to just be called `data`, declared it as + external: + + volumes: + data: + external: true ## Variable substitution From 4736b4409a6b752bb11f5f66611305531328909f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:53:02 +0000 Subject: [PATCH 054/580] Update for links, external_links, network_mode Signed-off-by: Aanand Prasad --- .../migrate-compose-file-v1-to-v2.py | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index a961b0bd..011ce338 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -20,20 +20,45 @@ def migrate(content): data = ruamel.yaml.load(content, ruamel.yaml.RoundTripLoader) service_names = data.keys() + for name, service in data.items(): - # remove links and external links - service.pop('links', None) - external_links = service.pop('external_links', None) + links = service.get('links') + if links: + example_service = links[0].partition(':')[0] + log.warn( + "Service {name} has links, which no longer create environment " + "variables such as {example_service_upper}_PORT. " + "If you are using those in your application code, you should " + "instead connect directly to the hostname, e.g. " + "'{example_service}'." + .format(name=name, example_service=example_service, + example_service_upper=example_service.upper())) + + external_links = service.get('external_links') if external_links: log.warn( - "Service {name} has external_links: {ext}, which are no longer " - "supported. See https://docs.docker.com/compose/networking/ " - "for options on how to connect external containers to the " - "compose network.".format(name=name, ext=external_links)) + "Service {name} has external_links: {ext}, which now work " + "slightly differently. In particular, two containers must be " + "connected to at least one network in common in order to " + "communicate, even if explicitly linked together.\n\n" + "Either connect the external container to your app's default " + "network, or connect both the external container and your " + "service's containers to a pre-existing network. See " + "https://docs.docker.com/compose/networking/ " + "for more on how to do this." + .format(name=name, ext=external_links)) - # net is now networks + # net is now network_mode if 'net' in service: - service['networks'] = [service.pop('net')] + network_mode = service.pop('net') + + # "container:" is now "service:" + if network_mode.startswith('container:'): + name = network_mode.partition(':')[2] + if name in service_names: + network_mode = 'service:{}'.format(name) + + service['network_mode'] = network_mode # create build section if 'dockerfile' in service: From 47b22e90f95c3aeac56f42083154c327449761c3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:53:25 +0000 Subject: [PATCH 055/580] Make warnings a bit more readable Signed-off-by: Aanand Prasad --- contrib/migration/migrate-compose-file-v1-to-v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index 011ce338..e156d9e1 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -106,7 +106,7 @@ def parse_opts(args): def main(args): - logging.basicConfig() + logging.basicConfig(format='\033[33m%(levelname)s:\033[37m %(message)s\n') opts = parse_opts(args) From f86fe118250fbb32ad4167c50965389763f75c01 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:54:18 +0000 Subject: [PATCH 056/580] Make sure version line is at the top of the file Signed-off-by: Aanand Prasad --- contrib/migration/migrate-compose-file-v1-to-v2.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index e156d9e1..fb73811c 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -78,8 +78,11 @@ def migrate(content): if volume_from.split(':', 1)[0] not in service_names: service['volumes_from'][idx] = 'container:%s' % volume_from - data['services'] = {name: data.pop(name) for name in data.keys()} + services = {name: data.pop(name) for name in data.keys()} + data['version'] = 2 + data['services'] = services + return data From aa4d43af0b40d5c38052ec4f5015aad5d3618e89 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 16:01:43 +0000 Subject: [PATCH 057/580] Create declarations for named volumes Signed-off-by: Aanand Prasad --- .../migrate-compose-file-v1-to-v2.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index fb73811c..23f6be3b 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -12,6 +12,8 @@ import sys import ruamel.yaml +from compose.config.types import VolumeSpec + log = logging.getLogger('migrate') @@ -82,10 +84,39 @@ def migrate(content): data['version'] = 2 data['services'] = services + create_volumes_section(data) return data +def create_volumes_section(data): + named_volumes = get_named_volumes(data['services']) + if named_volumes: + log.warn( + "Named volumes ({names}) must be explicitly declared. Creating a " + "'volumes' section with declarations.\n\n" + "For backwards-compatibility, they've been declared as external. " + "If you don't mind the volume names being prefixed with the " + "project name, you can remove the 'external' option from each one." + .format(names=', '.join(list(named_volumes)))) + + data['volumes'] = named_volumes + + +def get_named_volumes(services): + volume_specs = [ + VolumeSpec.parse(volume) + for service in services.values() + for volume in service.get('volumes', []) + ] + names = { + spec.external + for spec in volume_specs + if spec.is_named_volume + } + return {name: {'external': True} for name in names} + + def write(stream, new_format, indent, width): ruamel.yaml.dump( new_format, From 7d403d09cf27dbca10b6b5cc1bd8f047cc21f1e4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 16:08:34 +0000 Subject: [PATCH 058/580] Extract helper methods Signed-off-by: Aanand Prasad --- .../migrate-compose-file-v1-to-v2.py | 125 ++++++++++-------- 1 file changed, 70 insertions(+), 55 deletions(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index 23f6be3b..4f9be97f 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -24,61 +24,12 @@ def migrate(content): service_names = data.keys() for name, service in data.items(): - links = service.get('links') - if links: - example_service = links[0].partition(':')[0] - log.warn( - "Service {name} has links, which no longer create environment " - "variables such as {example_service_upper}_PORT. " - "If you are using those in your application code, you should " - "instead connect directly to the hostname, e.g. " - "'{example_service}'." - .format(name=name, example_service=example_service, - example_service_upper=example_service.upper())) - - external_links = service.get('external_links') - if external_links: - log.warn( - "Service {name} has external_links: {ext}, which now work " - "slightly differently. In particular, two containers must be " - "connected to at least one network in common in order to " - "communicate, even if explicitly linked together.\n\n" - "Either connect the external container to your app's default " - "network, or connect both the external container and your " - "service's containers to a pre-existing network. See " - "https://docs.docker.com/compose/networking/ " - "for more on how to do this." - .format(name=name, ext=external_links)) - - # net is now network_mode - if 'net' in service: - network_mode = service.pop('net') - - # "container:" is now "service:" - if network_mode.startswith('container:'): - name = network_mode.partition(':')[2] - if name in service_names: - network_mode = 'service:{}'.format(name) - - service['network_mode'] = network_mode - - # create build section - if 'dockerfile' in service: - service['build'] = { - 'context': service.pop('build'), - 'dockerfile': service.pop('dockerfile'), - } - - # create logging section - if 'log_driver' in service: - service['logging'] = {'driver': service.pop('log_driver')} - if 'log_opt' in service: - service['logging']['options'] = service.pop('log_opt') - - # volumes_from prefix with 'container:' - for idx, volume_from in enumerate(service.get('volumes_from', [])): - if volume_from.split(':', 1)[0] not in service_names: - service['volumes_from'][idx] = 'container:%s' % volume_from + warn_for_links(name, service) + warn_for_external_links(name, service) + rewrite_net(service, service_names) + rewrite_build(service) + rewrite_logging(service) + rewrite_volumes_from(service, service_names) services = {name: data.pop(name) for name in data.keys()} @@ -89,6 +40,70 @@ def migrate(content): return data +def warn_for_links(name, service): + links = service.get('links') + if links: + example_service = links[0].partition(':')[0] + log.warn( + "Service {name} has links, which no longer create environment " + "variables such as {example_service_upper}_PORT. " + "If you are using those in your application code, you should " + "instead connect directly to the hostname, e.g. " + "'{example_service}'." + .format(name=name, example_service=example_service, + example_service_upper=example_service.upper())) + + +def warn_for_external_links(name, service): + external_links = service.get('external_links') + if external_links: + log.warn( + "Service {name} has external_links: {ext}, which now work " + "slightly differently. In particular, two containers must be " + "connected to at least one network in common in order to " + "communicate, even if explicitly linked together.\n\n" + "Either connect the external container to your app's default " + "network, or connect both the external container and your " + "service's containers to a pre-existing network. See " + "https://docs.docker.com/compose/networking/ " + "for more on how to do this." + .format(name=name, ext=external_links)) + + +def rewrite_net(service, service_names): + if 'net' in service: + network_mode = service.pop('net') + + # "container:" is now "service:" + if network_mode.startswith('container:'): + name = network_mode.partition(':')[2] + if name in service_names: + network_mode = 'service:{}'.format(name) + + service['network_mode'] = network_mode + + +def rewrite_build(service): + if 'dockerfile' in service: + service['build'] = { + 'context': service.pop('build'), + 'dockerfile': service.pop('dockerfile'), + } + + +def rewrite_logging(service): + if 'log_driver' in service: + service['logging'] = {'driver': service.pop('log_driver')} + if 'log_opt' in service: + service['logging']['options'] = service.pop('log_opt') + + +def rewrite_volumes_from(service, service_names): + for idx, volume_from in enumerate(service.get('volumes_from', [])): + if volume_from.split(':', 1)[0] not in service_names: + service['volumes_from'][idx] = 'container:%s' % volume_from + + def create_volumes_section(data): named_volumes = get_named_volumes(data['services']) if named_volumes: From e40a46349f348efec11bd607f10ace62313a3152 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 17:41:26 +0000 Subject: [PATCH 059/580] Fix rebase-bump-commit script Trim whitespace from wc's output before constructing arguments to `git rebase` Signed-off-by: Aanand Prasad --- script/release/rebase-bump-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/rebase-bump-commit b/script/release/rebase-bump-commit index 23877bb5..3c2ae72b 100755 --- a/script/release/rebase-bump-commit +++ b/script/release/rebase-bump-commit @@ -32,7 +32,7 @@ if [[ "$sha" == "$(git rev-parse HEAD)" ]]; then exit 0 fi -commits=$(git log --format="%H" "$sha..HEAD" | wc -l) +commits=$(git log --format="%H" "$sha..HEAD" | wc -l | xargs echo) git rebase --onto $sha~1 HEAD~$commits $BRANCH git cherry-pick $sha From 0ba02b4a185d75343c0790bfff746f23f4c209a9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 18:48:43 +0000 Subject: [PATCH 060/580] Add back external links in v2 Signed-off-by: Aanand Prasad --- compose/config/service_schema_v2.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 56c0cbf5..ca9bb671 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -83,6 +83,7 @@ ] }, + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, "hostname": {"type": "string"}, "image": {"type": "string"}, From b84da7c78bacb28b62ec0b1ad34edd3330320e5b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 23:22:21 +0000 Subject: [PATCH 061/580] Fix trailing whitespace in docker-compose.md Signed-off-by: Aanand Prasad --- docs/reference/docker-compose.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 44a466e3..6e7e4901 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -87,9 +87,9 @@ relative to the current working directory. The `-f` flag is optional. If you don't provide this flag on the command line, Compose traverses the working directory and its parent directories looking for a `docker-compose.yml` and a `docker-compose.override.yml` file. You must -supply at least the `docker-compose.yml` file. If both files are present on the -same directory level, Compose combines the two files into a single configuration. -The configuration in the `docker-compose.override.yml` file is applied over and +supply at least the `docker-compose.yml` file. If both files are present on the +same directory level, Compose combines the two files into a single configuration. +The configuration in the `docker-compose.override.yml` file is applied over and in addition to the values in the `docker-compose.yml` file. See also the `COMPOSE_FILE` [environment variable](overview.md#compose-file). From e69ef1c456e509b3b6e967974360b5ef7a0262a4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Jan 2016 11:12:35 -0800 Subject: [PATCH 062/580] Bump docker-py version to latest RC Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ed3b8686..68ef9f35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.0rc2 +docker-py==1.7.0rc3 docopt==0.6.1 enum34==1.0.4 git+https://github.com/d11wtq/dockerpty.git@29b1394108b017ef3e3deaf00604a9eb99880d5e#egg=dockerpty From 650b0cec384e115c66c9b66e401f4ca37fc10490 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 00:42:04 +0000 Subject: [PATCH 063/580] Remove ability to join bridge network + user-defined networks Containers connected to the bridge network can't have aliases, so it's simpler to rule that they can *either* be connected to the bridge network (via `network_mode: bridge`) *or* be connected to user-defined networks (via `networks` or the default network). Signed-off-by: Aanand Prasad --- compose/project.py | 15 ++++++--------- docs/compose-file.md | 5 ----- tests/acceptance/cli_test.py | 16 ---------------- 3 files changed, 6 insertions(+), 30 deletions(-) diff --git a/compose/project.py b/compose/project.py index e5b6faef..d2787ecf 100644 --- a/compose/project.py +++ b/compose/project.py @@ -470,16 +470,13 @@ def get_networks(service_dict, network_definitions): networks = [] for name in service_dict.pop('networks', ['default']): - if name in ['bridge']: - networks.append(name) + matches = [n for n in network_definitions if n.name == name] + if matches: + networks.append(matches[0].full_name) else: - matches = [n for n in network_definitions if n.name == name] - if matches: - networks.append(matches[0].full_name) - else: - raise ConfigurationError( - 'Service "{}" uses an undefined network "{}"' - .format(service_dict['name'], name)) + raise ConfigurationError( + 'Service "{}" uses an undefined network "{}"' + .format(service_dict['name'], name)) return networks diff --git a/docs/compose-file.md b/docs/compose-file.md index 6b61755f..afef0b1a 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -472,11 +472,6 @@ Networks to join, referencing entries under the - some-network - other-network -The value `bridge` can also be used to make containers join the pre-defined -`bridge` network. - -There is no equivalent to `net: "container:[name or id]"`. - ### pid pid: "host" diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 4b560efa..30589bad 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -495,22 +495,6 @@ class CLITestCase(DockerClientTestCase): assert 'Service "web" uses an undefined network "foo"' in result.stderr - @v2_only() - def test_up_with_bridge_network_plus_default(self): - filename = 'bridge.yml' - - self.base_dir = 'tests/fixtures/networks' - self._project = get_project(self.base_dir, [filename]) - - self.dispatch(['-f', filename, 'up', '-d'], None) - - container = self.project.containers()[0] - - assert sorted(list(container.get('NetworkSettings.Networks'))) == sorted([ - 'bridge', - self.project.default_network.full_name, - ]) - @v2_only() def test_up_with_network_mode(self): c = self.client.create_container('busybox', 'top', name='composetest_network_mode_container') From d3a1cea1709143feb0f2b490dadcf50090dbffc1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 02:34:59 +0000 Subject: [PATCH 064/580] Remove outdated warnings about links from docs Signed-off-by: Aanand Prasad --- docs/compose-file.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index afef0b1a..28fedf42 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -808,7 +808,7 @@ future Compose release. Version 1 files cannot declare named [volumes](#volume-configuration-reference), [networks](networking.md) or -[build arguments](#args). They *can*, however, define [links](#links). +[build arguments](#args). Example: @@ -837,10 +837,6 @@ Named [volumes](#volume-configuration-reference) can be declared under the `volumes` key, and [networks](#network-configuration-reference) can be declared under the `networks` key. -You cannot define links when using version 2. Instead, you should use -[networking](networking.md) for communication between containers. In most cases, -this will involve less configuration than links. - Simple example: version: 2 From 3547c55523f67b31f8f54936c0ac743f55b22036 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 13:26:32 +0000 Subject: [PATCH 065/580] Default to vim if EDITOR is not set Signed-off-by: Aanand Prasad --- script/release/make-branch | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/script/release/make-branch b/script/release/make-branch index 48fa771b..82b6ada0 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -63,15 +63,17 @@ git merge --strategy=ours --no-edit $REMOTE/release git config "branch.${BRANCH}.release" $VERSION +editor=${EDITOR:-vim} + echo "Update versions in docs/install.md, compose/__init__.py, script/run.sh" -$EDITOR docs/install.md -$EDITOR compose/__init__.py -$EDITOR script/run.sh +$editor docs/install.md +$editor compose/__init__.py +$editor script/run.sh echo "Write release notes in CHANGELOG.md" browser "https://github.com/docker/compose/issues?q=milestone%3A$VERSION+is%3Aclosed" -$EDITOR CHANGELOG.md +$editor CHANGELOG.md git diff From 634ae7daa59a8f2fe3410ff8b538651fcb9a6662 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 13:27:12 +0000 Subject: [PATCH 066/580] Let the user specify any repo as their fork Signed-off-by: Aanand Prasad --- script/release/make-branch | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/release/make-branch b/script/release/make-branch index 82b6ada0..46ba6bbc 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -86,10 +86,10 @@ echo "Push branch to user remote" GITHUB_USER=$USER USER_REMOTE="$(find_remote $GITHUB_USER/compose)" if [ -z "$USER_REMOTE" ]; then - echo "No user remote found for $GITHUB_USER" - read -r -p "Enter the name of your github user: " GITHUB_USER + echo "$GITHUB_USER/compose not found" + read -r -p "Enter the name of your GitHub fork (username/repo): " GITHUB_REPO # assumes there is already a user remote somewhere - USER_REMOTE=$(find_remote $GITHUB_USER/compose) + USER_REMOTE=$(find_remote $GITHUB_REPO) fi if [ -z "$USER_REMOTE" ]; then >&2 echo "No user remote found. You need to 'git push' your branch." From 3fc72038c56482e63dbb2e1341f8475cf6bb5350 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Sun, 24 Jan 2016 12:03:44 -0800 Subject: [PATCH 067/580] New navigation for 1.10 release Updating with Joffrey's comments Signed-off-by: Mary Anthony --- docs/README.md | 4 +- docs/completion.md | 4 +- docs/compose-file.md | 3 +- docs/django.md | 6 +- docs/env.md | 6 +- docs/extends.md | 2 +- docs/faq.md | 4 +- docs/gettingstarted.md | 4 +- docs/index.md | 175 ++---------------------------------- docs/install.md | 6 +- docs/networking.md | 3 +- docs/overview.md | 191 ++++++++++++++++++++++++++++++++++++++++ docs/production.md | 2 +- docs/rails.md | 6 +- docs/reference/index.md | 7 +- docs/wordpress.md | 6 +- 16 files changed, 230 insertions(+), 199 deletions(-) create mode 100644 docs/overview.md diff --git a/docs/README.md b/docs/README.md index d8ab7c3e..e60fa48c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,7 +58,7 @@ The top of each Docker Compose documentation file contains TOML metadata. The me description = "How to use Docker Compose's extends keyword to share configuration between files and projects" keywords = ["fig, composition, compose, docker, orchestration, documentation, docs"] [menu.main] - parent="smn_workw_compose" + parent="workw_compose" weight=2 +++ @@ -70,7 +70,7 @@ The metadata alone has this structure: description = "How to use Docker Compose's extends keyword to share configuration between files and projects" keywords = ["fig, composition, compose, docker, orchestration, documentation, docs"] [menu.main] - parent="smn_workw_compose" + parent="workw_compose" weight=2 +++ diff --git a/docs/completion.md b/docs/completion.md index cac0d1a6..2076d512 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -4,8 +4,8 @@ title = "Command-line Completion" description = "Compose CLI reference" keywords = ["fig, composition, compose, docker, orchestration, cli, reference"] [menu.main] -parent="smn_workw_compose" -weight=10 +parent="workw_compose" +weight=88 +++ diff --git a/docs/compose-file.md b/docs/compose-file.md index 5b72cc28..cc21bd8a 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -5,7 +5,8 @@ description = "Compose file reference" keywords = ["fig, composition, compose, docker"] aliases = ["/compose/yml"] [menu.main] -parent="smn_compose_ref" +parent="workw_compose" +weight=70 +++ diff --git a/docs/django.md b/docs/django.md index 2d4fdaf9..573ea3d9 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,16 +1,16 @@ -# Quickstart Guide: Compose and Django +# Quickstart: Compose and Django This quick-start guide demonstrates how to use Compose to set up and run a simple Django/PostgreSQL app. Before starting, you'll need to have diff --git a/docs/env.md b/docs/env.md index 7b7e1bc7..c7b93c77 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,11 +1,11 @@ diff --git a/docs/extends.md b/docs/extends.md index 011a7350..c9b65db8 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -4,7 +4,7 @@ title = "Extending services in Compose" description = "How to use Docker Compose's extends keyword to share configuration between files and projects" keywords = ["fig, composition, compose, docker, orchestration, documentation, docs"] [menu.main] -parent="smn_workw_compose" +parent="workw_compose" weight=2 +++ diff --git a/docs/faq.md b/docs/faq.md index b36eb5ac..264a27ee 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,8 +4,8 @@ title = "Frequently Asked Questions" description = "Docker Compose FAQ" keywords = "documentation, docs, docker, compose, faq" [menu.main] -parent="smn_workw_compose" -weight=9 +parent="workw_compose" +weight=90 +++ diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index bb3d51e9..1939500c 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -4,8 +4,8 @@ title = "Getting Started" description = "Getting started with Docker Compose" keywords = ["documentation, docs, docker, compose, orchestration, containers"] [menu.main] -parent="smn_workw_compose" -weight=3 +parent="workw_compose" +weight=-85 +++ diff --git a/docs/index.md b/docs/index.md index 9cb594a7..61bc41ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,65 +1,21 @@ -# Overview of Docker Compose +# Docker Compose -Compose is a tool for defining and running multi-container Docker applications. -With Compose, you use a Compose file to configure your application's services. -Then, using a single command, you create and start all the services -from your configuration. To learn more about all the features of Compose -see [the list of features](#features). +Compose is a tool for defining and running multi-container Docker applications. To learn more about Compose refer to the following documentation: -Compose is great for development, testing, and staging environments, as well as -CI workflows. You can learn more about each case in -[Common Use Cases](#common-use-cases). - -Using Compose is basically a three-step process. - -1. Define your app's environment with a `Dockerfile` so it can be -reproduced anywhere. -2. Define the services that make up your app in `docker-compose.yml` so -they can be run together in an isolated environment. -3. Lastly, run `docker-compose up` and Compose will start and run your entire app. - -A `docker-compose.yml` looks like this: - - version: 2 - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - - logvolume01:/var/log - links: - - redis - redis: - image: redis - volumes: - logvolume01: {} - -For more information about the Compose file, see the -[Compose file reference](compose-file.md) - -Compose has commands for managing the whole lifecycle of your application: - - * Start, stop and rebuild services - * View the status of running services - * Stream the log output of running services - * Run a one-off command on a service - -## Compose documentation - -- [Installing Compose](install.md) +- [Compose Overview](overview.md) +- [Install Compose](install.md) - [Getting Started](gettingstarted.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) @@ -68,123 +24,6 @@ Compose has commands for managing the whole lifecycle of your application: - [Command line reference](./reference/index.md) - [Compose file reference](compose-file.md) -## Features - -The features of Compose that make it effective are: - -* [Multiple isolated environments on a single host](#Multiple-isolated-environments-on-a-single-host) -* [Preserve volume data when containers are created](#preserve-volume-data-when-containers-are-created) -* [Only recreate containers that have changed](#only-recreate-containers-that-have-changed) -* [Variables and moving a composition between environments](#variables-and-moving-a-composition-between-environments) - -#### Multiple isolated environments on a single host - -Compose uses a project name to isolate environments from each other. You can use -this project name to: - -* on a dev host, to create multiple copies of a single environment (ex: you want - to run a stable copy for each feature branch of a project) -* on a CI server, to keep builds from interfering with each other, you can set - the project name to a unique build number -* on a shared host or dev host, to prevent different projects which may use the - same service names, from interfering with each other - -The default project name is the basename of the project directory. You can set -a custom project name by using the -[`-p` command line option](./reference/docker-compose.md) or the -[`COMPOSE_PROJECT_NAME` environment variable](./reference/overview.md#compose-project-name). - -#### Preserve volume data when containers are created - -Compose preserves all volumes used by your services. When `docker-compose up` -runs, if it finds any containers from previous runs, it copies the volumes from -the old container to the new container. This process ensures that any data -you've created in volumes isn't lost. - - -#### Only recreate containers that have changed - -Compose caches the configuration used to create a container. When you -restart a service that has not changed, Compose re-uses the existing -containers. Re-using containers means that you can make changes to your -environment very quickly. - - -#### Variables and moving a composition between environments - -Compose supports variables in the Compose file. You can use these variables -to customize your composition for different environments, or different users. -See [Variable substitution](compose-file.md#variable-substitution) for more -details. - -You can extend a Compose file using the `extends` field or by creating multiple -Compose files. See [extends](extends.md) for more details. - - -## Common Use Cases - -Compose can be used in many different ways. Some common use cases are outlined -below. - -### Development environments - -When you're developing software, the ability to run an application in an -isolated environment and interact with it is crucial. The Compose command -line tool can be used to create the environment and interact with it. - -The [Compose file](compose-file.md) provides a way to document and configure -all of the application's service dependencies (databases, queues, caches, -web service APIs, etc). Using the Compose command line tool you can create -and start one or more containers for each dependency with a single command -(`docker-compose up`). - -Together, these features provide a convenient way for developers to get -started on a project. Compose can reduce a multi-page "developer getting -started guide" to a single machine readable Compose file and a few commands. - -### Automated testing environments - -An important part of any Continuous Deployment or Continuous Integration process -is the automated test suite. Automated end-to-end testing requires an -environment in which to run tests. Compose provides a convenient way to create -and destroy isolated testing environments for your test suite. By defining the full -environment in a [Compose file](compose-file.md) you can create and destroy these -environments in just a few commands: - - $ docker-compose up -d - $ ./run_tests - $ docker-compose down - -### Single host deployments - -Compose has traditionally been focused on development and testing workflows, -but with each release we're making progress on more production-oriented features. -You can use Compose to deploy to a remote Docker Engine. The Docker Engine may -be a single instance provisioned with -[Docker Machine](https://docs.docker.com/machine/) or an entire -[Docker Swarm](https://docs.docker.com/swarm/) cluster. - -For details on using production-oriented features, see -[compose in production](production.md) in this documentation. - - -## Release Notes - To see a detailed list of changes for past and current releases of Docker Compose, please refer to the [CHANGELOG](https://github.com/docker/compose/blob/master/CHANGELOG.md). - -## Getting help - -Docker Compose is under active development. If you need help, would like to -contribute, or simply want to talk about the project with like-minded -individuals, we have a number of open channels for communication. - -* To report bugs or file feature requests: please use the [issue tracker on Github](https://github.com/docker/compose/issues). - -* To talk about the project with people in real time: please join the - `#docker-compose` channel on freenode IRC. - -* To contribute code or documentation changes: please submit a [pull request on Github](https://github.com/docker/compose/pulls). - -For more information and resources, please visit the [Getting Help project page](https://docs.docker.com/opensource/get-help/). diff --git a/docs/install.md b/docs/install.md index 417a48c1..3aaca8fa 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,11 +1,11 @@ diff --git a/docs/networking.md b/docs/networking.md index 93533e9d..fb34e3de 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -4,8 +4,7 @@ title = "Networking in Compose" description = "How Compose sets up networking between containers" keywords = ["documentation, docs, docker, compose, orchestration, containers, networking"] [menu.main] -parent="smn_workw_compose" -weight=6 +parent="workw_compose" +++ diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 00000000..19f51ab4 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,191 @@ + + + +# Overview of Docker Compose + +Compose is a tool for defining and running multi-container Docker applications. +With Compose, you use a Compose file to configure your application's services. +Then, using a single command, you create and start all the services +from your configuration. To learn more about all the features of Compose +see [the list of features](#features). + +Compose is great for development, testing, and staging environments, as well as +CI workflows. You can learn more about each case in +[Common Use Cases](#common-use-cases). + +Using Compose is basically a three-step process. + +1. Define your app's environment with a `Dockerfile` so it can be +reproduced anywhere. +2. Define the services that make up your app in `docker-compose.yml` so +they can be run together in an isolated environment. +3. Lastly, run `docker-compose up` and Compose will start and run your entire app. + +A `docker-compose.yml` looks like this: + + version: 2 + services: + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + - logvolume01:/var/log + links: + - redis + redis: + image: redis + volumes: + logvolume01: {} + +For more information about the Compose file, see the +[Compose file reference](compose-file.md) + +Compose has commands for managing the whole lifecycle of your application: + + * Start, stop and rebuild services + * View the status of running services + * Stream the log output of running services + * Run a one-off command on a service + +## Compose documentation + +- [Installing Compose](install.md) +- [Getting Started](gettingstarted.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with WordPress](wordpress.md) +- [Frequently asked questions](faq.md) +- [Command line reference](./reference/index.md) +- [Compose file reference](compose-file.md) + +## Features + +The features of Compose that make it effective are: + +* [Multiple isolated environments on a single host](#Multiple-isolated-environments-on-a-single-host) +* [Preserve volume data when containers are created](#preserve-volume-data-when-containers-are-created) +* [Only recreate containers that have changed](#only-recreate-containers-that-have-changed) +* [Variables and moving a composition between environments](#variables-and-moving-a-composition-between-environments) + +### Multiple isolated environments on a single host + +Compose uses a project name to isolate environments from each other. You can use +this project name to: + +* on a dev host, to create multiple copies of a single environment (ex: you want + to run a stable copy for each feature branch of a project) +* on a CI server, to keep builds from interfering with each other, you can set + the project name to a unique build number +* on a shared host or dev host, to prevent different projects which may use the + same service names, from interfering with each other + +The default project name is the basename of the project directory. You can set +a custom project name by using the +[`-p` command line option](./reference/docker-compose.md) or the +[`COMPOSE_PROJECT_NAME` environment variable](./reference/overview.md#compose-project-name). + +### Preserve volume data when containers are created + +Compose preserves all volumes used by your services. When `docker-compose up` +runs, if it finds any containers from previous runs, it copies the volumes from +the old container to the new container. This process ensures that any data +you've created in volumes isn't lost. + + +### Only recreate containers that have changed + +Compose caches the configuration used to create a container. When you +restart a service that has not changed, Compose re-uses the existing +containers. Re-using containers means that you can make changes to your +environment very quickly. + + +### Variables and moving a composition between environments + +Compose supports variables in the Compose file. You can use these variables +to customize your composition for different environments, or different users. +See [Variable substitution](compose-file.md#variable-substitution) for more +details. + +You can extend a Compose file using the `extends` field or by creating multiple +Compose files. See [extends](extends.md) for more details. + + +## Common Use Cases + +Compose can be used in many different ways. Some common use cases are outlined +below. + +### Development environments + +When you're developing software, the ability to run an application in an +isolated environment and interact with it is crucial. The Compose command +line tool can be used to create the environment and interact with it. + +The [Compose file](compose-file.md) provides a way to document and configure +all of the application's service dependencies (databases, queues, caches, +web service APIs, etc). Using the Compose command line tool you can create +and start one or more containers for each dependency with a single command +(`docker-compose up`). + +Together, these features provide a convenient way for developers to get +started on a project. Compose can reduce a multi-page "developer getting +started guide" to a single machine readable Compose file and a few commands. + +### Automated testing environments + +An important part of any Continuous Deployment or Continuous Integration process +is the automated test suite. Automated end-to-end testing requires an +environment in which to run tests. Compose provides a convenient way to create +and destroy isolated testing environments for your test suite. By defining the full +environment in a [Compose file](compose-file.md) you can create and destroy these +environments in just a few commands: + + $ docker-compose up -d + $ ./run_tests + $ docker-compose down + +### Single host deployments + +Compose has traditionally been focused on development and testing workflows, +but with each release we're making progress on more production-oriented features. +You can use Compose to deploy to a remote Docker Engine. The Docker Engine may +be a single instance provisioned with +[Docker Machine](https://docs.docker.com/machine/) or an entire +[Docker Swarm](https://docs.docker.com/swarm/) cluster. + +For details on using production-oriented features, see +[compose in production](production.md) in this documentation. + + +## Release Notes + +To see a detailed list of changes for past and current releases of Docker +Compose, please refer to the +[CHANGELOG](https://github.com/docker/compose/blob/master/CHANGELOG.md). + +## Getting help + +Docker Compose is under active development. If you need help, would like to +contribute, or simply want to talk about the project with like-minded +individuals, we have a number of open channels for communication. + +* To report bugs or file feature requests: please use the [issue tracker on Github](https://github.com/docker/compose/issues). + +* To talk about the project with people in real time: please join the + `#docker-compose` channel on freenode IRC. + +* To contribute code or documentation changes: please submit a [pull request on Github](https://github.com/docker/compose/pulls). + +For more information and resources, please visit the [Getting Help project page](https://docs.docker.com/opensource/get-help/). diff --git a/docs/production.md b/docs/production.md index 46e221bb..d51ca549 100644 --- a/docs/production.md +++ b/docs/production.md @@ -4,7 +4,7 @@ title = "Using Compose in production" description = "Guide to using Docker Compose in production" keywords = ["documentation, docs, docker, compose, orchestration, containers, production"] [menu.main] -parent="smn_workw_compose" +parent="workw_compose" weight=1 +++ diff --git a/docs/rails.md b/docs/rails.md index e3daff25..f7634a6d 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -1,15 +1,15 @@ -## Quickstart Guide: Compose and Rails +## Quickstart: Compose and Rails This Quickstart guide will show you how to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). diff --git a/docs/reference/index.md b/docs/reference/index.md index 5406b9c7..d1bd61c2 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,15 +1,16 @@ -## Compose CLI reference +## Compose command-line reference The following pages describe the usage information for the [docker-compose](docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. diff --git a/docs/wordpress.md b/docs/wordpress.md index 84010491..50362253 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -1,16 +1,16 @@ -# Quickstart Guide: Compose and WordPress +# Quickstart: Compose and WordPress You can use Compose to easily run WordPress in an isolated environment built with Docker containers. From b4868d02593925f3d179f6f17c5e6e25fe6d6ef0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 12:50:52 -0500 Subject: [PATCH 068/580] Fix race condition with up and setting signal handlers. Also print stdout on wait_for_container(). Signed-off-by: Daniel Nephin --- compose/cli/main.py | 36 +++++++++++-------- tests/acceptance/cli_test.py | 15 ++++++-- .../sleeps-composefile/docker-compose.yml | 10 ++++++ tests/unit/cli/main_test.py | 18 ---------- 4 files changed, 44 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/sleeps-composefile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 14febe25..93c4729f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import print_function from __future__ import unicode_literals +import contextlib import json import logging import re @@ -53,7 +54,7 @@ def main(): command = TopLevelCommand() command.sys_dispatch() except KeyboardInterrupt: - log.error("\nAborting.") + log.error("Aborting.") sys.exit(1) except (UserError, NoSuchService, ConfigurationError) as e: log.error(e.msg) @@ -629,18 +630,20 @@ class TopLevelCommand(DocoptCommand): if detached and cascade_stop: raise UserError("--abort-on-container-exit and -d cannot be combined.") - to_attach = project.up( - service_names=service_names, - start_deps=start_deps, - strategy=convergence_strategy_from_opts(options), - do_build=not options['--no-build'], - timeout=timeout, - detached=detached - ) + with up_shutdown_context(project, service_names, timeout, detached): + to_attach = project.up( + service_names=service_names, + start_deps=start_deps, + strategy=convergence_strategy_from_opts(options), + do_build=not options['--no-build'], + timeout=timeout, + detached=detached) - if not detached: + if detached: + return log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop) - attach_to_logs(project, log_printer, service_names, timeout) + print("Attaching to", list_containers(log_printer.containers)) + log_printer.run() def version(self, project, options): """ @@ -740,13 +743,16 @@ def build_log_printer(containers, service_names, monochrome, cascade_stop): return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop) -def attach_to_logs(project, log_printer, service_names, timeout): - print("Attaching to", list_containers(log_printer.containers)) - signals.set_signal_handler_to_shutdown() +@contextlib.contextmanager +def up_shutdown_context(project, service_names, timeout, detached): + if detached: + yield + return + signals.set_signal_handler_to_shutdown() try: try: - log_printer.run() + yield except signals.ShutdownException: print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 30589bad..b69ce8aa 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -43,7 +43,8 @@ def start_process(base_dir, options): def wait_on_process(proc, returncode=0): stdout, stderr = proc.communicate() if proc.returncode != returncode: - print(stderr.decode('utf-8')) + print("Stderr: {}".format(stderr)) + print("Stdout: {}".format(stdout)) assert proc.returncode == returncode return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) @@ -81,7 +82,6 @@ class ContainerStateCondition(object): self.name = name self.running = running - # State.Running == true def __call__(self): try: container = self.client.inspect_container(self.name) @@ -707,6 +707,17 @@ class CLITestCase(DockerClientTestCase): os.kill(proc.pid, signal.SIGTERM) wait_on_condition(ContainerCountCondition(self.project, 0)) + @v2_only() + def test_up_handles_force_shutdown(self): + self.base_dir = 'tests/fixtures/sleeps-composefile' + proc = start_process(self.base_dir, ['up', '-t', '200']) + wait_on_condition(ContainerCountCondition(self.project, 2)) + + os.kill(proc.pid, signal.SIGTERM) + time.sleep(0.1) + os.kill(proc.pid, signal.SIGTERM) + wait_on_condition(ContainerCountCondition(self.project, 0)) + def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', 'console', '/bin/true']) diff --git a/tests/fixtures/sleeps-composefile/docker-compose.yml b/tests/fixtures/sleeps-composefile/docker-compose.yml new file mode 100644 index 00000000..1eff7b73 --- /dev/null +++ b/tests/fixtures/sleeps-composefile/docker-compose.yml @@ -0,0 +1,10 @@ + +version: 2 + +services: + simple: + image: busybox:latest + command: sleep 200 + another: + image: busybox:latest + command: sleep 200 diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 6f5dd3ca..fd6c5002 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -6,12 +6,9 @@ import logging from compose import container from compose.cli.errors import UserError from compose.cli.formatter import ConsoleWarningFormatter -from compose.cli.log_printer import LogPrinter -from compose.cli.main import attach_to_logs from compose.cli.main import build_log_printer from compose.cli.main import convergence_strategy_from_opts from compose.cli.main import setup_console_handler -from compose.project import Project from compose.service import ConvergenceStrategy from tests import mock from tests import unittest @@ -49,21 +46,6 @@ class CLIMainTestCase(unittest.TestCase): log_printer = build_log_printer(containers, service_names, True, False) self.assertEqual(log_printer.containers, containers) - def test_attach_to_logs(self): - project = mock.create_autospec(Project) - log_printer = mock.create_autospec(LogPrinter, containers=[]) - service_names = ['web', 'db'] - timeout = 12 - - with mock.patch('compose.cli.main.signals.signal', autospec=True) as mock_signal: - attach_to_logs(project, log_printer, service_names, timeout) - - assert mock_signal.signal.mock_calls == [ - mock.call(mock_signal.SIGINT, mock.ANY), - mock.call(mock_signal.SIGTERM, mock.ANY), - ] - log_printer.run.assert_called_once_with() - class SetupConsoleHandlerTestCase(unittest.TestCase): From 34ccb90d7e12965b2a2e531ad633ac0afbdee4f1 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Fri, 29 Jan 2016 14:15:38 +0200 Subject: [PATCH 069/580] Falling back to default project name when COMPOSE_PROJECT_NAME is set to empty Signed-off-by: Dimitar Bonev --- compose/cli/command.py | 2 +- tests/unit/cli_test.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index c1681ffc..2a0d8698 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -92,7 +92,7 @@ def get_project_name(working_dir, project_name=None): return re.sub(r'[^a-z0-9]', '', name.lower()) project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') - if project_name is not None: + if project_name: return normalize_name(project_name) project = os.path.basename(os.path.abspath(working_dir)) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fd52a3c1..fec7cdba 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -49,6 +49,13 @@ class CLITestCase(unittest.TestCase): project_name = get_project_name(None) self.assertEquals(project_name, name) + def test_project_name_with_empty_environment_var(self): + base_dir = 'tests/fixtures/simple-composefile' + with mock.patch.dict(os.environ): + os.environ['COMPOSE_PROJECT_NAME'] = '' + project_name = get_project_name(base_dir) + self.assertEquals('simplecomposefile', project_name) + def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir) From 0b7877d82a62fdbddec27dcb030a4af54558850f Mon Sep 17 00:00:00 2001 From: Mustafa Ulu Date: Mon, 1 Feb 2016 00:09:44 +0200 Subject: [PATCH 070/580] Update link to "Common Use Cases" title It is now under overview.md Signed-off-by: Mustafa Ulu --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b60a7eee..595ff1e1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ see [the list of features](docs/index.md#features). Compose is great for development, testing, and staging environments, as well as CI workflows. You can learn more about each case in -[Common Use Cases](docs/index.md#common-use-cases). +[Common Use Cases](docs/overview.md#common-use-cases). Using Compose is basically a three-step process. From 6928c24323af38fcce54078e51272be0951ca350 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 16:30:39 -0500 Subject: [PATCH 071/580] Deploying to bintray from appveyor using the new bintray support. Signed-off-by: Daniel Nephin --- appveyor.yml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index b162db1e..489be021 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -9,20 +9,16 @@ install: # Build the binary after tests build: false -environment: - BINTRAY_USER: "docker-compose-roleuser" - BINTRAY_PATH: "docker-compose/master/windows/master/docker-compose-Windows-x86_64.exe" - test_script: - "tox -e py27,py34 -- tests/unit" - ps: ".\\script\\build-windows.ps1" -deploy_script: - - "curl -sS - -u \"%BINTRAY_USER%:%BINTRAY_API_KEY%\" - -X PUT \"https://api.bintray.com/content/%BINTRAY_PATH%?override=1&publish=1\" - --data-binary @dist\\docker-compose-Windows-x86_64.exe" - artifacts: - path: .\dist\docker-compose-Windows-x86_64.exe name: "Compose Windows binary" + +deploy: + - provider: Environment + name: master-builds + on: + branch: master From e8756905ba41b53ad92cd9dea8f64853e1c5ac77 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Feb 2016 13:31:28 +0000 Subject: [PATCH 072/580] Run test containers in TTY mode Signed-off-by: Aanand Prasad --- script/test-versions | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/test-versions b/script/test-versions index 2e9c9167..14a3e6e4 100755 --- a/script/test-versions +++ b/script/test-versions @@ -6,6 +6,7 @@ set -e >&2 echo "Running lint checks" docker run --rm \ + --tty \ ${GIT_VOLUME} \ --entrypoint="tox" \ "$TAG" -e pre-commit @@ -51,6 +52,7 @@ for version in $DOCKER_VERSIONS; do docker run \ --rm \ + --tty \ --link="$daemon_container:docker" \ --env="DOCKER_HOST=tcp://docker:2375" \ --env="DOCKER_VERSION=$version" \ From d40bc6e4a04f9ba39d8c9d167357b0358ab09469 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 Jan 2016 14:44:51 +0000 Subject: [PATCH 073/580] Convert validation error tests to pytest style Signed-off-by: Aanand Prasad --- tests/unit/config/config_test.py | 139 +++++++++++++++++-------------- 1 file changed, 76 insertions(+), 63 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 5f8b097b..87f10afb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -274,9 +274,7 @@ class ConfigTest(unittest.TestCase): assert error_msg in exc.exconly() def test_config_integer_service_name_raise_validation_error(self): - expected_error_msg = ("In file 'filename.yml' service name: 1 needs to " - "be a string, eg '1'") - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( {1: {'image': 'busybox'}}, @@ -285,11 +283,11 @@ class ConfigTest(unittest.TestCase): ) ) - def test_config_integer_service_name_raise_validation_error_v2(self): - expected_error_msg = ("In file 'filename.yml' service name: 1 needs to " - "be a string, eg '1'") + assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \ + in excinfo.exconly() - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + def test_config_integer_service_name_raise_validation_error_v2(self): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -301,6 +299,9 @@ class ConfigTest(unittest.TestCase): ) ) + assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \ + in excinfo.exconly() + def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( 'base.yaml', @@ -624,8 +625,7 @@ class ConfigTest(unittest.TestCase): assert services[0]['name'] == valid_name def test_config_hint(self): - expected_error_msg = "(did you mean 'privileged'?)" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -636,6 +636,8 @@ class ConfigTest(unittest.TestCase): ) ) + assert "(did you mean 'privileged'?)" in excinfo.exconly() + def test_load_errors_on_uppercase_with_no_image(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details({ @@ -643,9 +645,8 @@ class ConfigTest(unittest.TestCase): }, 'tests/fixtures/build-ctx')) assert "Service 'Foo' contains uppercase characters" in exc.exconly() - def test_invalid_config_build_and_image_specified(self): - expected_error_msg = "Service 'foo' has both an image and build path specified." - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + def test_invalid_config_build_and_image_specified_v1(self): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -656,9 +657,10 @@ class ConfigTest(unittest.TestCase): ) ) + assert "Service 'foo' has both an image and build path specified." in excinfo.exconly() + def test_invalid_config_type_should_be_an_array(self): - expected_error_msg = "Service 'foo' configuration key 'links' contains an invalid type, it should be an array" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -669,10 +671,11 @@ class ConfigTest(unittest.TestCase): ) ) + assert "Service 'foo' configuration key 'links' contains an invalid type, it should be an array" \ + in excinfo.exconly() + def test_invalid_config_not_a_dictionary(self): - expected_error_msg = ("Top level object in 'filename.yml' needs to be " - "an object.") - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( ['foo', 'lol'], @@ -681,9 +684,11 @@ class ConfigTest(unittest.TestCase): ) ) + assert "Top level object in 'filename.yml' needs to be an object" \ + in excinfo.exconly() + def test_invalid_config_not_unique_items(self): - expected_error_msg = "has non-unique elements" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -694,10 +699,10 @@ class ConfigTest(unittest.TestCase): ) ) + assert "has non-unique elements" in excinfo.exconly() + def test_invalid_list_of_strings_format(self): - expected_error_msg = "Service 'web' configuration key 'command' contains 1" - expected_error_msg += ", which is an invalid type, it should be a string" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -708,7 +713,10 @@ class ConfigTest(unittest.TestCase): ) ) - def test_load_config_dockerfile_without_build_raises_error(self): + assert "Service 'web' configuration key 'command' contains 1, which is an invalid type, it should be a string" \ + in excinfo.exconly() + + def test_load_config_dockerfile_without_build_raises_error_v1(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details({ 'web': { @@ -716,12 +724,11 @@ class ConfigTest(unittest.TestCase): 'dockerfile': 'Dockerfile.alt' } })) + assert "Service 'web' has both an image and alternate Dockerfile." in exc.exconly() def test_config_extra_hosts_string_raises_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type" - - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( {'web': { @@ -733,12 +740,11 @@ class ConfigTest(unittest.TestCase): ) ) - def test_config_extra_hosts_list_of_dicts_validation_error(self): - expected_error_msg = ( - "key 'extra_hosts' contains {\"somehost\": \"162.242.195.82\"}, " - "which is an invalid type, it should be a string") + assert "Service 'web' configuration key 'extra_hosts' contains an invalid type" \ + in excinfo.exconly() - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + def test_config_extra_hosts_list_of_dicts_validation_error(self): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( {'web': { @@ -753,10 +759,11 @@ class ConfigTest(unittest.TestCase): ) ) - def test_config_ulimits_invalid_keys_validation_error(self): - expected = ("Service 'web' configuration key 'ulimits' 'nofile' contains " - "unsupported option: 'not_soft_or_hard'") + assert "key 'extra_hosts' contains {\"somehost\": \"162.242.195.82\"}, " \ + "which is an invalid type, it should be a string" \ + in excinfo.exconly() + def test_config_ulimits_invalid_keys_validation_error(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( { @@ -773,10 +780,11 @@ class ConfigTest(unittest.TestCase): }, 'working_dir', 'filename.yml')) - assert expected in exc.exconly() + + assert "Service 'web' configuration key 'ulimits' 'nofile' contains unsupported option: 'not_soft_or_hard'" \ + in exc.exconly() def test_config_ulimits_required_keys_validation_error(self): - with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( { @@ -1574,11 +1582,7 @@ class MemoryOptionsTest(unittest.TestCase): When you set a 'memswap_limit' it is invalid config unless you also set a mem_limit """ - expected_error_msg = ( - "Service 'foo' configuration key 'memswap_limit' is invalid: when " - "defining 'memswap_limit' you must set 'mem_limit' as well" - ) - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -1589,6 +1593,10 @@ class MemoryOptionsTest(unittest.TestCase): ) ) + assert "Service 'foo' configuration key 'memswap_limit' is invalid: when defining " \ + "'memswap_limit' you must set 'mem_limit' as well" \ + in excinfo.exconly() + def test_validation_with_correct_memswap_values(self): service_dict = config.load( build_config_details( @@ -1851,7 +1859,7 @@ class ExtendsTest(unittest.TestCase): self.assertEqual(path, expected) def test_extends_validation_empty_dictionary(self): - with self.assertRaisesRegexp(ConfigurationError, 'service'): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -1862,8 +1870,10 @@ class ExtendsTest(unittest.TestCase): ) ) + assert 'service' in excinfo.exconly() + def test_extends_validation_missing_service_key(self): - with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -1874,12 +1884,10 @@ class ExtendsTest(unittest.TestCase): ) ) + assert "'service' is a required property" in excinfo.exconly() + def test_extends_validation_invalid_key(self): - expected_error_msg = ( - "Service 'web' configuration key 'extends' " - "contains unsupported option: 'rogue_key'" - ) - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -1897,12 +1905,11 @@ class ExtendsTest(unittest.TestCase): ) ) + assert "Service 'web' configuration key 'extends' contains unsupported option: 'rogue_key'" \ + in excinfo.exconly() + def test_extends_validation_sub_property_key(self): - expected_error_msg = ( - "Service 'web' configuration key 'extends' 'file' contains 1, " - "which is an invalid type, it should be a string" - ) - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( { @@ -1919,13 +1926,16 @@ class ExtendsTest(unittest.TestCase): ) ) + assert "Service 'web' configuration key 'extends' 'file' contains 1, which is an invalid type, it should be a string" \ + in excinfo.exconly() + def test_extends_validation_no_file_key_no_filename_set(self): dictionary = {'extends': {'service': 'web'}} - def load_config(): - return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') + with pytest.raises(ConfigurationError) as excinfo: + make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') - self.assertRaisesRegexp(ConfigurationError, 'file', load_config) + assert 'file' in excinfo.exconly() def test_extends_validation_valid_config(self): service = config.load( @@ -1979,16 +1989,17 @@ class ExtendsTest(unittest.TestCase): ])) def test_invalid_links_in_extended_service(self): - expected_error_msg = "services with 'links' cannot be extended" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + with pytest.raises(ConfigurationError) as excinfo: load_from_filename('tests/fixtures/extends/invalid-links.yml') - def test_invalid_volumes_from_in_extended_service(self): - expected_error_msg = "services with 'volumes_from' cannot be extended" + assert "services with 'links' cannot be extended" in excinfo.exconly() - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + def test_invalid_volumes_from_in_extended_service(self): + with pytest.raises(ConfigurationError) as excinfo: load_from_filename('tests/fixtures/extends/invalid-volumes.yml') + assert "services with 'volumes_from' cannot be extended" in excinfo.exconly() + def test_invalid_net_in_extended_service(self): with pytest.raises(ConfigurationError) as excinfo: load_from_filename('tests/fixtures/extends/invalid-net-v2.yml') @@ -2044,10 +2055,12 @@ class ExtendsTest(unittest.TestCase): ]) def test_load_throws_error_when_base_service_does_not_exist(self): - err_msg = r'''Cannot extend service 'foo' in .*: Service not found''' - with self.assertRaisesRegexp(ConfigurationError, err_msg): + with pytest.raises(ConfigurationError) as excinfo: load_from_filename('tests/fixtures/extends/nonexistent-service.yml') + assert "Cannot extend service 'foo'" in excinfo.exconly() + assert "Service not found" in excinfo.exconly() + def test_partial_service_config_in_extends_is_still_valid(self): dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml') self.assertEqual(dicts[0]['environment'], {'FOO': '1'}) From 4ac004059a59c0ffb4e80dec8295d507ab54ef2d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 Jan 2016 15:20:46 +0000 Subject: [PATCH 074/580] Normalise/fix config field designators in validation messages - Instead of "Service 'web' configuration key 'image'", just say "web.image" - Fix the "Service 'services'" bug in the v2 file format Signed-off-by: Aanand Prasad --- compose/config/validation.py | 96 +++++++++++++++++--------------- tests/unit/config/config_test.py | 91 +++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 66 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 05982020..ba1ac521 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -174,8 +174,8 @@ def validate_depends_on(service_config, service_names): "undefined.".format(s=service_config, dep=dependency)) -def get_unsupported_config_msg(service_name, error_key): - msg = "Unsupported config option for '{}' service: '{}'".format(service_name, error_key) +def get_unsupported_config_msg(path, error_key): + msg = "Unsupported config option for {}: '{}'".format(path_string(path), error_key) if error_key in DOCKER_CONFIG_HINTS: msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key]) return msg @@ -191,7 +191,7 @@ def is_service_dict_schema(schema_id): return schema_id == 'fields_schema_v1.json' or schema_id == '#/properties/services' -def handle_error_for_schema_with_id(error, service_name): +def handle_error_for_schema_with_id(error, path): schema_id = error.schema['id'] if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties': @@ -215,62 +215,64 @@ def handle_error_for_schema_with_id(error, service_name): # TODO: only applies to v1 if 'image' in error.instance and context: return ( - "Service '{}' has both an image and build path specified. " + "{} has both an image and build path specified. " "A service can either be built to image or use an existing " - "image, not both.".format(service_name)) + "image, not both.".format(path_string(path))) if 'image' not in error.instance and not context: return ( - "Service '{}' has neither an image nor a build path " - "specified. At least one must be provided.".format(service_name)) + "{} has neither an image nor a build path specified. " + "At least one must be provided.".format(path_string(path))) # TODO: only applies to v1 if 'image' in error.instance and dockerfile: return ( - "Service '{}' has both an image and alternate Dockerfile. " + "{} has both an image and alternate Dockerfile. " "A service can either be built to image or use an existing " - "image, not both.".format(service_name)) + "image, not both.".format(path_string(path))) if schema_id == '#/definitions/service': if error.validator == 'additionalProperties': invalid_config_key = parse_key_from_error_msg(error) - return get_unsupported_config_msg(service_name, invalid_config_key) + return get_unsupported_config_msg(path, invalid_config_key) -def handle_generic_service_error(error, service_name): - config_key = " ".join("'%s'" % k for k in error.path) +def handle_generic_service_error(error, path): msg_format = None error_msg = error.message if error.validator == 'oneOf': - msg_format = "Service '{}' configuration key {} {}" - error_msg = _parse_oneof_validator(error) + msg_format = "{path} {msg}" + config_key, error_msg = _parse_oneof_validator(error) + if config_key: + path.append(config_key) elif error.validator == 'type': - msg_format = ("Service '{}' configuration key {} contains an invalid " - "type, it should be {}") + msg_format = "{path} contains an invalid type, it should be {msg}" error_msg = _parse_valid_types_from_validator(error.validator_value) # TODO: no test case for this branch, there are no config options # which exercise this branch elif error.validator == 'required': - msg_format = "Service '{}' configuration key '{}' is invalid, {}" + msg_format = "{path} is invalid, {msg}" elif error.validator == 'dependencies': - msg_format = "Service '{}' configuration key '{}' is invalid: {}" config_key = list(error.validator_value.keys())[0] required_keys = ",".join(error.validator_value[config_key]) + + msg_format = "{path} is invalid: {msg}" + path.append(config_key) error_msg = "when defining '{}' you must set '{}' as well".format( config_key, required_keys) elif error.cause: error_msg = six.text_type(error.cause) - msg_format = "Service '{}' configuration key {} is invalid: {}" + msg_format = "{path} is invalid: {msg}" elif error.path: - msg_format = "Service '{}' configuration key {} value {}" + msg_format = "{path} value {msg}" if msg_format: - return msg_format.format(service_name, config_key, error_msg) + return msg_format.format(path=path_string(path), msg=error_msg) return error.message @@ -279,6 +281,10 @@ def parse_key_from_error_msg(error): return error.message.split("'")[1] +def path_string(path): + return ".".join(c for c in path if isinstance(c, six.string_types)) + + def _parse_valid_types_from_validator(validator): """A validator value can be either an array of valid types or a string of a valid type. Parse the valid types and prefix with the correct article. @@ -304,52 +310,52 @@ def _parse_oneof_validator(error): for context in error.context: if context.validator == 'required': - return context.message + return (None, context.message) if context.validator == 'additionalProperties': invalid_config_key = parse_key_from_error_msg(context) - return "contains unsupported option: '{}'".format(invalid_config_key) + return (None, "contains unsupported option: '{}'".format(invalid_config_key)) if context.path: - invalid_config_key = " ".join( - "'{}' ".format(fragment) for fragment in context.path - if isinstance(fragment, six.string_types) + return ( + path_string(context.path), + "contains {}, which is an invalid type, it should be {}".format( + json.dumps(context.instance), + _parse_valid_types_from_validator(context.validator_value)), ) - return "{}contains {}, which is an invalid type, it should be {}".format( - invalid_config_key, - # Always print the json repr of the invalid value - json.dumps(context.instance), - _parse_valid_types_from_validator(context.validator_value)) if context.validator == 'uniqueItems': - return "contains non unique items, please remove duplicates from {}".format( - context.instance) + return ( + None, + "contains non unique items, please remove duplicates from {}".format( + context.instance), + ) if context.validator == 'type': types.append(context.validator_value) valid_types = _parse_valid_types_from_validator(types) - return "contains an invalid type, it should be {}".format(valid_types) + return (None, "contains an invalid type, it should be {}".format(valid_types)) -def process_errors(errors, service_name=None): +def process_errors(errors, path_prefix=None): """jsonschema gives us an error tree full of information to explain what has gone wrong. Process each error and pull out relevant information and re-write helpful error messages that are relevant. """ - def format_error_message(error, service_name): - if not service_name and error.path: - # field_schema errors will have service name on the path - service_name = error.path.popleft() + path_prefix = path_prefix or [] + + def format_error_message(error): + path = path_prefix + list(error.path) if 'id' in error.schema: - error_msg = handle_error_for_schema_with_id(error, service_name) + error_msg = handle_error_for_schema_with_id(error, path) if error_msg: return error_msg - return handle_generic_service_error(error, service_name) + return handle_generic_service_error(error, path) - return '\n'.join(format_error_message(error, service_name) for error in errors) + return '\n'.join(format_error_message(error) for error in errors) def validate_against_fields_schema(config_file): @@ -366,14 +372,14 @@ def validate_against_service_schema(config, service_name, version): config, "service_schema_v{0}.json".format(version), format_checker=["ports"], - service_name=service_name) + path_prefix=[service_name]) def _validate_against_schema( config, schema_filename, format_checker=(), - service_name=None, + path_prefix=None, filename=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) @@ -399,7 +405,7 @@ def _validate_against_schema( if not errors: return - error_msg = process_errors(errors, service_name) + error_msg = process_errors(errors, path_prefix=path_prefix) file_msg = " in file '{}'".format(filename) if filename else '' raise ConfigurationError("Validation failed{}, reason(s):\n{}".format( file_msg, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 87f10afb..44f5c684 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -253,15 +253,31 @@ class ConfigTest(unittest.TestCase): assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() def test_load_with_invalid_field_name(self): - config_details = build_config_details( - {'web': {'image': 'busybox', 'name': 'bogus'}}, - 'working_dir', - 'filename.yml') with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - error_msg = "Unsupported config option for 'web' service: 'name'" - assert error_msg in exc.exconly() - assert "Validation failed in file 'filename.yml'" in exc.exconly() + config.load(build_config_details( + { + 'version': 2, + 'services': { + 'web': {'image': 'busybox', 'name': 'bogus'}, + } + }, + 'working_dir', + 'filename.yml', + )) + + assert "Unsupported config option for services.web: 'name'" in exc.exconly() + + def test_load_with_invalid_field_name_v1(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': {'image': 'busybox', 'name': 'bogus'}, + }, + 'working_dir', + 'filename.yml', + )) + + assert "Unsupported config option for web: 'name'" in exc.exconly() def test_load_invalid_service_definition(self): config_details = build_config_details( @@ -645,6 +661,39 @@ class ConfigTest(unittest.TestCase): }, 'tests/fixtures/build-ctx')) assert "Service 'Foo' contains uppercase characters" in exc.exconly() + def test_invalid_config_v1(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'foo': {'image': 1}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + + assert "foo.image contains an invalid type, it should be a string" \ + in excinfo.exconly() + + def test_invalid_config_v2(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': 2, + 'services': { + 'foo': {'image': 1}, + }, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + + assert "services.foo.image contains an invalid type, it should be a string" \ + in excinfo.exconly() + def test_invalid_config_build_and_image_specified_v1(self): with pytest.raises(ConfigurationError) as excinfo: config.load( @@ -657,7 +706,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "Service 'foo' has both an image and build path specified." in excinfo.exconly() + assert "foo has both an image and build path specified." in excinfo.exconly() def test_invalid_config_type_should_be_an_array(self): with pytest.raises(ConfigurationError) as excinfo: @@ -671,7 +720,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "Service 'foo' configuration key 'links' contains an invalid type, it should be an array" \ + assert "foo.links contains an invalid type, it should be an array" \ in excinfo.exconly() def test_invalid_config_not_a_dictionary(self): @@ -713,7 +762,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "Service 'web' configuration key 'command' contains 1, which is an invalid type, it should be a string" \ + assert "web.command contains 1, which is an invalid type, it should be a string" \ in excinfo.exconly() def test_load_config_dockerfile_without_build_raises_error_v1(self): @@ -725,7 +774,7 @@ class ConfigTest(unittest.TestCase): } })) - assert "Service 'web' has both an image and alternate Dockerfile." in exc.exconly() + assert "web has both an image and alternate Dockerfile." in exc.exconly() def test_config_extra_hosts_string_raises_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: @@ -740,7 +789,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "Service 'web' configuration key 'extra_hosts' contains an invalid type" \ + assert "web.extra_hosts contains an invalid type" \ in excinfo.exconly() def test_config_extra_hosts_list_of_dicts_validation_error(self): @@ -759,7 +808,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "key 'extra_hosts' contains {\"somehost\": \"162.242.195.82\"}, " \ + assert "web.extra_hosts contains {\"somehost\": \"162.242.195.82\"}, " \ "which is an invalid type, it should be a string" \ in excinfo.exconly() @@ -781,7 +830,7 @@ class ConfigTest(unittest.TestCase): 'working_dir', 'filename.yml')) - assert "Service 'web' configuration key 'ulimits' 'nofile' contains unsupported option: 'not_soft_or_hard'" \ + assert "web.ulimits.nofile contains unsupported option: 'not_soft_or_hard'" \ in exc.exconly() def test_config_ulimits_required_keys_validation_error(self): @@ -795,7 +844,7 @@ class ConfigTest(unittest.TestCase): }, 'working_dir', 'filename.yml')) - assert "Service 'web' configuration key 'ulimits' 'nofile'" in exc.exconly() + assert "web.ulimits.nofile" in exc.exconly() assert "'hard' is a required property" in exc.exconly() def test_config_ulimits_soft_greater_than_hard_error(self): @@ -896,7 +945,7 @@ class ConfigTest(unittest.TestCase): 'extra_hosts': "www.example.com: 192.168.0.17", } })) - assert "'extra_hosts' contains an invalid type" in exc.exconly() + assert "web.extra_hosts contains an invalid type" in exc.exconly() def test_validate_extra_hosts_invalid_list(self): with pytest.raises(ConfigurationError) as exc: @@ -1593,7 +1642,7 @@ class MemoryOptionsTest(unittest.TestCase): ) ) - assert "Service 'foo' configuration key 'memswap_limit' is invalid: when defining " \ + assert "foo.memswap_limit is invalid: when defining " \ "'memswap_limit' you must set 'mem_limit' as well" \ in excinfo.exconly() @@ -1905,7 +1954,7 @@ class ExtendsTest(unittest.TestCase): ) ) - assert "Service 'web' configuration key 'extends' contains unsupported option: 'rogue_key'" \ + assert "web.extends contains unsupported option: 'rogue_key'" \ in excinfo.exconly() def test_extends_validation_sub_property_key(self): @@ -1926,7 +1975,7 @@ class ExtendsTest(unittest.TestCase): ) ) - assert "Service 'web' configuration key 'extends' 'file' contains 1, which is an invalid type, it should be a string" \ + assert "web.extends.file contains 1, which is an invalid type, it should be a string" \ in excinfo.exconly() def test_extends_validation_no_file_key_no_filename_set(self): @@ -1956,7 +2005,7 @@ class ExtendsTest(unittest.TestCase): with pytest.raises(ConfigurationError) as exc: load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') assert ( - "Service 'myweb' has neither an image nor a build path specified" in + "myweb has neither an image nor a build path specified" in exc.exconly() ) From a8de582425f0ab3b9ec2a36ef20300dbabc05052 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 Jan 2016 16:26:25 +0000 Subject: [PATCH 075/580] Remove redundant check - self.config should never be None Signed-off-by: Aanand Prasad --- compose/config/config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ffd805ad..34168df5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -129,8 +129,6 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): @cached_property def version(self): - if self.config is None: - return 1 version = self.config.get('version', 1) if isinstance(version, dict): log.warn("Unexpected type for field 'version', in file {} assuming " From aeef61fcd8b9fc23f62238e9839a72d280ac2738 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jan 2016 15:58:38 +0000 Subject: [PATCH 076/580] Make 'version' a string Signed-off-by: Aanand Prasad --- compose/config/config.py | 33 +++--- ...schema_v2.json => fields_schema_v2.0.json} | 6 +- ...chema_v2.json => service_schema_v2.0.json} | 2 +- compose/config/types.py | 3 +- compose/const.py | 9 +- compose/project.py | 5 +- docker-compose.spec | 8 +- tests/acceptance/cli_test.py | 2 +- tests/fixtures/extends/invalid-net-v2.yml | 2 +- .../logging-composefile/docker-compose.yml | 2 +- tests/fixtures/net-container/v2-invalid.yml | 2 +- tests/fixtures/networks/bridge.yml | 2 +- .../networks/default-network-config.yml | 2 +- tests/fixtures/networks/docker-compose.yml | 2 +- tests/fixtures/networks/external-default.yml | 2 +- tests/fixtures/networks/external-networks.yml | 2 +- tests/fixtures/networks/missing-network.yml | 2 +- tests/fixtures/networks/network-mode.yml | 2 +- tests/fixtures/no-services/docker-compose.yml | 2 +- .../sleeps-composefile/docker-compose.yml | 2 +- tests/fixtures/v2-full/docker-compose.yml | 2 +- tests/fixtures/v2-simple/docker-compose.yml | 2 +- tests/fixtures/v2-simple/links-invalid.yml | 2 +- tests/integration/project_test.py | 29 ++--- tests/integration/testcases.py | 6 +- tests/unit/config/config_test.py | 109 +++++++++++++----- tests/unit/config/types_test.py | 16 +-- 27 files changed, 160 insertions(+), 98 deletions(-) rename compose/config/{fields_schema_v2.json => fields_schema_v2.0.json} (94%) rename compose/config/{service_schema_v2.json => service_schema_v2.0.json} (99%) diff --git a/compose/config/config.py b/compose/config/config.py index 34168df5..bda086b9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,6 +14,8 @@ import six import yaml from cached_property import cached_property +from ..const import COMPOSEFILE_V1 as V1 +from ..const import COMPOSEFILE_V2_0 as V2_0 from ..const import COMPOSEFILE_VERSIONS from .errors import CircularReference from .errors import ComposeFileNotFound @@ -129,25 +131,34 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): @cached_property def version(self): - version = self.config.get('version', 1) + version = self.config.get('version', V1) + if isinstance(version, dict): log.warn("Unexpected type for field 'version', in file {} assuming " "version is the name of a service, and defaulting to " "Compose file version 1".format(self.filename)) - return 1 + return V1 + + if version == '2': + version = V2_0 + + if version not in COMPOSEFILE_VERSIONS: + raise ConfigurationError( + 'Invalid Compose file version: {0}'.format(version)) + return version def get_service(self, name): return self.get_service_dicts()[name] def get_service_dicts(self): - return self.config if self.version == 1 else self.config.get('services', {}) + return self.config if self.version == V1 else self.config.get('services', {}) def get_volumes(self): - return {} if self.version == 1 else self.config.get('volumes', {}) + return {} if self.version == V1 else self.config.get('volumes', {}) def get_networks(self): - return {} if self.version == 1 else self.config.get('networks', {}) + return {} if self.version == V1 else self.config.get('networks', {}) class Config(namedtuple('_Config', 'version services volumes networks')): @@ -209,10 +220,6 @@ def validate_config_version(config_files): next_file.filename, next_file.version)) - if main_file.version not in COMPOSEFILE_VERSIONS: - raise ConfigurationError( - 'Invalid Compose file version: {0}'.format(main_file.version)) - def get_default_config_files(base_dir): (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir) @@ -276,7 +283,7 @@ def load(config_details): main_file, [file.get_service_dicts() for file in config_details.config_files]) - if main_file.version >= 2: + if main_file.version != V1: for service_dict in service_dicts: match_named_volumes(service_dict, volumes) @@ -361,7 +368,7 @@ def process_config_file(config_file, service_name=None): interpolated_config = interpolate_environment_variables(service_dicts, 'service') - if config_file.version == 2: + if config_file.version == V2_0: processed_config = dict(config_file.config) processed_config['services'] = services = interpolated_config processed_config['volumes'] = interpolate_environment_variables( @@ -369,7 +376,7 @@ def process_config_file(config_file, service_name=None): processed_config['networks'] = interpolate_environment_variables( config_file.get_networks(), 'network') - if config_file.version == 1: + if config_file.version == V1: processed_config = services = interpolated_config config_file = config_file._replace(config=processed_config) @@ -653,7 +660,7 @@ def merge_service_dicts(base, override, version): if field in base or field in override: d[field] = override.get(field, base.get(field)) - if version == 1: + if version == V1: legacy_v1_merge_image_or_build(d, base, override) else: merge_build(d, base, override) diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.0.json similarity index 94% rename from compose/config/fields_schema_v2.json rename to compose/config/fields_schema_v2.0.json index c001df68..7703adcd 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.0.json @@ -1,18 +1,18 @@ { "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", - "id": "fields_schema_v2.json", + "id": "fields_schema_v2.0.json", "properties": { "version": { - "enum": [2] + "type": "string" }, "services": { "id": "#/properties/services", "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { - "$ref": "service_schema_v2.json#/definitions/service" + "$ref": "service_schema_v2.0.json#/definitions/service" } }, "additionalProperties": false diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.0.json similarity index 99% rename from compose/config/service_schema_v2.json rename to compose/config/service_schema_v2.0.json index ca9bb671..8dd4faf5 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.0.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "service_schema_v2.json", + "id": "service_schema_v2.0.json", "type": "object", diff --git a/compose/config/types.py b/compose/config/types.py index 2e648e5a..9bda7180 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals import os from collections import namedtuple +from compose.config.config import V1 from compose.config.errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM @@ -16,7 +17,7 @@ class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')): # TODO: drop service_names arg when v1 is removed @classmethod def parse(cls, volume_from_config, service_names, version): - func = cls.parse_v1 if version == 1 else cls.parse_v2 + func = cls.parse_v1 if version == V1 else cls.parse_v2 return func(service_names, volume_from_config) @classmethod diff --git a/compose/const.py b/compose/const.py index 6ff108fb..d78a5fa7 100644 --- a/compose/const.py +++ b/compose/const.py @@ -14,9 +14,12 @@ LABEL_PROJECT = 'com.docker.compose.project' LABEL_SERVICE = 'com.docker.compose.service' LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' -COMPOSEFILE_VERSIONS = (1, 2) + +COMPOSEFILE_V1 = '1' +COMPOSEFILE_V2_0 = '2.0' +COMPOSEFILE_VERSIONS = (COMPOSEFILE_V1, COMPOSEFILE_V2_0) API_VERSIONS = { - 1: '1.21', - 2: '1.22', + COMPOSEFILE_V1: '1.21', + COMPOSEFILE_V2_0: '1.22', } diff --git a/compose/project.py b/compose/project.py index d2787ecf..6411f7cc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,7 @@ from docker.errors import NotFound from . import parallel from .config import ConfigurationError +from .config.config import V1 from .config.sort_services import get_container_name_from_network_mode from .config.sort_services import get_service_name_from_network_mode from .const import DEFAULT_TIMEOUT @@ -56,7 +57,7 @@ class Project(object): """ Construct a Project from a config.Config object. """ - use_networking = (config_data.version and config_data.version >= 2) + use_networking = (config_data.version and config_data.version != V1) project = cls(name, [], client, use_networking=use_networking) network_config = config_data.networks or {} @@ -94,7 +95,7 @@ class Project(object): network_mode = project.get_network_mode(service_dict, networks) volumes_from = get_volumes_from(project, service_dict) - if config_data.version == 2: + if config_data.version != V1: service_volumes = service_dict.get('volumes', []) for volume_spec in service_volumes: if volume_spec.is_named_volume: diff --git a/docker-compose.spec b/docker-compose.spec index f7f2059f..b3d8db39 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -23,8 +23,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/fields_schema_v2.json', - 'compose/config/fields_schema_v2.json', + 'compose/config/fields_schema_v2.0.json', + 'compose/config/fields_schema_v2.0.json', 'DATA' ), ( @@ -33,8 +33,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/service_schema_v2.json', - 'compose/config/service_schema_v2.json', + 'compose/config/service_schema_v2.0.json', + 'compose/config/service_schema_v2.0.json', 'DATA' ), ( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b69ce8aa..447b1e32 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,7 +177,7 @@ class CLITestCase(DockerClientTestCase): output = yaml.load(result.stdout) expected = { - 'version': 2, + 'version': '2.0', 'volumes': {'data': {'driver': 'local'}}, 'networks': {'front': {}}, 'services': { diff --git a/tests/fixtures/extends/invalid-net-v2.yml b/tests/fixtures/extends/invalid-net-v2.yml index 0a04f468..7ba714e8 100644 --- a/tests/fixtures/extends/invalid-net-v2.yml +++ b/tests/fixtures/extends/invalid-net-v2.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: myweb: build: '.' diff --git a/tests/fixtures/logging-composefile/docker-compose.yml b/tests/fixtures/logging-composefile/docker-compose.yml index 0a73030a..466d13e5 100644 --- a/tests/fixtures/logging-composefile/docker-compose.yml +++ b/tests/fixtures/logging-composefile/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/net-container/v2-invalid.yml b/tests/fixtures/net-container/v2-invalid.yml index eac4b5f1..9b846295 100644 --- a/tests/fixtures/net-container/v2-invalid.yml +++ b/tests/fixtures/net-container/v2-invalid.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: foo: diff --git a/tests/fixtures/networks/bridge.yml b/tests/fixtures/networks/bridge.yml index 95098372..9fa7db82 100644 --- a/tests/fixtures/networks/bridge.yml +++ b/tests/fixtures/networks/bridge.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/default-network-config.yml b/tests/fixtures/networks/default-network-config.yml index 275fae98..4bd0989b 100644 --- a/tests/fixtures/networks/default-network-config.yml +++ b/tests/fixtures/networks/default-network-config.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml index 5351c0f0..c11fa682 100644 --- a/tests/fixtures/networks/docker-compose.yml +++ b/tests/fixtures/networks/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/external-default.yml b/tests/fixtures/networks/external-default.yml index 7b0797e5..5c9426b8 100644 --- a/tests/fixtures/networks/external-default.yml +++ b/tests/fixtures/networks/external-default.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/networks/external-networks.yml b/tests/fixtures/networks/external-networks.yml index 644e3dda..db75b780 100644 --- a/tests/fixtures/networks/external-networks.yml +++ b/tests/fixtures/networks/external-networks.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/missing-network.yml b/tests/fixtures/networks/missing-network.yml index 666f7d34..41012535 100644 --- a/tests/fixtures/networks/missing-network.yml +++ b/tests/fixtures/networks/missing-network.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: web: diff --git a/tests/fixtures/networks/network-mode.yml b/tests/fixtures/networks/network-mode.yml index 7ab63df8..e4d070b4 100644 --- a/tests/fixtures/networks/network-mode.yml +++ b/tests/fixtures/networks/network-mode.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: bridge: diff --git a/tests/fixtures/no-services/docker-compose.yml b/tests/fixtures/no-services/docker-compose.yml index fa498784..6e76ec0c 100644 --- a/tests/fixtures/no-services/docker-compose.yml +++ b/tests/fixtures/no-services/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" networks: foo: {} diff --git a/tests/fixtures/sleeps-composefile/docker-compose.yml b/tests/fixtures/sleeps-composefile/docker-compose.yml index 1eff7b73..7c8d84f8 100644 --- a/tests/fixtures/sleeps-composefile/docker-compose.yml +++ b/tests/fixtures/sleeps-composefile/docker-compose.yml @@ -1,5 +1,5 @@ -version: 2 +version: "2" services: simple: diff --git a/tests/fixtures/v2-full/docker-compose.yml b/tests/fixtures/v2-full/docker-compose.yml index 725296c9..a973dd0c 100644 --- a/tests/fixtures/v2-full/docker-compose.yml +++ b/tests/fixtures/v2-full/docker-compose.yml @@ -1,5 +1,5 @@ -version: 2 +version: "2" volumes: data: diff --git a/tests/fixtures/v2-simple/docker-compose.yml b/tests/fixtures/v2-simple/docker-compose.yml index 12a9de72..c99ae02f 100644 --- a/tests/fixtures/v2-simple/docker-compose.yml +++ b/tests/fixtures/v2-simple/docker-compose.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/fixtures/v2-simple/links-invalid.yml b/tests/fixtures/v2-simple/links-invalid.yml index 422f9314..481aa404 100644 --- a/tests/fixtures/v2-simple/links-invalid.yml +++ b/tests/fixtures/v2-simple/links-invalid.yml @@ -1,4 +1,4 @@ -version: 2 +version: "2" services: simple: image: busybox:latest diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 0c8c9a6a..180c9df1 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -10,6 +10,7 @@ from docker.errors import NotFound from .testcases import DockerClientTestCase from compose.config import config from compose.config import ConfigurationError +from compose.config.config import V2_0 from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT @@ -112,7 +113,7 @@ class ProjectTest(DockerClientTestCase): name='composetest', client=self.client, config_data=build_service_dicts({ - 'version': 2, + 'version': V2_0, 'services': { 'net': { 'image': 'busybox:latest', @@ -139,7 +140,7 @@ class ProjectTest(DockerClientTestCase): return Project.from_config( name='composetest', config_data=build_service_dicts({ - 'version': 2, + 'version': V2_0, 'services': { 'web': { 'image': 'busybox:latest', @@ -559,7 +560,7 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_project_up_networks(self): config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -592,7 +593,7 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_up_with_ipam_config(self): config_data = config.Config( - version=2, + version=V2_0, services=[], volumes={}, networks={ @@ -651,7 +652,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -677,7 +678,7 @@ class ProjectTest(DockerClientTestCase): base_file = config.ConfigFile( 'base.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'simple': {'image': 'busybox:latest', 'command': 'top'}, 'another': { @@ -696,7 +697,7 @@ class ProjectTest(DockerClientTestCase): override_file = config.ConfigFile( 'override.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'another': { 'logging': { @@ -729,7 +730,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -754,7 +755,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -779,7 +780,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -802,7 +803,7 @@ class ProjectTest(DockerClientTestCase): full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -841,7 +842,7 @@ class ProjectTest(DockerClientTestCase): full_vol_name = 'composetest_{0}'.format(vol_name) self.client.create_volume(vol_name) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -866,7 +867,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -895,7 +896,7 @@ class ProjectTest(DockerClientTestCase): base_file = config.ConfigFile( 'base.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'simple': { 'image': 'busybox:latest', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 5870946d..8e2f2593 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -10,6 +10,8 @@ from pytest import skip from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment +from compose.config.config import V1 +from compose.config.config import V2_0 from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output @@ -54,9 +56,9 @@ class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): if engine_version_too_low_for_v2(): - version = API_VERSIONS[1] + version = API_VERSIONS[V1] else: - version = API_VERSIONS[2] + version = API_VERSIONS[V2_0] cls.client = docker_client(version) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 44f5c684..302f4703 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -14,14 +14,15 @@ import pytest from compose.config import config from compose.config.config import resolve_build_args from compose.config.config import resolve_environment +from compose.config.config import V1 +from compose.config.config import V2_0 from compose.config.errors import ConfigurationError from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest -DEFAULT_VERSION = V2 = 2 -V1 = 1 +DEFAULT_VERSION = V2_0 def make_service_dict(name, service_dict, working_dir, filename=None): @@ -78,7 +79,7 @@ class ConfigTest(unittest.TestCase): def test_load_v2(self): config_data = config.load( build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'foo': {'image': 'busybox'}, 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, @@ -143,9 +144,55 @@ class ConfigTest(unittest.TestCase): } }) + def test_valid_versions(self): + for version in ['2', '2.0']: + cfg = config.load(build_config_details({'version': version})) + assert cfg.version == V2_0 + + def test_v1_file_version(self): + cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) + assert cfg.version == V1 + assert list(s['name'] for s in cfg.services) == ['web'] + + cfg = config.load(build_config_details({'version': {'image': 'busybox'}})) + assert cfg.version == V1 + assert list(s['name'] for s in cfg.services) == ['version'] + + def test_wrong_version_type(self): + for version in [None, 2, 2.0]: + with pytest.raises(ConfigurationError): + config.load( + build_config_details( + {'version': version}, + filename='filename.yml', + ) + ) + + def test_unsupported_version(self): + with pytest.raises(ConfigurationError): + config.load( + build_config_details( + {'version': '2.1'}, + filename='filename.yml', + ) + ) + + def test_v1_file_with_version_is_invalid(self): + for version in [1, "1"]: + with pytest.raises(ConfigurationError): + config.load( + build_config_details( + { + 'version': version, + 'web': {'image': 'busybox'}, + }, + filename='filename.yml', + ) + ) + def test_named_volume_config_empty(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'simple': {'image': 'busybox'} }, @@ -214,7 +261,7 @@ class ConfigTest(unittest.TestCase): with self.assertRaises(ConfigurationError): config.load( build_config_details( - {'version': 2, 'services': {'web': 'busybox:latest'}}, + {'version': '2', 'services': {'web': 'busybox:latest'}}, 'working_dir', 'filename.yml' ) @@ -224,7 +271,7 @@ class ConfigTest(unittest.TestCase): with self.assertRaises(ConfigurationError): config.load( build_config_details({ - 'version': 2, + 'version': '2', 'services': {'web': 'busybox:latest'}, 'networks': { 'invalid': {'foo', 'bar'} @@ -246,7 +293,7 @@ class ConfigTest(unittest.TestCase): with pytest.raises(ConfigurationError) as exc: config.load( build_config_details({ - 'version': 2, + 'version': '2', 'services': {invalid_name: {'image': 'busybox'}} }, 'working_dir', 'filename.yml') ) @@ -256,7 +303,7 @@ class ConfigTest(unittest.TestCase): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': {'image': 'busybox', 'name': 'bogus'}, } @@ -307,7 +354,7 @@ class ConfigTest(unittest.TestCase): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': {1: {'image': 'busybox'}} }, 'working_dir', @@ -370,7 +417,7 @@ class ConfigTest(unittest.TestCase): def test_load_with_multiple_files_and_empty_override_v2(self): base_file = config.ConfigFile( 'base.yml', - {'version': 2, 'services': {'web': {'image': 'example/web'}}}) + {'version': '2', 'services': {'web': {'image': 'example/web'}}}) override_file = config.ConfigFile('override.yml', None) details = config.ConfigDetails('.', [base_file, override_file]) @@ -394,7 +441,7 @@ class ConfigTest(unittest.TestCase): base_file = config.ConfigFile('base.yml', None) override_file = config.ConfigFile( 'override.tml', - {'version': 2, 'services': {'web': {'image': 'example/web'}}} + {'version': '2', 'services': {'web': {'image': 'example/web'}}} ) details = config.ConfigDetails('.', [base_file, override_file]) with pytest.raises(ConfigurationError) as exc: @@ -494,7 +541,7 @@ class ConfigTest(unittest.TestCase): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '.', @@ -509,7 +556,7 @@ class ConfigTest(unittest.TestCase): service = config.load( build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '.' @@ -522,7 +569,7 @@ class ConfigTest(unittest.TestCase): service = config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': { @@ -543,7 +590,7 @@ class ConfigTest(unittest.TestCase): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'example/web', @@ -556,7 +603,7 @@ class ConfigTest(unittest.TestCase): override_file = config.ConfigFile( 'override.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '/', @@ -585,7 +632,7 @@ class ConfigTest(unittest.TestCase): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox:latest', @@ -601,7 +648,7 @@ class ConfigTest(unittest.TestCase): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox:latest', @@ -681,7 +728,7 @@ class ConfigTest(unittest.TestCase): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'foo': {'image': 1}, }, @@ -1016,7 +1063,7 @@ class ConfigTest(unittest.TestCase): def test_external_volume_config(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'bogus': {'image': 'busybox'} }, @@ -1034,7 +1081,7 @@ class ConfigTest(unittest.TestCase): def test_external_volume_invalid_config(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'bogus': {'image': 'busybox'} }, @@ -1047,7 +1094,7 @@ class ConfigTest(unittest.TestCase): def test_depends_on_orders_services(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'one': {'image': 'busybox', 'depends_on': ['three', 'two']}, 'two': {'image': 'busybox', 'depends_on': ['three']}, @@ -1062,7 +1109,7 @@ class ConfigTest(unittest.TestCase): def test_depends_on_unknown_service_errors(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'one': {'image': 'busybox', 'depends_on': ['three']}, }, @@ -1075,7 +1122,7 @@ class ConfigTest(unittest.TestCase): class NetworkModeTest(unittest.TestCase): def test_network_mode_standard(self): config_data = config.load(build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox', @@ -1101,7 +1148,7 @@ class NetworkModeTest(unittest.TestCase): def test_network_mode_container(self): config_data = config.load(build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox', @@ -1126,7 +1173,7 @@ class NetworkModeTest(unittest.TestCase): def test_network_mode_service(self): config_data = config.load(build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox', @@ -1160,7 +1207,7 @@ class NetworkModeTest(unittest.TestCase): def test_network_mode_service_nonexistent(self): with pytest.raises(ConfigurationError) as excinfo: config.load(build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox', @@ -1175,7 +1222,7 @@ class NetworkModeTest(unittest.TestCase): def test_network_mode_plus_networks_is_invalid(self): with pytest.raises(ConfigurationError) as excinfo: config.load(build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox', @@ -2202,7 +2249,7 @@ class ExtendsTest(unittest.TestCase): tmpdir = py.test.ensuretemp('test_extends_with_mixed_version') self.addCleanup(tmpdir.remove) tmpdir.join('docker-compose.yml').write(""" - version: 2 + version: "2" services: web: extends: @@ -2224,7 +2271,7 @@ class ExtendsTest(unittest.TestCase): tmpdir = py.test.ensuretemp('test_extends_with_defined_version') self.addCleanup(tmpdir.remove) tmpdir.join('docker-compose.yml').write(""" - version: 2 + version: "2" services: web: extends: @@ -2233,7 +2280,7 @@ class ExtendsTest(unittest.TestCase): image: busybox """) tmpdir.join('base.yml').write(""" - version: 2 + version: "2" services: base: volumes: ['/foo'] diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 50b7efcb..c741a339 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -3,13 +3,13 @@ from __future__ import unicode_literals import pytest +from compose.config.config import V1 +from compose.config.config import V2_0 from compose.config.errors import ConfigurationError from compose.config.types import parse_extra_hosts from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM -from tests.unit.config.config_test import V1 -from tests.unit.config.config_test import V2 def test_parse_extra_hosts_list(): @@ -91,26 +91,26 @@ class TestVolumesFromSpec(object): VolumeFromSpec.parse('unknown:format:ro', self.services, V1) def test_parse_v2_from_service(self): - volume_from = VolumeFromSpec.parse('servicea', self.services, V2) + volume_from = VolumeFromSpec.parse('servicea', self.services, V2_0) assert volume_from == VolumeFromSpec('servicea', 'rw', 'service') def test_parse_v2_from_service_with_mode(self): - volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2) + volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2_0) assert volume_from == VolumeFromSpec('servicea', 'ro', 'service') def test_parse_v2_from_container(self): - volume_from = VolumeFromSpec.parse('container:foo', self.services, V2) + volume_from = VolumeFromSpec.parse('container:foo', self.services, V2_0) assert volume_from == VolumeFromSpec('foo', 'rw', 'container') def test_parse_v2_from_container_with_mode(self): - volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2) + volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2_0) assert volume_from == VolumeFromSpec('foo', 'ro', 'container') def test_parse_v2_invalid_type(self): with pytest.raises(ConfigurationError) as exc: - VolumeFromSpec.parse('bogus:foo:ro', self.services, V2) + VolumeFromSpec.parse('bogus:foo:ro', self.services, V2_0) assert "Unknown volumes_from type 'bogus'" in exc.exconly() def test_parse_v2_invalid(self): with pytest.raises(ConfigurationError): - VolumeFromSpec.parse('unknown:format:ro', self.services, V2) + VolumeFromSpec.parse('unknown:format:ro', self.services, V2_0) From ef8db3650aa551959c0979bba939d5747ec74a68 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jan 2016 16:15:16 +0000 Subject: [PATCH 077/580] Improve error messages for invalid versions Signed-off-by: Aanand Prasad --- compose/config/config.py | 23 ++++++++-- compose/config/errors.py | 8 ++++ compose/config/validation.py | 8 +++- compose/const.py | 1 - tests/unit/config/config_test.py | 73 +++++++++++++++++--------------- 5 files changed, 71 insertions(+), 42 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index bda086b9..094c8b3a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,10 +16,10 @@ from cached_property import cached_property from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V2_0 as V2_0 -from ..const import COMPOSEFILE_VERSIONS from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError +from .errors import VERSION_EXPLANATION from .interpolation import interpolate_environment_variables from .sort_services import get_container_name_from_network_mode from .sort_services import get_service_name_from_network_mode @@ -105,6 +105,7 @@ SUPPORTED_FILENAMES = [ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' + log = logging.getLogger(__name__) @@ -131,7 +132,10 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): @cached_property def version(self): - version = self.config.get('version', V1) + if 'version' not in self.config: + return V1 + + version = self.config['version'] if isinstance(version, dict): log.warn("Unexpected type for field 'version', in file {} assuming " @@ -139,12 +143,23 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): "Compose file version 1".format(self.filename)) return V1 + if not isinstance(version, six.string_types): + raise ConfigurationError( + 'Version in "{}" is invalid - it should be a string.' + .format(self.filename)) + + if version == '1': + raise ConfigurationError( + 'Version in "{}" is invalid. {}' + .format(self.filename, VERSION_EXPLANATION)) + if version == '2': version = V2_0 - if version not in COMPOSEFILE_VERSIONS: + if version != V2_0: raise ConfigurationError( - 'Invalid Compose file version: {0}'.format(version)) + 'Version in "{}" is unsupported. {}' + .format(self.filename, VERSION_EXPLANATION)) return version diff --git a/compose/config/errors.py b/compose/config/errors.py index 99129f3d..f94ac7ac 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -2,6 +2,14 @@ from __future__ import absolute_import from __future__ import unicode_literals +VERSION_EXPLANATION = ( + 'Either specify a version of "2" (or "2.0") and place your service ' + 'definitions under the `services` key, or omit the `version` key and place ' + 'your service definitions at the root of the file to use version 1.\n' + 'For more on the Compose file format versions, see ' + 'https://docs.docker.com/compose/compose-file/') + + class ConfigurationError(Exception): def __init__(self, msg): self.msg = msg diff --git a/compose/config/validation.py b/compose/config/validation.py index ba1ac521..6b240135 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ from jsonschema import RefResolver from jsonschema import ValidationError from .errors import ConfigurationError +from .errors import VERSION_EXPLANATION from .sort_services import get_service_name_from_network_mode @@ -229,11 +230,14 @@ def handle_error_for_schema_with_id(error, path): "A service can either be built to image or use an existing " "image, not both.".format(path_string(path))) - if schema_id == '#/definitions/service': - if error.validator == 'additionalProperties': + if error.validator == 'additionalProperties': + if schema_id == '#/definitions/service': invalid_config_key = parse_key_from_error_msg(error) return get_unsupported_config_msg(path, invalid_config_key) + if not error.path: + return '{}\n{}'.format(error.message, VERSION_EXPLANATION) + def handle_generic_service_error(error, path): msg_format = None diff --git a/compose/const.py b/compose/const.py index d78a5fa7..0e307835 100644 --- a/compose/const.py +++ b/compose/const.py @@ -17,7 +17,6 @@ LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' -COMPOSEFILE_VERSIONS = (COMPOSEFILE_V1, COMPOSEFILE_V2_0) API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 302f4703..3c012ea7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -17,6 +17,7 @@ from compose.config.config import resolve_environment from compose.config.config import V1 from compose.config.config import V2_0 from compose.config.errors import ConfigurationError +from compose.config.errors import VERSION_EXPLANATION from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -159,8 +160,8 @@ class ConfigTest(unittest.TestCase): assert list(s['name'] for s in cfg.services) == ['version'] def test_wrong_version_type(self): - for version in [None, 2, 2.0]: - with pytest.raises(ConfigurationError): + for version in [None, 1, 2, 2.0]: + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( {'version': version}, @@ -168,8 +169,11 @@ class ConfigTest(unittest.TestCase): ) ) + assert 'Version in "filename.yml" is invalid - it should be a string.' \ + in excinfo.exconly() + def test_unsupported_version(self): - with pytest.raises(ConfigurationError): + with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( {'version': '2.1'}, @@ -177,18 +181,38 @@ class ConfigTest(unittest.TestCase): ) ) - def test_v1_file_with_version_is_invalid(self): - for version in [1, "1"]: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - { - 'version': version, - 'web': {'image': 'busybox'}, - }, - filename='filename.yml', - ) + assert 'Version in "filename.yml" is unsupported' in excinfo.exconly() + assert VERSION_EXPLANATION in excinfo.exconly() + + def test_version_1_is_invalid(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': '1', + 'web': {'image': 'busybox'}, + }, + filename='filename.yml', ) + ) + + assert 'Version in "filename.yml" is invalid' in excinfo.exconly() + assert VERSION_EXPLANATION in excinfo.exconly() + + def test_v1_file_with_version_is_invalid(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': '2', + 'web': {'image': 'busybox'}, + }, + filename='filename.yml', + ) + ) + + assert 'Additional properties are not allowed' in excinfo.exconly() + assert VERSION_EXPLANATION in excinfo.exconly() def test_named_volume_config_empty(self): config_details = build_config_details({ @@ -226,27 +250,6 @@ class ConfigTest(unittest.TestCase): ]) ) - def test_load_invalid_version(self): - with self.assertRaises(ConfigurationError): - config.load( - build_config_details({ - 'version': 18, - 'services': { - 'foo': {'image': 'busybox'} - } - }, 'working_dir', 'filename.yml') - ) - - with self.assertRaises(ConfigurationError): - config.load( - build_config_details({ - 'version': 'two point oh', - 'services': { - 'foo': {'image': 'busybox'} - } - }, 'working_dir', 'filename.yml') - ) - def test_load_throws_error_when_not_dict(self): with self.assertRaises(ConfigurationError): config.load( From 1152c5b25b571c3a2f7ffe4394844b1ebcbad570 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jan 2016 16:38:23 +0000 Subject: [PATCH 078/580] Tweak and test warning shown when version is a dict Signed-off-by: Aanand Prasad --- compose/config/config.py | 6 +++--- tests/unit/config/config_test.py | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 094c8b3a..be680503 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -138,9 +138,9 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): version = self.config['version'] if isinstance(version, dict): - log.warn("Unexpected type for field 'version', in file {} assuming " - "version is the name of a service, and defaulting to " - "Compose file version 1".format(self.filename)) + log.warn('Unexpected type for "version" key in "{}". Assuming ' + '"version" is the name of a service, and defaulting to ' + 'Compose file version 1.'.format(self.filename)) return V1 if not isinstance(version, six.string_types): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3c012ea7..af256f20 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -232,13 +232,18 @@ class ConfigTest(unittest.TestCase): assert volumes['other'] == {} def test_load_service_with_name_version(self): - config_data = config.load( - build_config_details({ - 'version': { - 'image': 'busybox' - } - }, 'working_dir', 'filename.yml') - ) + with mock.patch('compose.config.config.log') as mock_logging: + config_data = config.load( + build_config_details({ + 'version': { + 'image': 'busybox' + } + }, 'working_dir', 'filename.yml') + ) + + assert 'Unexpected type for "version" key in "filename.yml"' \ + in mock_logging.warn.call_args[0][0] + service_dicts = config_data.services self.assertEqual( service_sort(service_dicts), From 4f92004d9ae3f36a18ac0246e3771da0a9d5ea4c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 28 Jan 2016 18:50:05 -0500 Subject: [PATCH 079/580] Re-order compose docs so that quickstart guides come before other documentation. Signed-off-by: Daniel Nephin --- docs/extends.md | 2 +- docs/networking.md | 1 + docs/production.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index c9b65db8..252ffdfc 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -5,7 +5,7 @@ description = "How to use Docker Compose's extends keyword to share configuratio keywords = ["fig, composition, compose, docker, orchestration, documentation, docs"] [menu.main] parent="workw_compose" -weight=2 +weight=20 +++ diff --git a/docs/networking.md b/docs/networking.md index fb34e3de..96cfa810 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -5,6 +5,7 @@ description = "How Compose sets up networking between containers" keywords = ["documentation, docs, docker, compose, orchestration, containers, networking"] [menu.main] parent="workw_compose" +weight=21 +++ diff --git a/docs/production.md b/docs/production.md index d51ca549..27c686dd 100644 --- a/docs/production.md +++ b/docs/production.md @@ -5,7 +5,7 @@ description = "Guide to using Docker Compose in production" keywords = ["documentation, docs, docker, compose, orchestration, containers, production"] [menu.main] parent="workw_compose" -weight=1 +weight=22 +++ From 5e30f089e3c439c7f4a32e4bdc02e39890532cf9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 28 Jan 2016 18:53:40 -0500 Subject: [PATCH 080/580] Use the same capitalization for all menu items in the docs. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 2 +- docs/env.md | 2 +- docs/extends.md | 2 +- docs/production.md | 2 +- docs/reference/index.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index cc21bd8a..9bc72561 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -1,6 +1,6 @@ -# Introduction to the CLI - -This section describes the subcommands you can use with the `docker-compose` command. You can run subcommand against one or more services. To run against a specific service, you supply the service name from your compose configuration. If you do not specify the service name, the command runs against all the services in your configuration. - - -## Commands - -* [docker-compose Command](docker-compose.md) -* [CLI Reference](index.md) - - -## Environment Variables +# CLI Environment Variables Several environment variables are available for you to configure the Docker Compose command-line behaviour. Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using `docker-machine`, then the `eval "$(docker-machine env my-docker-vm)"` command should set them to their correct values. (In this example, `my-docker-vm` is the name of a machine you created.) -### COMPOSE\_PROJECT\_NAME +## COMPOSE\_PROJECT\_NAME Sets the project name. This value is prepended along with the service name to the container container on start up. For example, if you project name is `myapp` and it includes two services `db` and `web` then compose starts containers named `myapp_db_1` and `myapp_web_1` respectively. @@ -36,14 +26,14 @@ Setting this is optional. If you do not set this, the `COMPOSE_PROJECT_NAME` defaults to the `basename` of the project directory. See also the `-p` [command-line option](docker-compose.md). -### COMPOSE\_FILE +## COMPOSE\_FILE Specify the file containing the compose configuration. If not provided, Compose looks for a file named `docker-compose.yml` in the current directory and then each parent directory in succession until a file by that name is found. See also the `-f` [command-line option](docker-compose.md). -### COMPOSE\_API\_VERSION +## COMPOSE\_API\_VERSION The Docker API only supports requests from clients which report a specific version. If you receive a `client and server don't have same version error` using @@ -63,20 +53,20 @@ If you run into problems running with this set, resolve the mismatch through upgrade and remove this setting to see if your problems resolve before notifying support. -### DOCKER\_HOST +## DOCKER\_HOST Sets the URL of the `docker` daemon. As with the Docker client, defaults to `unix:///var/run/docker.sock`. -### DOCKER\_TLS\_VERIFY +## DOCKER\_TLS\_VERIFY When set to anything other than an empty string, enables TLS communication with the `docker` daemon. -### DOCKER\_CERT\_PATH +## DOCKER\_CERT\_PATH Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. -### COMPOSE\_HTTP\_TIMEOUT +## COMPOSE\_HTTP\_TIMEOUT Configures the time (in seconds) a request to the Docker daemon is allowed to hang before Compose considers it failed. Defaults to 60 seconds. diff --git a/docs/reference/index.md b/docs/reference/index.md index d6fa2948..528550c7 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -14,6 +14,7 @@ weight=80 The following pages describe the usage information for the [docker-compose](docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. +* [docker-compose](docker-compose.md) * [build](build.md) * [config](config.md) * [create](create.md) @@ -37,5 +38,5 @@ The following pages describe the usage information for the [docker-compose](dock ## Where to go next -* [CLI environment variables](overview.md) +* [CLI environment variables](envvars.md) * [docker-compose Command](docker-compose.md) From cf24c36c5549a2a87952da27c6e3d35974687e1c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 13:00:43 -0500 Subject: [PATCH 082/580] Rename the old environment variable page to link environment variables. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 3 ++- docs/{env.md => link-env-deprecated.md} | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) rename docs/{env.md => link-env-deprecated.md} (94%) diff --git a/docs/compose-file.md b/docs/compose-file.md index 9bc72561..e5326566 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -903,7 +903,8 @@ It's more complicated if you're using particular configuration features: syslog-address: "tcp://192.168.0.42:123" - `links` with environment variables: As documented in the - [environment variables reference](env.md), environment variables created by + [environment variables reference](link-env-deprecated.md), environment variables + created by links have been deprecated for some time. In the new Docker network system, they have been removed. You should either connect directly to the appropriate hostname or set the relevant environment variable yourself, diff --git a/docs/env.md b/docs/link-env-deprecated.md similarity index 94% rename from docs/env.md rename to docs/link-env-deprecated.md index f4eeedeb..55ba5f2d 100644 --- a/docs/env.md +++ b/docs/link-env-deprecated.md @@ -1,15 +1,16 @@ -# Compose environment variables reference +# Link environment variables reference > **Note:** Environment variables are no longer the recommended method for connecting to linked services. Instead, you should use the link name (by default, the name of the linked service) as the hostname to connect to. See the [docker-compose.yml documentation](compose-file.md#links) for details. > From 7b03de7d01ded900a416187efcbb312c5d8423de Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 12:10:21 -0500 Subject: [PATCH 083/580] Move command reference to overview. Signed-off-by: Daniel Nephin --- docs/extends.md | 2 +- docs/faq.md | 2 +- docs/index.md | 2 +- docs/networking.md | 2 +- docs/overview.md | 2 +- docs/reference/envvars.md | 5 ++--- docs/reference/index.md | 6 +++--- docs/reference/{docker-compose.md => overview.md} | 8 ++++---- 8 files changed, 14 insertions(+), 15 deletions(-) rename docs/reference/{docker-compose.md => overview.md} (95%) diff --git a/docs/extends.md b/docs/extends.md index 95a3d64f..4067a4f0 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -42,7 +42,7 @@ are copied. To use multiple override files, or an override file with a different name, you can use the `-f` option to specify the list of files. Compose merges files in the order they're specified on the command line. See the [`docker-compose` -command reference](./reference/docker-compose.md) for more information about +command reference](./reference/overview.md) for more information about using `-f`. When you use multiple configuration files, you must make sure all paths in the diff --git a/docs/faq.md b/docs/faq.md index 3cb6a553..8fa629de 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -50,7 +50,7 @@ handling `SIGTERM` properly. Compose uses the project name to create unique identifiers for all of a project's containers and other resources. To run multiple copies of a project, set a custom project name using the [`-p` command line -option](./reference/docker-compose.md) or the [`COMPOSE_PROJECT_NAME` +option](./reference/overview.md) or the [`COMPOSE_PROJECT_NAME` environment variable](./reference/envvars.md#compose-project-name). ## What's the difference between `up`, `run`, and `start`? diff --git a/docs/index.md b/docs/index.md index 61bc41ee..f5d84218 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ -# docker-compose Command +# Overview of docker-compose CLI This page provides the usage information for the `docker-compose` Command. You can also see this information by running `docker-compose --help` from the @@ -114,4 +115,3 @@ envvars.md#compose-project-name) ## Where to go next * [CLI environment variables](envvars.md) -* [Command line reference](index.md) From c70c72f49ac72f864f614f38288a1151dc590553 Mon Sep 17 00:00:00 2001 From: Ryan Taylor Long Date: Thu, 28 Jan 2016 06:19:03 +0000 Subject: [PATCH 084/580] Add depends_on to ALLOWED_KEYS This ensures and already existing `depends_on` is not deleted when the service on which it is defined also employs `extends`. Signed-off-by: Ryan Taylor Long --- compose/config/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config.py b/compose/config/config.py index be680503..7a8e5533 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -84,6 +84,7 @@ DOCKER_CONFIG_KEYS = [ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', + 'depends_on', 'dockerfile', 'expose', 'external_links', From bf6a5d3e4956d51f03926577fc759b29388f824f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 13:36:51 -0500 Subject: [PATCH 085/580] Fix merging of lists with multiple files. Signed-off-by: Daniel Nephin --- compose/config/config.py | 12 ++++++++---- tests/unit/config/config_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7a8e5533..07f62290 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -84,10 +84,7 @@ DOCKER_CONFIG_KEYS = [ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', - 'depends_on', 'dockerfile', - 'expose', - 'external_links', 'logging', ] @@ -666,7 +663,14 @@ def merge_service_dicts(base, override, version): for field in ['volumes', 'devices']: merge_field(field, merge_path_mappings) - for field in ['ports', 'expose', 'external_links']: + for field in [ + 'depends_on', + 'expose', + 'external_links', + 'links', + 'ports', + 'volumes_from', + ]: merge_field(field, operator.add, default=[]) for field in ['dns', 'dns_search', 'env_file']: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index af256f20..f667b3bb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -602,6 +602,7 @@ class ConfigTest(unittest.TestCase): 'services': { 'web': { 'image': 'example/web', + 'depends_on': ['db'], }, 'db': { 'image': 'example/db', @@ -616,7 +617,11 @@ class ConfigTest(unittest.TestCase): 'web': { 'build': '/', 'volumes': ['/home/user/project:/code'], + 'depends_on': ['other'], }, + 'other': { + 'image': 'example/other', + } } }) details = config.ConfigDetails('.', [base_file, override_file]) @@ -628,11 +633,16 @@ class ConfigTest(unittest.TestCase): 'build': {'context': os.path.abspath('/')}, 'image': 'example/web', 'volumes': [VolumeSpec.parse('/home/user/project:/code')], + 'depends_on': ['db', 'other'], }, { 'name': 'db', 'image': 'example/db', }, + { + 'name': 'other', + 'image': 'example/other', + }, ] assert service_sort(service_dicts) == service_sort(expected) @@ -2299,6 +2309,24 @@ class ExtendsTest(unittest.TestCase): service = load_from_filename(str(tmpdir.join('docker-compose.yml'))) self.assertEquals(service[0]['command'], "top") + def test_extends_with_depends_on(self): + tmpdir = py.test.ensuretemp('test_extends_with_defined_version') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: 2 + services: + base: + image: example + web: + extends: base + image: busybox + depends_on: ['other'] + other: + image: example + """) + services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + assert service_sort(services)[2]['depends_on'] == ['other'] + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From e32863f89ebe0c70143695525e5062ae1c8f375c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 13:47:13 -0500 Subject: [PATCH 086/580] Make links unique-by-alias when merging Factor out MergeDict from merge_service_dicts to reduce complexity below limit. Signed-off-by: Daniel Nephin --- compose/config/config.py | 85 ++++++++++++++++++++++++++++------------ compose/config/types.py | 19 +++++++++ 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 07f62290..f362f1b8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -26,6 +26,7 @@ from .sort_services import get_service_name_from_network_mode from .sort_services import sort_service_dicts from .types import parse_extra_hosts from .types import parse_restart_spec +from .types import ServiceLink from .types import VolumeFromSpec from .types import VolumeSpec from .validation import match_named_volumes @@ -641,51 +642,79 @@ def merge_service_dicts_from_files(base, override, version): return new_service +class MergeDict(dict): + """A dict-like object responsible for merging two dicts into one.""" + + def __init__(self, base, override): + self.base = base + self.override = override + + def needs_merge(self, field): + return field in self.base or field in self.override + + def merge_field(self, field, merge_func, default=None): + if not self.needs_merge(field): + return + + self[field] = merge_func( + self.base.get(field, default), + self.override.get(field, default)) + + def merge_mapping(self, field, parse_func): + if not self.needs_merge(field): + return + + self[field] = parse_func(self.base.get(field)) + self[field].update(parse_func(self.override.get(field))) + + def merge_sequence(self, field, parse_func): + def parse_sequence_func(seq): + return to_mapping((parse_func(item) for item in seq), 'merge_field') + + if not self.needs_merge(field): + return + + merged = parse_sequence_func(self.base.get(field, [])) + merged.update(parse_sequence_func(self.override.get(field, []))) + self[field] = [item.repr() for item in merged.values()] + + def merge_scalar(self, field): + if self.needs_merge(field): + self[field] = self.override.get(field, self.base.get(field)) + + def merge_service_dicts(base, override, version): - d = {} + md = MergeDict(base, override) - def merge_field(field, merge_func, default=None): - if field in base or field in override: - d[field] = merge_func( - base.get(field, default), - override.get(field, default)) - - def merge_mapping(mapping, parse_func): - if mapping in base or mapping in override: - merged = parse_func(base.get(mapping, None)) - merged.update(parse_func(override.get(mapping, None))) - d[mapping] = merged - - merge_mapping('environment', parse_environment) - merge_mapping('labels', parse_labels) - merge_mapping('ulimits', parse_ulimits) + md.merge_mapping('environment', parse_environment) + md.merge_mapping('labels', parse_labels) + md.merge_mapping('ulimits', parse_ulimits) + md.merge_sequence('links', ServiceLink.parse) for field in ['volumes', 'devices']: - merge_field(field, merge_path_mappings) + md.merge_field(field, merge_path_mappings) for field in [ 'depends_on', 'expose', 'external_links', - 'links', 'ports', 'volumes_from', ]: - merge_field(field, operator.add, default=[]) + md.merge_field(field, operator.add, default=[]) for field in ['dns', 'dns_search', 'env_file']: - merge_field(field, merge_list_or_string) + md.merge_field(field, merge_list_or_string) - for field in set(ALLOWED_KEYS) - set(d): - if field in base or field in override: - d[field] = override.get(field, base.get(field)) + for field in set(ALLOWED_KEYS) - set(md): + md.merge_scalar(field) if version == V1: - legacy_v1_merge_image_or_build(d, base, override) + legacy_v1_merge_image_or_build(md, base, override) else: - merge_build(d, base, override) + merge_build(md, base, override) - return d + return dict(md) def merge_build(output, base, override): @@ -919,6 +948,10 @@ def to_list(value): return value +def to_mapping(sequence, key_field): + return {getattr(item, key_field): item for item in sequence} + + def has_uppercase(name): return any(char in string.ascii_uppercase for char in name) diff --git a/compose/config/types.py b/compose/config/types.py index 9bda7180..fc3347c8 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -168,3 +168,22 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): @property def is_named_volume(self): return self.external and not self.external.startswith(('.', '/', '~')) + + +class ServiceLink(namedtuple('_ServiceLink', 'target alias')): + + @classmethod + def parse(cls, link_spec): + target, _, alias = link_spec.partition(':') + if not alias: + alias = target + return cls(target, alias) + + def repr(self): + if self.target == self.alias: + return self.target + return '{s.target}:{s.alias}'.format(s=self) + + @property + def merge_field(self): + return self.alias From 3ec87adccc6d248aecf6beffbe5cb5fa9c7755a5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 14:04:53 -0500 Subject: [PATCH 087/580] Update merge docs with depends_on, and correction about how links and volumes_from are merged. Signed-off-by: Daniel Nephin --- docs/extends.md | 27 ++++++++++----------------- tests/unit/config/config_test.py | 2 +- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index c9b65db8..8390767d 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -32,12 +32,9 @@ contains your base configuration. The override file, as its name implies, can contain configuration overrides for existing services or entirely new services. -If a service is defined in both files, Compose merges the configurations using -the same rules as the `extends` field (see [Adding and overriding -configuration](#adding-and-overriding-configuration)), with one exception. If a -service contains `links` or `volumes_from` those fields are copied over and -replace any values in the original service, in the same way single-valued fields -are copied. +If a service is defined in both files Compose merges the configurations using +the rules described in [Adding and overriding +configuration](#adding-and-overriding-configuration). To use multiple override files, or an override file with a different name, you can use the `-f` option to specify the list of files. Compose merges files in @@ -176,10 +173,12 @@ is useful if you have several services that reuse a common set of configuration options. Using `extends` you can define a common set of service options in one place and refer to it from anywhere. -> **Note:** `links` and `volumes_from` are never shared between services using -> `extends`. See -> [Adding and overriding configuration](#adding-and-overriding-configuration) - > for more information. +> **Note:** `links`, `volumes_from`, and `depends_on` are never shared between +> services using >`extends`. These exceptions exist to avoid +> implicit dependencies—you always define `links` and `volumes_from` +> locally. This ensures dependencies between services are clearly visible when +> reading the current file. Defining these locally also ensures changes to the +> referenced file don't result in breakage. ### Understand the extends configuration @@ -275,13 +274,7 @@ common configuration: ## Adding and overriding configuration -Compose copies configurations from the original service over to the local one, -**except** for `links` and `volumes_from`. These exceptions exist to avoid -implicit dependencies—you always define `links` and `volumes_from` -locally. This ensures dependencies between services are clearly visible when -reading the current file. Defining these locally also ensures changes to the -referenced file don't result in breakage. - +Compose copies configurations from the original service over to the local one. If a configuration option is defined in both the original service the local service, the local value *replaces* or *extends* the original value. diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f667b3bb..8c9b73dc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2313,7 +2313,7 @@ class ExtendsTest(unittest.TestCase): tmpdir = py.test.ensuretemp('test_extends_with_defined_version') self.addCleanup(tmpdir.remove) tmpdir.join('docker-compose.yml').write(""" - version: 2 + version: "2" services: base: image: example From 8e838968fe64ca222c51cd77d2a84f805bada7a9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 14:41:18 -0500 Subject: [PATCH 088/580] Refactor project network initlization. Signed-off-by: Daniel Nephin --- compose/network.py | 64 ++++++++++++++++++++++++++ compose/project.py | 93 ++++++++------------------------------ tests/unit/project_test.py | 8 ++-- 3 files changed, 88 insertions(+), 77 deletions(-) diff --git a/compose/network.py b/compose/network.py index 4f4f5522..4f4e06b2 100644 --- a/compose/network.py +++ b/compose/network.py @@ -104,3 +104,67 @@ def create_ipam_config_from_dict(ipam_dict): for config in ipam_dict.get('config', []) ], ) + + +def build_networks(name, config_data, client): + network_config = config_data.networks or {} + networks = { + network_name: Network( + client=client, project=name, name=network_name, + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + ipam=data.get('ipam'), + external_name=data.get('external_name'), + ) + for network_name, data in network_config.items() + } + + if 'default' not in networks: + networks['default'] = Network(client, name, 'default') + + return networks + + +class ProjectNetworks(object): + + def __init__(self, networks, use_networking): + self.networks = networks or {} + self.use_networking = use_networking + + @classmethod + def from_services(cls, services, networks, use_networking): + networks = { + network: networks[network] + for service in services + for network in service.get('networks', ['default']) + } + return cls(networks, use_networking) + + def remove(self): + if not self.use_networking: + return + for network in self.networks.values(): + network.remove() + + def initialize(self): + if not self.use_networking: + return + + for network in self.networks.values(): + network.ensure() + + +def get_networks(service_dict, network_definitions): + if 'network_mode' in service_dict: + return [] + + networks = [] + for name in service_dict.pop('networks', ['default']): + network = network_definitions.get(name) + if network: + networks.append(network.full_name) + else: + raise ConfigurationError( + 'Service "{}" uses an undefined network "{}"' + .format(service_dict['name'], name)) + return networks diff --git a/compose/project.py b/compose/project.py index 6411f7cc..2e9cfb8f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -19,7 +19,9 @@ from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .container import Container -from .network import Network +from .network import build_networks +from .network import get_networks +from .network import ProjectNetworks from .service import ContainerNetworkMode from .service import ConvergenceStrategy from .service import NetworkMode @@ -36,15 +38,12 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, - use_networking=False, network_driver=None): + def __init__(self, name, services, client, networks=None, volumes=None): self.name = name self.services = services self.client = client - self.use_networking = use_networking - self.network_driver = network_driver - self.networks = networks or [] self.volumes = volumes or {} + self.networks = networks or ProjectNetworks({}, False) def labels(self, one_off=False): return [ @@ -58,23 +57,12 @@ class Project(object): Construct a Project from a config.Config object. """ use_networking = (config_data.version and config_data.version != V1) - project = cls(name, [], client, use_networking=use_networking) - - network_config = config_data.networks or {} - custom_networks = [ - Network( - client=client, project=name, name=network_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - ipam=data.get('ipam'), - external_name=data.get('external_name'), - ) - for network_name, data in network_config.items() - ] - - all_networks = custom_networks[:] - if 'default' not in network_config: - all_networks.append(project.default_network) + networks = build_networks(name, config_data, client) + project_networks = ProjectNetworks.from_services( + config_data.services, + networks, + use_networking) + project = cls(name, [], client, project_networks) if config_data.volumes: for vol_name, data in config_data.volumes.items(): @@ -86,13 +74,15 @@ class Project(object): ) for service_dict in config_data.services: + service_dict = dict(service_dict) if use_networking: - networks = get_networks(service_dict, all_networks) + service_networks = get_networks(service_dict, networks) else: - networks = [] + service_networks = [] + service_dict.pop('networks', None) links = project.get_links(service_dict) - network_mode = project.get_network_mode(service_dict, networks) + network_mode = project.get_network_mode(service_dict, service_networks) volumes_from = get_volumes_from(project, service_dict) if config_data.version != V1: @@ -109,17 +99,13 @@ class Project(object): client=client, project=name, use_networking=use_networking, - networks=networks, + networks=service_networks, links=links, network_mode=network_mode, volumes_from=volumes_from, **service_dict) ) - project.networks += custom_networks - if 'default' not in network_config and project.uses_default_network(): - project.networks.append(project.default_network) - return project @property @@ -201,7 +187,7 @@ class Project(object): def get_network_mode(self, service_dict, networks): network_mode = service_dict.pop('network_mode', None) if not network_mode: - if self.use_networking: + if self.networks.use_networking: return NetworkMode(networks[0]) if networks else NetworkMode('none') return NetworkMode(None) @@ -285,7 +271,7 @@ class Project(object): def down(self, remove_image_type, include_volumes): self.stop() self.remove_stopped(v=include_volumes) - self.remove_networks() + self.networks.remove() if include_volumes: self.remove_volumes() @@ -296,33 +282,10 @@ class Project(object): for service in self.get_services(): service.remove_image(remove_image_type) - def remove_networks(self): - if not self.use_networking: - return - for network in self.networks: - network.remove() - def remove_volumes(self): for volume in self.volumes.values(): volume.remove() - def initialize_networks(self): - if not self.use_networking: - return - - for network in self.networks: - network.ensure() - - def uses_default_network(self): - return any( - self.default_network.full_name in service.networks - for service in self.services - ) - - @property - def default_network(self): - return Network(client=self.client, project=self.name, name='default') - def restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) parallel.parallel_restart(containers, options) @@ -392,7 +355,7 @@ class Project(object): plans = self._get_convergence_plans(services, strategy) - self.initialize_networks() + self.networks.initialize() self.initialize_volumes() return [ @@ -465,22 +428,6 @@ class Project(object): return acc + dep_services -def get_networks(service_dict, network_definitions): - if 'network_mode' in service_dict: - return [] - - networks = [] - for name in service_dict.pop('networks', ['default']): - matches = [n for n in network_definitions if n.name == name] - if matches: - networks.append(matches[0].full_name) - else: - raise ConfigurationError( - 'Service "{}" uses an undefined network "{}"' - .format(service_dict['name'], name)) - return networks - - def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) if not volumes_from: diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 21c6be47..bec238de 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -45,7 +45,7 @@ class ProjectTest(unittest.TestCase): self.assertEqual(project.get_service('web').options['image'], 'busybox:latest') self.assertEqual(project.get_service('db').name, 'db') self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') - self.assertFalse(project.use_networking) + self.assertFalse(project.networks.use_networking) def test_from_config_v2(self): config = Config( @@ -65,7 +65,7 @@ class ProjectTest(unittest.TestCase): ) project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) - self.assertTrue(project.use_networking) + self.assertTrue(project.networks.use_networking) def test_get_service(self): web = Service( @@ -426,7 +426,7 @@ class ProjectTest(unittest.TestCase): ), ) - assert project.uses_default_network() + assert 'default' in project.networks.networks def test_uses_default_network_false(self): project = Project.from_config( @@ -446,7 +446,7 @@ class ProjectTest(unittest.TestCase): ), ) - assert not project.uses_default_network() + assert 'default' not in project.networks.networks def test_container_without_name(self): self.mock_client.containers.return_value = [ From 0810eeba106f71fc374c791283a84cd9f6725e8f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 16:00:50 -0500 Subject: [PATCH 089/580] Don't initialize networks that aren't used by any services. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/network.py | 11 ++++++++--- compose/project.py | 13 ++++++++----- tests/acceptance/cli_test.py | 16 ++++++++-------- tests/integration/project_test.py | 7 ++++++- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 93c4729f..5121d5c3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -693,7 +693,7 @@ def run_one_off_container(container_options, project, service, options): start_deps=True, strategy=ConvergenceStrategy.never) - project.initialize_networks() + project.initialize() container = service.create_container( quiet=True, diff --git a/compose/network.py b/compose/network.py index 4f4e06b2..36de4502 100644 --- a/compose/network.py +++ b/compose/network.py @@ -133,12 +133,17 @@ class ProjectNetworks(object): @classmethod def from_services(cls, services, networks, use_networking): - networks = { - network: networks[network] + service_networks = { + network: networks.get(network) for service in services for network in service.get('networks', ['default']) } - return cls(networks, use_networking) + unused = set(networks) - set(service_networks) - {'default'} + if unused: + log.warn( + "Some networks were defined but are not used by any service: " + "{}".format(", ".join(unused))) + return cls(service_networks, use_networking) def remove(self): if not self.use_networking: diff --git a/compose/project.py b/compose/project.py index 2e9cfb8f..291e32b1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -351,13 +351,12 @@ class Project(object): timeout=DEFAULT_TIMEOUT, detached=False): - services = self.get_services_without_duplicate(service_names, include_deps=start_deps) + self.initialize() + services = self.get_services_without_duplicate( + service_names, + include_deps=start_deps) plans = self._get_convergence_plans(services, strategy) - - self.networks.initialize() - self.initialize_volumes() - return [ container for service in services @@ -369,6 +368,10 @@ class Project(object): ) ] + def initialize(self): + self.networks.initialize() + self.initialize_volumes() + def _get_convergence_plans(self, services, strategy): plans = {} diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 447b1e32..6b9a28e5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -406,7 +406,8 @@ class CLITestCase(DockerClientTestCase): services = self.project.get_services() - networks = self.client.networks(names=[self.project.default_network.full_name]) + network_name = self.project.networks.networks['default'] + networks = self.client.networks(names=[network_name]) self.assertEqual(len(networks), 1) self.assertEqual(networks[0]['Driver'], 'bridge') assert 'com.docker.network.bridge.enable_icc' not in networks[0]['Options'] @@ -439,7 +440,9 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['-f', filename, 'up', '-d'], None) - networks = self.client.networks(names=[self.project.default_network.full_name]) + network_name = self.project.networks.networks['default'] + networks = self.client.networks(names=[network_name]) + assert networks[0]['Options']['com.docker.network.bridge.enable_icc'] == 'false' @v2_only() @@ -586,18 +589,15 @@ class CLITestCase(DockerClientTestCase): n['Name'] for n in self.client.networks() if n['Name'].startswith('{}_'.format(self.project.name)) ] - - assert sorted(network_names) == [ - '{}_{}'.format(self.project.name, name) - for name in ['bar', 'foo'] - ] + assert network_names == [] def test_up_with_links_v1(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'web'], None) # No network was created - networks = self.client.networks(names=[self.project.default_network.full_name]) + network_name = self.project.networks.networks['default'] + networks = self.client.networks(names=[network_name]) assert networks == [] web = self.project.get_service('web') diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 180c9df1..79644e59 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,6 +565,7 @@ class ProjectTest(DockerClientTestCase): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', + 'networks': ['foo', 'bar', 'baz'], }], volumes={}, networks={ @@ -594,7 +595,11 @@ class ProjectTest(DockerClientTestCase): def test_up_with_ipam_config(self): config_data = config.Config( version=V2_0, - services=[], + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': ['front'], + }], volumes={}, networks={ 'front': { From e551988616021b50c8069b5d36a808afee5a8653 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 16:30:24 -0500 Subject: [PATCH 090/580] Include networks in the config_hash. Signed-off-by: Daniel Nephin --- compose/network.py | 10 +++++++--- compose/project.py | 1 + compose/service.py | 1 + tests/acceptance/cli_test.py | 6 +++--- tests/integration/project_test.py | 3 ++- tests/unit/service_test.py | 8 +++++--- 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/compose/network.py b/compose/network.py index 36de4502..82a78f3b 100644 --- a/compose/network.py +++ b/compose/network.py @@ -136,7 +136,7 @@ class ProjectNetworks(object): service_networks = { network: networks.get(network) for service in services - for network in service.get('networks', ['default']) + for network in get_network_names_for_service(service) } unused = set(networks) - set(service_networks) - {'default'} if unused: @@ -159,12 +159,15 @@ class ProjectNetworks(object): network.ensure() -def get_networks(service_dict, network_definitions): +def get_network_names_for_service(service_dict): if 'network_mode' in service_dict: return [] + return service_dict.get('networks', ['default']) + +def get_networks(service_dict, network_definitions): networks = [] - for name in service_dict.pop('networks', ['default']): + for name in get_network_names_for_service(service_dict): network = network_definitions.get(name) if network: networks.append(network.full_name) @@ -172,4 +175,5 @@ def get_networks(service_dict, network_definitions): raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' .format(service_dict['name'], name)) + return networks diff --git a/compose/project.py b/compose/project.py index 291e32b1..a90ec2c3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -96,6 +96,7 @@ class Project(object): project.services.append( Service( + service_dict.pop('name'), client=client, project=name, use_networking=use_networking, diff --git a/compose/service.py b/compose/service.py index 2ca0e64d..e6aad3ae 100644 --- a/compose/service.py +++ b/compose/service.py @@ -472,6 +472,7 @@ class Service(object): 'image_id': self.image()['Id'], 'links': self.get_link_names(), 'net': self.network_mode.id, + 'networks': self.networks, 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6b9a28e5..032900d5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -406,7 +406,7 @@ class CLITestCase(DockerClientTestCase): services = self.project.get_services() - network_name = self.project.networks.networks['default'] + network_name = self.project.networks.networks['default'].full_name networks = self.client.networks(names=[network_name]) self.assertEqual(len(networks), 1) self.assertEqual(networks[0]['Driver'], 'bridge') @@ -440,7 +440,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['-f', filename, 'up', '-d'], None) - network_name = self.project.networks.networks['default'] + network_name = self.project.networks.networks['default'].full_name networks = self.client.networks(names=[network_name]) assert networks[0]['Options']['com.docker.network.bridge.enable_icc'] == 'false' @@ -596,7 +596,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['up', '-d', 'web'], None) # No network was created - network_name = self.project.networks.networks['default'] + network_name = self.project.networks.networks['default'].full_name networks = self.client.networks(names=[network_name]) assert networks == [] diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 79644e59..45bae2c3 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -832,7 +832,8 @@ class ProjectTest(DockerClientTestCase): ) project = Project.from_config( name='composetest', - config_data=config_data, client=self.client + config_data=config_data, + client=self.client ) with self.assertRaises(config.ConfigurationError) as e: project.initialize_volumes() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 74e9f0f5..f34de3bf 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -266,7 +266,7 @@ class ServiceTest(unittest.TestCase): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], - '3c85881a8903b9d73a06c41860c8be08acce1494ab4cf8408375966dccd714de') + 'f8bfa1058ad1f4231372a0b1639f0dfdb574dafff4e8d7938049ae993f7cf1fc') self.assertEqual( opts['environment'], { @@ -417,9 +417,10 @@ class ServiceTest(unittest.TestCase): 'options': {'image': 'example.com/foo'}, 'links': [('one', 'one')], 'net': 'other', + 'networks': [], 'volumes_from': [('two', 'rw')], } - self.assertEqual(config_dict, expected) + assert config_dict == expected def test_config_dict_with_network_mode_from_container(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} @@ -437,10 +438,11 @@ class ServiceTest(unittest.TestCase): 'image_id': 'abcd', 'options': {'image': 'example.com/foo'}, 'links': [], + 'networks': [], 'net': 'aaabbb', 'volumes_from': [], } - self.assertEqual(config_dict, expected) + assert config_dict == expected def test_remove_image_none(self): web = Service('web', image='example', client=self.mock_client) From 3d3388d59ba94b074b38418fcec8da1ccddd7b58 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 17:31:27 -0500 Subject: [PATCH 091/580] Extract volume init and removal from project. Signed-off-by: Daniel Nephin --- compose/project.py | 72 +++++-------------------------- compose/volume.py | 70 ++++++++++++++++++++++++++++++ tests/integration/project_test.py | 12 +++--- 3 files changed, 86 insertions(+), 68 deletions(-) diff --git a/compose/project.py b/compose/project.py index a90ec2c3..62e1d2cd 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,7 +6,6 @@ import logging from functools import reduce from docker.errors import APIError -from docker.errors import NotFound from . import parallel from .config import ConfigurationError @@ -28,7 +27,7 @@ from .service import NetworkMode from .service import Service from .service import ServiceNetworkMode from .utils import microseconds_from_time_nano -from .volume import Volume +from .volume import ProjectVolumes log = logging.getLogger(__name__) @@ -42,7 +41,7 @@ class Project(object): self.name = name self.services = services self.client = client - self.volumes = volumes or {} + self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) def labels(self, one_off=False): @@ -62,16 +61,8 @@ class Project(object): config_data.services, networks, use_networking) - project = cls(name, [], client, project_networks) - - if config_data.volumes: - for vol_name, data in config_data.volumes.items(): - project.volumes[vol_name] = Volume( - client=client, project=name, name=vol_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - external_name=data.get('external_name') - ) + volumes = ProjectVolumes.from_config(name, config_data, client) + project = cls(name, [], client, project_networks, volumes) for service_dict in config_data.services: service_dict = dict(service_dict) @@ -86,13 +77,10 @@ class Project(object): volumes_from = get_volumes_from(project, service_dict) if config_data.version != V1: - service_volumes = service_dict.get('volumes', []) - for volume_spec in service_volumes: - if volume_spec.is_named_volume: - declared_volume = project.volumes[volume_spec.external] - service_volumes[service_volumes.index(volume_spec)] = ( - volume_spec._replace(external=declared_volume.full_name) - ) + service_dict['volumes'] = [ + volumes.namespace_spec(volume_spec) + for volume_spec in service_dict.get('volumes', []) + ] project.services.append( Service( @@ -233,49 +221,13 @@ class Project(object): def remove_stopped(self, service_names=None, **options): parallel.parallel_remove(self.containers(service_names, stopped=True), options) - def initialize_volumes(self): - try: - for volume in self.volumes.values(): - if volume.external: - log.debug( - 'Volume {0} declared as external. No new ' - 'volume will be created.'.format(volume.name) - ) - if not volume.exists(): - raise ConfigurationError( - 'Volume {name} declared as external, but could' - ' not be found. Please create the volume manually' - ' using `{command}{name}` and try again.'.format( - name=volume.full_name, - command='docker volume create --name=' - ) - ) - continue - volume.create() - except NotFound: - raise ConfigurationError( - 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) - ) - except APIError as e: - if 'Choose a different volume name' in str(e): - 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'] - ) - ) - def down(self, remove_image_type, include_volumes): self.stop() self.remove_stopped(v=include_volumes) self.networks.remove() if include_volumes: - self.remove_volumes() + self.volumes.remove() self.remove_images(remove_image_type) @@ -283,10 +235,6 @@ class Project(object): for service in self.get_services(): service.remove_image(remove_image_type) - def remove_volumes(self): - for volume in self.volumes.values(): - volume.remove() - def restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) parallel.parallel_restart(containers, options) @@ -371,7 +319,7 @@ class Project(object): def initialize(self): self.networks.initialize() - self.initialize_volumes() + self.volumes.initialize() def _get_convergence_plans(self, services, strategy): plans = {} diff --git a/compose/volume.py b/compose/volume.py index 469e406a..2713fd32 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -3,8 +3,10 @@ from __future__ import unicode_literals import logging +from docker.errors import APIError from docker.errors import NotFound +from .config import ConfigurationError log = logging.getLogger(__name__) @@ -50,3 +52,71 @@ class Volume(object): if self.external_name: return self.external_name return '{0}_{1}'.format(self.project, self.name) + + +class ProjectVolumes(object): + + def __init__(self, volumes): + self.volumes = volumes + + @classmethod + def from_config(cls, name, config_data, client): + config_volumes = config_data.volumes or {} + volumes = { + vol_name: Volume( + client=client, + project=name, + name=vol_name, + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external_name=data.get('external_name')) + for vol_name, data in config_volumes.items() + } + return cls(volumes) + + def remove(self): + for volume in self.volumes.values(): + volume.remove() + + def initialize(self): + try: + for volume in self.volumes.values(): + if volume.external: + log.debug( + 'Volume {0} declared as external. No new ' + 'volume will be created.'.format(volume.name) + ) + if not volume.exists(): + raise ConfigurationError( + 'Volume {name} declared as external, but could' + ' not be found. Please create the volume manually' + ' using `{command}{name}` and try again.'.format( + name=volume.full_name, + command='docker volume create --name=' + ) + ) + continue + volume.create() + except NotFound: + raise ConfigurationError( + 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) + ) + except APIError as e: + if 'Choose a different volume name' in str(e): + 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'] + ) + ) + + def namespace_spec(self, volume_spec): + if not volume_spec.is_named_volume: + return volume_spec + + volume = self.volumes[volume_spec.external] + return volume_spec._replace(external=volume.full_name) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 45bae2c3..6bb076a3 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -749,7 +749,7 @@ class ProjectTest(DockerClientTestCase): name='composetest', config_data=config_data, client=self.client ) - project.initialize_volumes() + project.volumes.initialize() volume_data = self.client.inspect_volume(full_vol_name) self.assertEqual(volume_data['Name'], full_vol_name) @@ -800,7 +800,7 @@ class ProjectTest(DockerClientTestCase): config_data=config_data, client=self.client ) with self.assertRaises(config.ConfigurationError): - project.initialize_volumes() + project.volumes.initialize() @v2_only() def test_initialize_volumes_updated_driver(self): @@ -821,7 +821,7 @@ class ProjectTest(DockerClientTestCase): name='composetest', config_data=config_data, client=self.client ) - project.initialize_volumes() + project.volumes.initialize() volume_data = self.client.inspect_volume(full_vol_name) self.assertEqual(volume_data['Name'], full_vol_name) @@ -836,7 +836,7 @@ class ProjectTest(DockerClientTestCase): client=self.client ) with self.assertRaises(config.ConfigurationError) as e: - project.initialize_volumes() + project.volumes.initialize() assert 'Configuration for volume {0} specifies driver smb'.format( vol_name ) in str(e.exception) @@ -863,7 +863,7 @@ class ProjectTest(DockerClientTestCase): name='composetest', config_data=config_data, client=self.client ) - project.initialize_volumes() + project.volumes.initialize() with self.assertRaises(NotFound): self.client.inspect_volume(full_vol_name) @@ -889,7 +889,7 @@ class ProjectTest(DockerClientTestCase): config_data=config_data, client=self.client ) with self.assertRaises(config.ConfigurationError) as e: - project.initialize_volumes() + project.volumes.initialize() assert 'Volume {0} declared as external'.format( vol_name ) in str(e.exception) From 2651c00f0c6255798c7e39a0407cd2357cd37b25 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Feb 2016 19:31:49 +0000 Subject: [PATCH 092/580] Connect container to networks before starting it Signed-off-by: Aanand Prasad --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index e6aad3ae..652ee794 100644 --- a/compose/service.py +++ b/compose/service.py @@ -424,8 +424,8 @@ class Service(object): return self.start_container(container) def start_container(self, container): - container.start() self.connect_container_to_networks(container) + container.start() return container def connect_container_to_networks(self, container): From a713447e0b746838ebaed192cadd4cbd3caba2af Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Tue, 2 Feb 2016 12:04:13 -0800 Subject: [PATCH 093/580] Fixing duplicate identifiers Signed-off-by: Mary Anthony --- docs/faq.md | 1 + docs/reference/down.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 8fa629de..73596c18 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,6 +4,7 @@ title = "Frequently Asked Questions" description = "Docker Compose FAQ" keywords = "documentation, docs, docker, compose, faq" [menu.main] +identifier="faq.compose" parent="workw_compose" weight=90 +++ diff --git a/docs/reference/down.md b/docs/reference/down.md index 428e4e58..2495abea 100644 --- a/docs/reference/down.md +++ b/docs/reference/down.md @@ -4,7 +4,7 @@ title = "down" description = "down" keywords = ["fig, composition, compose, docker, orchestration, cli, down"] [menu.main] -identifier="build.compose" +identifier="down.compose" parent = "smn_compose_cli" +++ From f612bc98d9da19e5b1b75a8239dd138c9d146c27 Mon Sep 17 00:00:00 2001 From: Spencer Rinehart Date: Wed, 3 Feb 2016 14:34:36 -0600 Subject: [PATCH 094/580] Fix example formatting for depends_on. Markdown was acting against expectations here by merging the example indented YAML into the previous list item instead of treating it as a code block. I decided that a better way of handling this would be to add a "Simple example:" line that is also used elsewhere in this file. It resets the markdown indentation in a way that works. Signed-off-by: Spencer Rinehart --- docs/compose-file.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index e5326566..c8f55992 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -181,6 +181,8 @@ Express dependency between services, which has two effects: dependencies. In the following example, `docker-compose up web` will also create and start `db` and `redis`. +Simple example: + version: 2 services: web: From bdddbe3a73266dd42d2f2baaa2c008aebc3b089e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 3 Feb 2016 17:42:57 -0800 Subject: [PATCH 095/580] Improve names in Compose file 2 example Just makes it a bit clearer what's going on. Signed-off-by: Ben Firshman --- docs/compose-file.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index e5326566..b86a8b45 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -862,21 +862,21 @@ A more extended example, defining volumes and networks: volumes: - .:/code networks: - - front - - back + - front-tier + - back-tier redis: image: redis volumes: - - data:/var/lib/redis + - redis-data:/var/lib/redis networks: - - back + - back-tier volumes: - data: + redis-data: driver: local networks: - front: + front-tier: driver: bridge - back: + back-tier: driver: bridge From a7c298799130a8eb07844bd4ecbb7b7dc461b7f7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 4 Feb 2016 12:17:20 -0500 Subject: [PATCH 096/580] Update docs for version being a string. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 16 ++++++++-------- docs/networking.md | 8 ++++---- docs/overview.md | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index c8f55992..0b7c6dcb 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -183,7 +183,7 @@ Express dependency between services, which has two effects: Simple example: - version: 2 + version: '2' services: web: build: . @@ -658,7 +658,7 @@ In the example below, instead of attemping to create a volume called `[projectname]_data`, Compose will look for an existing volume simply called `data` and mount it into the `db` service's containers. - version: 2 + version: '2' services: db: @@ -748,7 +748,7 @@ attemping to create a network called `[projectname]_outside`, Compose will look for an existing network simply called `outside` and connect the `proxy` service's containers to it. - version: 2 + version: '2' services: proxy: @@ -780,7 +780,7 @@ There are two versions of the Compose file format: - Version 1, the legacy format. This is specified by omitting a `version` key at the root of the YAML. -- Version 2, the recommended format. This is specified with a `version: 2` entry +- Version 2, the recommended format. This is specified with a `version: '2'` entry at the root of the YAML. To move your project from version 1 to 2, see the [Upgrading](#upgrading) @@ -842,7 +842,7 @@ under the `networks` key. Simple example: - version: 2 + version: '2' services: web: build: . @@ -855,7 +855,7 @@ Simple example: A more extended example, defining volumes and networks: - version: 2 + version: '2' services: web: build: . @@ -887,7 +887,7 @@ A more extended example, defining volumes and networks: In the majority of cases, moving from version 1 to 2 is a very simple process: 1. Indent the whole file by one level and put a `services:` key at the top. -2. Add a `version: 2` line at the top of the file. +2. Add a `version: '2'` line at the top of the file. It's more complicated if you're using particular configuration features: @@ -950,7 +950,7 @@ It's more complicated if you're using particular configuration features: named volume called `data`, you must declare a `data` volume in your top-level `volumes` section. The whole file might look like this: - version: 2 + version: '2' services: db: image: postgres diff --git a/docs/networking.md b/docs/networking.md index 7d819d18..d625ca19 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -28,7 +28,7 @@ identical to the container name. For example, suppose your app is in a directory called `myapp`, and your `docker-compose.yml` looks like this: - version: 2 + version: '2' services: web: @@ -63,7 +63,7 @@ If any containers have connections open to the old container, they will be close Links allow you to define extra aliases by which a service is reachable from another service. They are not required to enable services to communicate - by default, any service can reach any other service at that service's name. In the following example, `db` is reachable from `web` at the hostnames `db` and `database`: - version: 2 + version: '2' services: web: build: . @@ -86,7 +86,7 @@ Each service can specify what networks to connect to with the *service-level* `n Here's an example Compose file defining two custom networks. The `proxy` service is isolated from the `db` service, because they do not share a network in common - only `app` can talk to both. - version: 2 + version: '2' services: proxy: @@ -123,7 +123,7 @@ For full details of the network configuration options available, see the followi Instead of (or as well as) specifying your own networks, you can also change the settings of the app-wide default network by defining an entry under `networks` named `default`: - version: 2 + version: '2' services: web: diff --git a/docs/overview.md b/docs/overview.md index f837d82f..bb3c5d71 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -32,7 +32,7 @@ they can be run together in an isolated environment. A `docker-compose.yml` looks like this: - version: 2 + version: '2' services: web: build: . From be236d88013b26d21a91f11afb1ceff748a24a89 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Feb 2016 16:01:11 +0000 Subject: [PATCH 097/580] Update docker-py and dockerpty Signed-off-by: Aanand Prasad --- compose/cli/main.py | 5 +++-- requirements.txt | 4 ++-- setup.py | 4 ++-- tests/unit/cli_test.py | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5121d5c3..deb1e912 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -42,7 +42,7 @@ from .utils import yesno if not IS_WINDOWS_PLATFORM: - from dockerpty.pty import PseudoTerminal + from dockerpty.pty import PseudoTerminal, RunOperation log = logging.getLogger(__name__) console_handler = logging.StreamHandler(sys.stderr) @@ -712,12 +712,13 @@ def run_one_off_container(container_options, project, service, options): signals.set_signal_handler_to_shutdown() try: try: - pty = PseudoTerminal( + operation = RunOperation( project.client, container.id, interactive=not options['-T'], logs=False, ) + pty = PseudoTerminal(project.client, operation) sockets = pty.sockets() service.start_container(container) pty.start(sockets) diff --git a/requirements.txt b/requirements.txt index 68ef9f35..3fdd34ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.0rc3 +docker-py==1.7.0 +dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/d11wtq/dockerpty.git@29b1394108b017ef3e3deaf00604a9eb99880d5e#egg=dockerpty jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/setup.py b/setup.py index b365e05b..df4172ce 100644 --- a/setup.py +++ b/setup.py @@ -34,8 +34,8 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.5.0, < 2', - 'dockerpty >= 0.3.4, < 0.4', + 'docker-py >= 1.7.0, < 2', + 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', ] diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fec7cdba..69236e2e 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -79,8 +79,9 @@ class CLITestCase(unittest.TestCase): TopLevelCommand().dispatch(['help', 'nonexistent'], None) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") + @mock.patch('compose.cli.main.RunOperation', autospec=True) @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) - def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal): + def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) mock_project = mock.Mock(client=mock_client) @@ -106,7 +107,7 @@ class CLITestCase(unittest.TestCase): '--name': None, }) - _, _, call_kwargs = mock_pseudo_terminal.mock_calls[0] + _, _, call_kwargs = mock_run_operation.mock_calls[0] assert call_kwargs['logs'] is False @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") From 869e815213569cec8592c6d0529104489ba557f2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Feb 2016 23:46:41 +0000 Subject: [PATCH 098/580] Bump 1.7.0dev Signed-off-by: Aanand Prasad --- CHANGELOG.md | 117 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 4 +- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79aee75f..d115f05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,123 @@ Change log ========== +1.6.0 (2016-01-15) +------------------ + +Major Features: + +- Compose 1.6 introduces a new format for `docker-compose.yml` which lets + you define networks and volumes in the Compose file as well as services. It + also makes a few changes to the structure of some configuration options. + + You don't have to use it - your existing Compose files will run on Compose + 1.6 exactly as they do today. + + Check the upgrade guide for full details: + https://docs.docker.com/compose/compose-file/upgrading + +- Support for networking has exited experimental status and is the recommended + way to enable communication between containers. + + If you use the new file format, your app will use networking. If you aren't + ready yet, just leave your Compose file as it is and it'll continue to work + just the same. + + By default, you don't have to configure any networks. In fact, using + networking with Compose involves even less configuration than using links. + Consult the networking guide for how to use it: + https://docs.docker.com/compose/networking + + The experimental flags `--x-networking` and `--x-network-driver`, introduced + in Compose 1.5, have been removed. + +- You can now pass arguments to a build if you're using the new file format: + + build: + context: . + args: + buildno: 1 + +- You can now specify both a `build` and an `image` key if you're using the + new file format. `docker-compose build` will build the image and tag it with + the name you've specified, while `docker-compose pull` will attempt to pull + it. + +- There's a new `events` command for monitoring container events from + the application, much like `docker events`. This is a good primitive for + building tools on top of Compose for performing actions when particular + things happen, such as containers starting and stopping. + +- There's a new `depends_on` option for specifying dependencies between + services. This enforces the order of startup, and ensures that when you run + `docker-compose up SERVICE` on a service with dependencies, those are started + as well. + +New Features: + +- Added a new command `config` which validates and prints the Compose + configuration after interpolating variables, resolving relative paths, and + merging multiple files and `extends`. + +- Added a new command `create` for creating containers without starting them. + +- Added a new command `down` to stop and remove all the resources created by + `up` in a single command. + +- Added support for the `cpu_quota` configuration option. + +- Added support for the `stop_signal` configuration option. + +- Commands `start`, `restart`, `pause`, and `unpause` now exit with an + error status code if no containers were modified. + +- Added a new `--abort-on-container-exit` flag to `up` which causes `up` to + stop all container and exit once the first container exits. + +- Removed support for `FIG_FILE`, `FIG_PROJECT_NAME`, and no longer reads + `fig.yml` as a default Compose file location. + +- Removed the `migrate-to-labels` command. + +- Removed the `--allow-insecure-ssl` flag. + + +Bug Fixes: + +- Fixed a validation bug that prevented the use of a range of ports in + the `expose` field. + +- Fixed a validation bug that prevented the use of arrays in the `entrypoint` + field if they contained duplicate entries. + +- Fixed a bug that caused `ulimits` to be ignored when used with `extends`. + +- Fixed a bug that prevented ipv6 addresses in `extra_hosts`. + +- Fixed a bug that caused `extends` to be ignored when included from + multiple Compose files. + +- Fixed an incorrect warning when a container volume was defined in + the Compose file. + +- Fixed a bug that prevented the force shutdown behaviour of `up` and + `logs`. + +- Fixed a bug that caused `None` to be printed as the network driver name + when the default network driver was used. + +- Fixed a bug where using the string form of `dns` or `dns_search` would + cause an error. + +- Fixed a bug where a container would be reported as "Up" when it was + in the restarting state. + +- Fixed a confusing error message when DOCKER_CERT_PATH was not set properly. + +- Fixed a bug where attaching to a container would fail if it was using a + non-standard logging driver (or none at all). + + 1.5.2 (2015-12-03) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 3ba90fde..fedc90ff 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.6.0dev' +__version__ = '1.7.0dev' diff --git a/docs/install.md b/docs/install.md index 3aaca8fa..cc3890f9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ first. To install Compose, do the following: -1. Install Docker Engine version 1.7.1 or greater: +1. Install Docker Engine: * Mac OS X installation (Toolbox installation includes both Engine and Compose) @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.5.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.6.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). From 57fc85b457f31f17a9ce40d2b4546b1bc6a072d0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 15:26:04 -0500 Subject: [PATCH 099/580] Wrap long lines and restrict lines to 105 characters. Signed-off-by: Daniel Nephin --- compose/cli/docker_client.py | 8 ++++--- compose/cli/main.py | 8 ++++--- compose/config/errors.py | 3 ++- compose/service.py | 4 +++- tests/acceptance/cli_test.py | 7 +++++- tests/integration/project_test.py | 19 +++++++++------ tests/integration/service_test.py | 14 +++++++---- tests/unit/config/config_test.py | 39 ++++++++++++++++++++---------- tests/unit/container_test.py | 4 +++- tests/unit/service_test.py | 40 ++++++++++++++++++++++++------- tox.ini | 3 +-- 11 files changed, 106 insertions(+), 43 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index b680616e..9e79fe77 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -20,14 +20,16 @@ def docker_client(version=None): according to the same logic as the official Docker client. """ if 'DOCKER_CLIENT_TIMEOUT' in os.environ: - log.warn('The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. Please use COMPOSE_HTTP_TIMEOUT instead.') + log.warn("The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. " + "Please use COMPOSE_HTTP_TIMEOUT instead.") try: kwargs = kwargs_from_env(assert_hostname=False) except TLSParameterError: raise UserError( - 'TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY and DOCKER_CERT_PATH are set correctly.\n' - 'You might need to run `eval "$(docker-machine env default)"`') + "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY " + "and DOCKER_CERT_PATH are set correctly.\n" + "You might need to run `eval \"$(docker-machine env default)\"`") if version: kwargs['version'] = version diff --git a/compose/cli/main.py b/compose/cli/main.py index deb1e912..282feebe 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -77,9 +77,11 @@ def main(): sys.exit(1) except ReadTimeout as e: log.error( - "An HTTP request took too long to complete. Retry with --verbose to obtain debug information.\n" - "If you encounter this issue regularly because of slow network conditions, consider setting " - "COMPOSE_HTTP_TIMEOUT to a higher value (current value: %s)." % HTTP_TIMEOUT + "An HTTP request took too long to complete. Retry with --verbose to " + "obtain debug information.\n" + "If you encounter this issue regularly because of slow network " + "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher " + "value (current value: %s)." % HTTP_TIMEOUT ) sys.exit(1) diff --git a/compose/config/errors.py b/compose/config/errors.py index f94ac7ac..d5df7ae5 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -38,7 +38,8 @@ class CircularReference(ConfigurationError): class ComposeFileNotFound(ConfigurationError): def __init__(self, supported_filenames): super(ComposeFileNotFound, self).__init__(""" - Can't find a suitable configuration file in this directory or any parent. Are you in the right directory? + Can't find a suitable configuration file in this directory or any + parent. Are you in the right directory? Supported filenames: %s """ % ", ".join(supported_filenames)) diff --git a/compose/service.py b/compose/service.py index 652ee794..16582c19 100644 --- a/compose/service.py +++ b/compose/service.py @@ -195,7 +195,9 @@ class Service(object): if num_running != len(all_containers): # we have some stopped containers, let's start them up again - stopped_containers = sorted([c for c in all_containers if not c.is_running], key=attrgetter('number')) + stopped_containers = sorted( + (c for c in all_containers if not c.is_running), + key=attrgetter('number')) num_stopped = len(stopped_containers) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 032900d5..56c4ce8d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -887,7 +887,12 @@ class CLITestCase(DockerClientTestCase): def test_run_service_with_explicitly_maped_ip_ports(self): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' - self.dispatch(['run', '-d', '-p', '127.0.0.1:30000:3000', '--publish', '127.0.0.1:30001:3001', 'simple'], None) + self.dispatch([ + 'run', '-d', + '-p', '127.0.0.1:30000:3000', + '--publish', '127.0.0.1:30001:3001', + 'simple' + ]) container = self.project.get_service('simple').containers(one_off=True)[0] # get port information diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6bb076a3..6091bb22 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -242,19 +242,24 @@ class ProjectTest(DockerClientTestCase): db_container = db.create_container() project.start(service_names=['web']) - self.assertEqual(set(c.name for c in project.containers()), set([web_container_1.name, web_container_2.name])) + self.assertEqual( + set(c.name for c in project.containers()), + set([web_container_1.name, web_container_2.name])) project.start() - self.assertEqual(set(c.name for c in project.containers()), - set([web_container_1.name, web_container_2.name, db_container.name])) + self.assertEqual( + set(c.name for c in project.containers()), + set([web_container_1.name, web_container_2.name, db_container.name])) project.pause(service_names=['web']) - self.assertEqual(set([c.name for c in project.containers() if c.is_paused]), - set([web_container_1.name, web_container_2.name])) + self.assertEqual( + set([c.name for c in project.containers() if c.is_paused]), + set([web_container_1.name, web_container_2.name])) project.pause() - self.assertEqual(set([c.name for c in project.containers() if c.is_paused]), - set([web_container_1.name, web_container_2.name, db_container.name])) + self.assertEqual( + set([c.name for c in project.containers() if c.is_paused]), + set([web_container_1.name, web_container_2.name, db_container.name])) project.unpause(service_names=['db']) self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 2) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 189eb9da..65428b7d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -128,7 +128,7 @@ class ServiceTest(DockerClientTestCase): service = self.create_service('db', read_only=read_only) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.ReadonlyRootfs'), read_only, container.get('HostConfig')) + assert container.get('HostConfig.ReadonlyRootfs') == read_only def test_create_container_with_security_opt(self): security_opt = ['label:disable'] @@ -378,7 +378,9 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(len(service.containers()), 0) self.assertEqual(len(service.containers(stopped=True)), 1) - containers = service.execute_convergence_plan(ConvergencePlan('recreate', containers), start=False) + containers = service.execute_convergence_plan( + ConvergencePlan('recreate', containers), + start=False) self.assertEqual(len(service.containers()), 0) self.assertEqual(len(service.containers(stopped=True)), 1) @@ -769,7 +771,9 @@ class ServiceTest(DockerClientTestCase): containers = service.containers() self.assertEqual(len(containers), 2) for container in containers: - self.assertEqual(list(container.inspect()['HostConfig']['PortBindings'].keys()), ['8000/tcp']) + self.assertEqual( + list(container.get('HostConfig.PortBindings')), + ['8000/tcp']) def test_scale_with_immediate_exit(self): service = self.create_service('web', image='busybox', command='true') @@ -846,7 +850,9 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(container.get('Config.WorkingDir'), '/working/dir/sample') def test_split_env(self): - service = self.create_service('web', environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS=']) + service = self.create_service( + 'web', + environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS=']) env = create_and_start_container(service).environment for k, v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.items(): self.assertEqual(env[k], v) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8c9b73dc..0b3b0c75 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1469,24 +1469,42 @@ class VolumeConfigTest(unittest.TestCase): @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') def test_relative_path_does_expand_posix(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['./data:/data']}, working_dir='/home/me/myproject') + d = make_service_dict( + 'foo', + {'build': '.', 'volumes': ['./data:/data']}, + working_dir='/home/me/myproject') self.assertEqual(d['volumes'], ['/home/me/myproject/data:/data']) - d = make_service_dict('foo', {'build': '.', 'volumes': ['.:/data']}, working_dir='/home/me/myproject') + d = make_service_dict( + 'foo', + {'build': '.', 'volumes': ['.:/data']}, + working_dir='/home/me/myproject') self.assertEqual(d['volumes'], ['/home/me/myproject:/data']) - d = make_service_dict('foo', {'build': '.', 'volumes': ['../otherproject:/data']}, working_dir='/home/me/myproject') + d = make_service_dict( + 'foo', + {'build': '.', 'volumes': ['../otherproject:/data']}, + working_dir='/home/me/myproject') self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths') def test_relative_path_does_expand_windows(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['./data:/data']}, working_dir='c:\\Users\\me\\myproject') + d = make_service_dict( + 'foo', + {'build': '.', 'volumes': ['./data:/data']}, + working_dir='c:\\Users\\me\\myproject') self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject\\data:/data']) - d = make_service_dict('foo', {'build': '.', 'volumes': ['.:/data']}, working_dir='c:\\Users\\me\\myproject') + d = make_service_dict( + 'foo', + {'build': '.', 'volumes': ['.:/data']}, + working_dir='c:\\Users\\me\\myproject') self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject:/data']) - d = make_service_dict('foo', {'build': '.', 'volumes': ['../otherproject:/data']}, working_dir='c:\\Users\\me\\myproject') + d = make_service_dict( + 'foo', + {'build': '.', 'volumes': ['../otherproject:/data']}, + working_dir='c:\\Users\\me\\myproject') self.assertEqual(d['volumes'], ['c:\\Users\\me\\otherproject:/data']) @mock.patch.dict(os.environ) @@ -2354,14 +2372,11 @@ class VolumePathTest(unittest.TestCase): @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') def test_split_path_mapping_with_windows_path(self): - windows_volume_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config:/opt/connect/config:ro" - expected_mapping = ( - "/opt/connect/config:ro", - "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" + expected_mapping = ("/opt/connect/config:ro", host_path) mapping = config.split_path_mapping(windows_volume_path) - self.assertEqual(mapping, expected_mapping) diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 88691150..e71defdd 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -148,7 +148,9 @@ class GetContainerNameTestCase(unittest.TestCase): def test_get_container_name(self): self.assertIsNone(get_container_name({})) self.assertEqual(get_container_name({'Name': 'myproject_db_1'}), 'myproject_db_1') - self.assertEqual(get_container_name({'Names': ['/myproject_db_1', '/myproject_web_1/db']}), 'myproject_db_1') + self.assertEqual( + get_container_name({'Names': ['/myproject_db_1', '/myproject_web_1/db']}), + 'myproject_db_1') self.assertEqual( get_container_name({ 'Names': [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f34de3bf..5a2a88ce 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -146,7 +146,13 @@ class ServiceTest(unittest.TestCase): def test_memory_swap_limit(self): self.mock_client.create_host_config.return_value = {} - service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) + service = Service( + name='foo', + image='foo', + hostname='name', + client=self.mock_client, + mem_limit=1000000000, + memswap_limit=2000000000) service._get_container_create_options({'some': 'overrides'}, 1) self.assertTrue(self.mock_client.create_host_config.called) @@ -162,7 +168,12 @@ class ServiceTest(unittest.TestCase): def test_cgroup_parent(self): self.mock_client.create_host_config.return_value = {} - service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, cgroup_parent='test') + service = Service( + name='foo', + image='foo', + hostname='name', + client=self.mock_client, + cgroup_parent='test') service._get_container_create_options({'some': 'overrides'}, 1) self.assertTrue(self.mock_client.create_host_config.called) @@ -176,7 +187,13 @@ class ServiceTest(unittest.TestCase): log_opt = {'syslog-address': 'tcp://192.168.0.42:123'} logging = {'driver': 'syslog', 'options': log_opt} - service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, logging=logging) + service = Service( + name='foo', + image='foo', + hostname='name', + client=self.mock_client, + log_driver='syslog', + logging=logging) service._get_container_create_options({'some': 'overrides'}, 1) self.assertTrue(self.mock_client.create_host_config.called) @@ -348,11 +365,18 @@ class ServiceTest(unittest.TestCase): self.assertEqual(parse_repository_tag("user/repo"), ("user/repo", "", ":")) self.assertEqual(parse_repository_tag("user/repo:tag"), ("user/repo", "tag", ":")) self.assertEqual(parse_repository_tag("url:5000/repo"), ("url:5000/repo", "", ":")) - self.assertEqual(parse_repository_tag("url:5000/repo:tag"), ("url:5000/repo", "tag", ":")) - - self.assertEqual(parse_repository_tag("root@sha256:digest"), ("root", "sha256:digest", "@")) - self.assertEqual(parse_repository_tag("user/repo@sha256:digest"), ("user/repo", "sha256:digest", "@")) - self.assertEqual(parse_repository_tag("url:5000/repo@sha256:digest"), ("url:5000/repo", "sha256:digest", "@")) + self.assertEqual( + parse_repository_tag("url:5000/repo:tag"), + ("url:5000/repo", "tag", ":")) + self.assertEqual( + parse_repository_tag("root@sha256:digest"), + ("root", "sha256:digest", "@")) + self.assertEqual( + parse_repository_tag("user/repo@sha256:digest"), + ("user/repo", "sha256:digest", "@")) + self.assertEqual( + parse_repository_tag("url:5000/repo@sha256:digest"), + ("url:5000/repo", "sha256:digest", "@")) def test_create_container_with_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) diff --git a/tox.ini b/tox.ini index dc85bc6d..5e89c037 100644 --- a/tox.ini +++ b/tox.ini @@ -42,8 +42,7 @@ directory = coverage-html # end coverage configuration [flake8] -# Allow really long lines for now -max-line-length = 140 +max-line-length = 105 # Set this high for now max-complexity = 12 exclude = compose/packages From b6356471054f5115c57109cc2035fca6a6bb5189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Bistuer?= Date: Fri, 5 Feb 2016 09:42:59 +0700 Subject: [PATCH 100/580] Fixed typo in compose-file.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Loïc Bistuer --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index ec90ddcd..e0d447a5 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -645,7 +645,7 @@ documentation for more information. Optional. foo: "bar" baz: 1 -## external +### external If set to `true`, specifies that this volume has been created outside of Compose. `docker-compose up` will not attempt to create it, and will raise From 8acb5e17e8caf26f73d4e0a600650140f713eb43 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 14:32:43 +0000 Subject: [PATCH 101/580] Add pytest section to tox.ini Signed-off-by: Aanand Prasad --- tox.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index dc85bc6d..a18bfda7 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = -rrequirements.txt -rrequirements-dev.txt commands = - py.test -v -rxs \ + py.test -v \ --cov=compose \ --cov-report html \ --cov-report term \ @@ -47,3 +47,6 @@ max-line-length = 140 # Set this high for now max-complexity = 12 exclude = compose/packages + +[pytest] +addopts = --tb=short -rxs From 677c50650c86b4b6fabbc21e18165f2117022bbe Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sat, 6 Feb 2016 02:54:06 -0500 Subject: [PATCH 102/580] Change special case from '_', None to () Signed-off-by: jrabbit --- compose/config/config.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2e4e036c..2eb3f2af 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -464,18 +464,12 @@ def resolve_environment(service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) - d = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) - if '_' in d.keys(): - del d['_'] - return d + return dict(filter(None, (resolve_env_var(k, v) for k, v in six.iteritems(env)))) def resolve_build_args(build): args = parse_build_arguments(build.get('args')) - d = dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) - if '_' in d.keys(): - del d['_'] - return d + return dict(filter(None, (resolve_env_var(k, v) for k, v in six.iteritems(args)))) def validate_extended_service_dict(service_dict, filename, service): @@ -736,7 +730,7 @@ def resolve_env_var(key, val): elif key in os.environ: return key, os.environ[key] else: - return "_", None + return () def env_vars_from_file(filename): From e929086c49225c9dcbd723eab80861059a5c7271 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Mon, 8 Feb 2016 14:29:03 +0100 Subject: [PATCH 103/580] Separate MergePortsTest from MergeListsTest and add MergeNetworksTest. Signed-off-by: Lukas Waslowski --- tests/unit/config/config_test.py | 52 ++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8c9b73dc..0fe1307e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1594,30 +1594,64 @@ class BuildOrImageMergeTest(unittest.TestCase): ) -class MergeListsTest(unittest.TestCase): +class MergeListsTest(object): + def config_name(self): + return "" + + def base_config(self): + return [] + + def override_config(self): + return [] + + def merged_config(self): + return set(self.base_config()) | set(self.override_config()) + def test_empty(self): - assert 'ports' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) + assert self.config_name() not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) def test_no_override(self): service_dict = config.merge_service_dicts( - {'ports': ['10:8000', '9000']}, + {self.config_name(): self.base_config()}, {}, DEFAULT_VERSION) - assert set(service_dict['ports']) == set(['10:8000', '9000']) + assert set(service_dict[self.config_name()]) == set(self.base_config()) def test_no_base(self): service_dict = config.merge_service_dicts( {}, - {'ports': ['10:8000', '9000']}, + {self.config_name(): self.base_config()}, DEFAULT_VERSION) - assert set(service_dict['ports']) == set(['10:8000', '9000']) + assert set(service_dict[self.config_name()]) == set(self.base_config()) def test_add_item(self): service_dict = config.merge_service_dicts( - {'ports': ['10:8000', '9000']}, - {'ports': ['20:8000']}, + {self.config_name(): self.base_config()}, + {self.config_name(): self.override_config()}, DEFAULT_VERSION) - assert set(service_dict['ports']) == set(['10:8000', '9000', '20:8000']) + assert set(service_dict[self.config_name()]) == set(self.merged_config()) + + +class MergePortsTest(unittest.TestCase, MergeListsTest): + def config_name(self): + return 'ports' + + def base_config(self): + return ['10:8000', '9000'] + + def override_config(self): + return ['20:8000'] + + +class MergeNetworksTest(unittest.TestCase, MergeListsTest): + def config_name(self): + return 'networks' + + def base_config(self): + return ['frontend', 'backend'] + + def override_config(self): + return ['monitoring'] class MergeStringsOrListsTest(unittest.TestCase): From 5a3a10d43bba5882f2e41824d2040ed6ad9e1874 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Mon, 8 Feb 2016 15:17:21 +0100 Subject: [PATCH 104/580] Correctly merge the 'services//networks' key in the case of multiple compose files. Fixes docker/compose#2839. Signed-off-by: Lukas Waslowski --- compose/config/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config.py b/compose/config/config.py index f362f1b8..d5d2547e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -698,6 +698,7 @@ def merge_service_dicts(base, override, version): 'depends_on', 'expose', 'external_links', + 'networks', 'ports', 'volumes_from', ]: From 5bd88f634fc35becf3644d0ffadd6ebc39be93ea Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Mon, 8 Feb 2016 15:33:26 +0100 Subject: [PATCH 105/580] Handle the 'network_mode' key when merging multiple compose files. Fixes docker/compose#2840. Signed-off-by: Lukas Waslowski --- compose/config/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config.py b/compose/config/config.py index d5d2547e..174dacab 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -87,6 +87,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'container_name', 'dockerfile', 'logging', + 'network_mode', ] DOCKER_VALID_URL_PREFIXES = ( From 421981e7d26bd5a5558d1bfdc8079749b15bb7bf Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Feb 2016 12:18:48 -0500 Subject: [PATCH 106/580] Use 12 characters for the short id to match docker and fix backwards compatibility. Signed-off-by: Daniel Nephin --- compose/container.py | 2 +- tests/unit/container_test.py | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/compose/container.py b/compose/container.py index 2565c8ff..3a1ce0b9 100644 --- a/compose/container.py +++ b/compose/container.py @@ -60,7 +60,7 @@ class Container(object): @property def short_id(self): - return self.id[:10] + return self.id[:12] @property def name(self): diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 88691150..189b0c99 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -12,8 +12,9 @@ from compose.container import get_container_name class ContainerTest(unittest.TestCase): def setUp(self): + self.container_id = "abcabcabcbabc12345" self.container_dict = { - "Id": "abc", + "Id": self.container_id, "Image": "busybox:latest", "Command": "top", "Created": 1387384730, @@ -41,19 +42,22 @@ class ContainerTest(unittest.TestCase): self.assertEqual( container.dictionary, { - "Id": "abc", + "Id": self.container_id, "Image": "busybox:latest", "Name": "/composetest_db_1", }) def test_from_ps_prefixed(self): - self.container_dict['Names'] = ['/swarm-host-1' + n for n in self.container_dict['Names']] + self.container_dict['Names'] = [ + '/swarm-host-1' + n for n in self.container_dict['Names'] + ] - container = Container.from_ps(None, - self.container_dict, - has_been_inspected=True) + container = Container.from_ps( + None, + self.container_dict, + has_been_inspected=True) self.assertEqual(container.dictionary, { - "Id": "abc", + "Id": self.container_id, "Image": "busybox:latest", "Name": "/composetest_db_1", }) @@ -142,6 +146,10 @@ class ContainerTest(unittest.TestCase): self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"]) self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None) + def test_short_id(self): + container = Container(None, self.container_dict, has_been_inspected=True) + assert container.short_id == self.container_id[:12] + class GetContainerNameTestCase(unittest.TestCase): From 582de19a5a2402d492e67339da46d8c8e2c70d53 Mon Sep 17 00:00:00 2001 From: cr7pt0gr4ph7 Date: Mon, 8 Feb 2016 21:57:15 +0100 Subject: [PATCH 107/580] Simplify unit tests in config/config_test.py by using class variables instead of methods for parametrizing tests. Signed-off-by: cr7pt0gr4ph7 --- tests/unit/config/config_test.py | 88 +++++++++++++------------------- 1 file changed, 35 insertions(+), 53 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0fe1307e..bd57c4a7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1506,57 +1506,54 @@ class VolumeConfigTest(unittest.TestCase): class MergePathMappingTest(object): - def config_name(self): - return "" + config_name = "" def test_empty(self): service_dict = config.merge_service_dicts({}, {}, DEFAULT_VERSION) - assert self.config_name() not in service_dict + assert self.config_name not in service_dict def test_no_override(self): service_dict = config.merge_service_dicts( - {self.config_name(): ['/foo:/code', '/data']}, + {self.config_name: ['/foo:/code', '/data']}, {}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(['/foo:/code', '/data']) + assert set(service_dict[self.config_name]) == set(['/foo:/code', '/data']) def test_no_base(self): service_dict = config.merge_service_dicts( {}, - {self.config_name(): ['/bar:/code']}, + {self.config_name: ['/bar:/code']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(['/bar:/code']) + assert set(service_dict[self.config_name]) == set(['/bar:/code']) def test_override_explicit_path(self): service_dict = config.merge_service_dicts( - {self.config_name(): ['/foo:/code', '/data']}, - {self.config_name(): ['/bar:/code']}, + {self.config_name: ['/foo:/code', '/data']}, + {self.config_name: ['/bar:/code']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/data']) + assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data']) def test_add_explicit_path(self): service_dict = config.merge_service_dicts( - {self.config_name(): ['/foo:/code', '/data']}, - {self.config_name(): ['/bar:/code', '/quux:/data']}, + {self.config_name: ['/foo:/code', '/data']}, + {self.config_name: ['/bar:/code', '/quux:/data']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/quux:/data']) + assert set(service_dict[self.config_name]) == set(['/bar:/code', '/quux:/data']) def test_remove_explicit_path(self): service_dict = config.merge_service_dicts( - {self.config_name(): ['/foo:/code', '/quux:/data']}, - {self.config_name(): ['/bar:/code', '/data']}, + {self.config_name: ['/foo:/code', '/quux:/data']}, + {self.config_name: ['/bar:/code', '/data']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/data']) + assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data']) class MergeVolumesTest(unittest.TestCase, MergePathMappingTest): - def config_name(self): - return 'volumes' + config_name = 'volumes' class MergeDevicesTest(unittest.TestCase, MergePathMappingTest): - def config_name(self): - return 'devices' + config_name = 'devices' class BuildOrImageMergeTest(unittest.TestCase): @@ -1595,63 +1592,48 @@ class BuildOrImageMergeTest(unittest.TestCase): class MergeListsTest(object): - def config_name(self): - return "" - - def base_config(self): - return [] - - def override_config(self): - return [] + config_name = "" + base_config = [] + override_config = [] def merged_config(self): - return set(self.base_config()) | set(self.override_config()) + return set(self.base_config) | set(self.override_config) def test_empty(self): - assert self.config_name() not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) + assert self.config_name not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) def test_no_override(self): service_dict = config.merge_service_dicts( - {self.config_name(): self.base_config()}, + {self.config_name: self.base_config}, {}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(self.base_config()) + assert set(service_dict[self.config_name]) == set(self.base_config) def test_no_base(self): service_dict = config.merge_service_dicts( {}, - {self.config_name(): self.base_config()}, + {self.config_name: self.base_config}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(self.base_config()) + assert set(service_dict[self.config_name]) == set(self.base_config) def test_add_item(self): service_dict = config.merge_service_dicts( - {self.config_name(): self.base_config()}, - {self.config_name(): self.override_config()}, + {self.config_name: self.base_config}, + {self.config_name: self.override_config}, DEFAULT_VERSION) - assert set(service_dict[self.config_name()]) == set(self.merged_config()) + assert set(service_dict[self.config_name]) == set(self.merged_config()) class MergePortsTest(unittest.TestCase, MergeListsTest): - def config_name(self): - return 'ports' - - def base_config(self): - return ['10:8000', '9000'] - - def override_config(self): - return ['20:8000'] + config_name = 'ports' + base_config = ['10:8000', '9000'] + override_config = ['20:8000'] class MergeNetworksTest(unittest.TestCase, MergeListsTest): - def config_name(self): - return 'networks' - - def base_config(self): - return ['frontend', 'backend'] - - def override_config(self): - return ['monitoring'] + config_name = 'networks' + base_config = ['frontend', 'backend'] + override_config = ['monitoring'] class MergeStringsOrListsTest(unittest.TestCase): From 63870fbccd45678917f3b0889d4935440dc8f7f8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Feb 2016 18:15:21 -0500 Subject: [PATCH 108/580] Fix upgrading url. Signed-off-by: Daniel Nephin --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d115f05d..8df63c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Major Features: 1.6 exactly as they do today. Check the upgrade guide for full details: - https://docs.docker.com/compose/compose-file/upgrading + https://docs.docker.com/compose/compose-file#upgrading - Support for networking has exited experimental status and is the recommended way to enable communication between containers. From 481caa8e480aa044e1a00f707c4b9af76677b457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=A4ufl?= Date: Sat, 6 Feb 2016 22:10:22 +0100 Subject: [PATCH 109/580] Used absolute links in readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This prevents links being broken on pypi (e.g. https://pypi.python.org/pypi/docs/index.md#features) Signed-off-by: Michael Käufl --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 595ff1e1..f8822151 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a Compose file to configure your application's services. Then, using a single command, you create and start all the services from your configuration. To learn more about all the features of Compose -see [the list of features](docs/index.md#features). +see [the list of features](https://github.com/docker/compose/blob/release/docs/overview.md#features). Compose is great for development, testing, and staging environments, as well as CI workflows. You can learn more about each case in -[Common Use Cases](docs/overview.md#common-use-cases). +[Common Use Cases](https://github.com/docker/compose/blob/release/docs/overview.md#common-use-cases). Using Compose is basically a three-step process. @@ -34,7 +34,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](docs/compose-file.md) +[Compose file reference](https://github.com/docker/compose/blob/release/docs/compose-file.md) Compose has commands for managing the whole lifecycle of your application: From 59a2920758ca763175093e0f2917d5ead1a117a7 Mon Sep 17 00:00:00 2001 From: Yohan Graterol Date: Tue, 9 Feb 2016 18:40:44 -0500 Subject: [PATCH 110/580] Typo into the doc with `networks` in yaml Signed-off-by: Yohan Graterol --- docs/compose-file.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index ec90ddcd..240fea1e 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -761,14 +761,14 @@ service's containers to it. networks: - default - networks + networks: outside: external: true You can also specify the name of the network separately from the name used to refer to it within the Compose file: - networks + networks: outside: external: name: actual-name-of-network From ab6e07da7d7d1ab9f2b8b0880edf836988227df4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 10 Feb 2016 15:56:50 +0000 Subject: [PATCH 111/580] Fix version in install guide Signed-off-by: Aanand Prasad --- docs/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index cc3890f9..c50d7649 100644 --- a/docs/install.md +++ b/docs/install.md @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.5.2 + docker-compose version: 1.6.0 ## Alternative install options From 37564a73c3844aa8563a4a929a07868f2c09ec6c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 10 Feb 2016 13:54:40 -0500 Subject: [PATCH 112/580] Merge build.args when merging services. Signed-off-by: Daniel Nephin --- compose/config/config.py | 29 ++++++++++++---------------- tests/unit/config/config_test.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 102758e9..ddfcca50 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -713,29 +713,24 @@ def merge_service_dicts(base, override, version): if version == V1: legacy_v1_merge_image_or_build(md, base, override) - else: - merge_build(md, base, override) + elif md.needs_merge('build'): + md['build'] = merge_build(md, base, override) return dict(md) def merge_build(output, base, override): - build = {} + def to_dict(service): + build_config = service.get('build', {}) + if isinstance(build_config, six.string_types): + return {'context': build_config} + return build_config - if 'build' in base: - if isinstance(base['build'], six.string_types): - build['context'] = base['build'] - else: - build.update(base['build']) - - if 'build' in override: - if isinstance(override['build'], six.string_types): - build['context'] = override['build'] - else: - build.update(override['build']) - - if build: - output['build'] = build + md = MergeDict(to_dict(base), to_dict(override)) + md.merge_scalar('context') + md.merge_scalar('dockerfile') + md.merge_mapping('args', parse_build_arguments) + return dict(md) def legacy_v1_merge_image_or_build(output, base, override): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e545aba7..e0456845 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1079,6 +1079,39 @@ class ConfigTest(unittest.TestCase): 'extends': {'service': 'foo'} } + def test_merge_build_args(self): + base = { + 'build': { + 'context': '.', + 'args': { + 'ONE': '1', + 'TWO': '2', + }, + } + } + override = { + 'build': { + 'args': { + 'TWO': 'dos', + 'THREE': '3', + }, + } + } + actual = config.merge_service_dicts( + base, + override, + DEFAULT_VERSION) + assert actual == { + 'build': { + 'context': '.', + 'args': { + 'ONE': '1', + 'TWO': 'dos', + 'THREE': '3', + }, + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 5e6dc3521c2b9bb3cac3553987f173c48ec17579 Mon Sep 17 00:00:00 2001 From: Spencer Rinehart Date: Fri, 5 Feb 2016 10:47:14 -0600 Subject: [PATCH 113/580] Add support for shm_size. Fixes #2823. shm_size controls the size of /dev/shm in the container and requires Docker 1.10 or newer (API version 1.22). This requires docker-py 1.8.0 (docker/docker-py#923). Similar to fields like `mem_limit`, `shm_size` may be specified as either an integer or a string (e.g., `64M`). Updating docker-py to the master branch in order to get the unreleased dependency on `shm_size` there in place. Signed-off-by: Spencer Rinehart --- compose/config/config.py | 1 + compose/config/service_schema_v1.json | 1 + compose/config/service_schema_v2.0.json | 1 + compose/service.py | 2 ++ docs/compose-file.md | 3 ++- requirements.txt | 2 +- tests/integration/service_test.py | 6 ++++++ 7 files changed, 14 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 102758e9..c87d73bb 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -72,6 +72,7 @@ DOCKER_CONFIG_KEYS = [ 'read_only', 'restart', 'security_opt', + 'shm_size', 'stdin_open', 'stop_signal', 'tty', diff --git a/compose/config/service_schema_v1.json b/compose/config/service_schema_v1.json index d220ec54..4d974d71 100644 --- a/compose/config/service_schema_v1.json +++ b/compose/config/service_schema_v1.json @@ -98,6 +98,7 @@ "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, "tty": {"type": "boolean"}, diff --git a/compose/config/service_schema_v2.0.json b/compose/config/service_schema_v2.0.json index 8dd4faf5..d3b294d7 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -127,6 +127,7 @@ "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, "tty": {"type": "boolean"}, diff --git a/compose/service.py b/compose/service.py index 652ee794..6472caeb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -57,6 +57,7 @@ DOCKER_START_KEYS = [ 'volumes_from', 'security_opt', 'cpu_quota', + 'shm_size', ] @@ -654,6 +655,7 @@ class Service(object): ipc_mode=options.get('ipc'), cgroup_parent=options.get('cgroup_parent'), cpu_quota=options.get('cpu_quota'), + shm_size=options.get('shm_size'), ) def build(self, no_cache=False, pull=False, force_rm=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index b2524f66..01fe3683 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -591,7 +591,7 @@ specifying read-only access(``ro``) or read-write(``rw``). > - container_name > - container_name:rw -### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, working\_dir +### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, shm\_size, stdin\_open, tty, user, working\_dir Each of these is a single value, analogous to its [docker run](https://docs.docker.com/engine/reference/run/) counterpart. @@ -615,6 +615,7 @@ Each of these is a single value, analogous to its restart: always read_only: true + shm_size: 64M stdin_open: true tty: true diff --git a/requirements.txt b/requirements.txt index 3fdd34ed..2204e6d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 +git+https://github.com/docker/docker-py.git@bba8e28f822c4cd3ebe2a2ca588f41f9d7d66e26#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 37dc4a0e..9871d440 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -102,6 +102,12 @@ class ServiceTest(DockerClientTestCase): container.start() self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) + def test_create_container_with_shm_size(self): + service = self.create_service('db', shm_size=67108864) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.ShmSize'), 67108864) + def test_create_container_with_extra_hosts_list(self): extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) From ab40d389d05f5f2e08dbea4c43ebb8a467172bf9 Mon Sep 17 00:00:00 2001 From: Spencer Rinehart Date: Fri, 5 Feb 2016 11:46:15 -0600 Subject: [PATCH 114/580] Fix sorting of DOCKER_START_KEYS. Make sure it's sorted! Signed-off-by: Spencer Rinehart --- compose/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 6472caeb..9dd2865b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -40,6 +40,7 @@ DOCKER_START_KEYS = [ 'cap_add', 'cap_drop', 'cgroup_parent', + 'cpu_quota', 'devices', 'dns', 'dns_search', @@ -54,10 +55,9 @@ DOCKER_START_KEYS = [ 'pid', 'privileged', 'restart', - 'volumes_from', 'security_opt', - 'cpu_quota', 'shm_size', + 'volumes_from', ] From ac14642d94718aa3a5d30eea17a41b36056c3488 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Wed, 10 Feb 2016 18:58:01 -0500 Subject: [PATCH 115/580] Typo fixed Signed-off-by: Manuel Kaufmann --- docs/django.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/django.md b/docs/django.md index 573ea3d9..150d3631 100644 --- a/docs/django.md +++ b/docs/django.md @@ -129,7 +129,7 @@ In this step, you create a Django started project by building the image from the In this section, you set up the database connection for Django. -1. In your project dirctory, edit the `composeexample/settings.py` file. +1. In your project directory, edit the `composeexample/settings.py` file. 2. Replace the `DATABASES = ...` with the following: From 643166ae98764e1bfe8b2dad8de349e2c23c7f33 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Wed, 10 Feb 2016 20:47:15 -0800 Subject: [PATCH 116/580] Updating Dockerfile Signed-off-by: Mary Anthony --- docs/Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index 83b65633..5f32dc4d 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -5,9 +5,10 @@ RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/engin RUN svn checkout https://github.com/docker/swarm/trunk/docs /docs/content/swarm RUN svn checkout https://github.com/docker/machine/trunk/docs /docs/content/machine RUN svn checkout https://github.com/docker/distribution/trunk/docs /docs/content/registry -RUN svn checkout https://github.com/kitematic/kitematic/trunk/docs /docs/content/kitematic -RUN svn checkout https://github.com/docker/tutorials/trunk/docs /docs/content/tutorials -RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content +RUN svn checkout https://github.com/docker/notary/trunk/docs /docs/content/notary +RUN svn checkout https://github.com/docker/kitematic/trunk/docs /docs/content/kitematic +RUN svn checkout https://github.com/docker/toolbox/trunk/docs /docs/content/toolbox +RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content/project ENV PROJECT=compose # To get the git info for this repo From 740329a131fb74d45a3821303c4c1818f2bcd171 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Feb 2016 13:50:23 -0500 Subject: [PATCH 117/580] Shm_size requires docker 1.10. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9871d440..1cc6b266 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -103,6 +103,7 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) def test_create_container_with_shm_size(self): + self.require_api_version('1.22') service = self.create_service('db', shm_size=67108864) container = service.create_container() service.start_container(container) From 1e7dd2e7400114c76d9d438d2b4a2f403a816573 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Feb 2016 13:10:29 -0500 Subject: [PATCH 118/580] Upgrade pyinstaller. Signed-off-by: Daniel Nephin --- requirements-build.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-build.txt b/requirements-build.txt index 20aad420..3f1dbd75 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.0 +pyinstaller==3.1.1 From 532dffd68807de1c50e99afe2feaff78c7a00391 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 10 Feb 2016 13:32:04 -0500 Subject: [PATCH 119/580] Fix build section without context. Signed-off-by: Daniel Nephin --- compose/config/config.py | 3 +++ compose/config/service_schema_v2.0.json | 7 ++++++- compose/config/validation.py | 5 ++--- tests/unit/config/config_test.py | 11 +++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index c342abc5..19722b0a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -874,6 +874,9 @@ def validate_paths(service_dict): build_path = build elif isinstance(build, dict) and 'context' in build: build_path = build['context'] + else: + # We have a build section but no context, so nothing to validate + return if ( not is_url(build_path) and diff --git a/compose/config/service_schema_v2.0.json b/compose/config/service_schema_v2.0.json index d3b294d7..f7a67818 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -196,7 +196,12 @@ "anyOf": [ {"required": ["build"]}, {"required": ["image"]} - ] + ], + "properties": { + "build": { + "required": ["context"] + } + } } } } diff --git a/compose/config/validation.py b/compose/config/validation.py index 6b240135..35727e2c 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -253,10 +253,9 @@ def handle_generic_service_error(error, path): msg_format = "{path} contains an invalid type, it should be {msg}" error_msg = _parse_valid_types_from_validator(error.validator_value) - # TODO: no test case for this branch, there are no config options - # which exercise this branch elif error.validator == 'required': - msg_format = "{path} is invalid, {msg}" + error_msg = ", ".join(error.validator_value) + msg_format = "{path} is invalid, {msg} is required." elif error.validator == 'dependencies': config_key = list(error.validator_value.keys())[0] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e0456845..7fecfed3 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1169,6 +1169,17 @@ class ConfigTest(unittest.TestCase): config.load(config_details) assert "Service 'one' depends on service 'three'" in exc.exconly() + def test_load_dockerfile_without_context(self): + config_details = build_config_details({ + 'version': '2', + 'services': { + 'one': {'build': {'dockerfile': 'Dockerfile.foo'}}, + }, + }) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert 'one.build is invalid, context is required.' in exc.exconly() + class NetworkModeTest(unittest.TestCase): def test_network_mode_standard(self): From e225f12551fd056da6c8d96fcef8c8b7a891d386 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Feb 2016 16:49:50 -0800 Subject: [PATCH 120/580] Add logging when initializing a volume. Signed-off-by: Joffrey F --- compose/volume.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/compose/volume.py b/compose/volume.py index 2713fd32..26fbda96 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -64,12 +64,13 @@ class ProjectVolumes(object): config_volumes = config_data.volumes or {} volumes = { vol_name: Volume( - client=client, - project=name, - name=vol_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - external_name=data.get('external_name')) + client=client, + project=name, + name=vol_name, + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external_name=data.get('external_name') + ) for vol_name, data in config_volumes.items() } return cls(volumes) @@ -96,6 +97,11 @@ class ProjectVolumes(object): ) ) continue + log.info( + 'Creating volume "{0}" with {1} driver'.format( + volume.full_name, volume.driver or 'default' + ) + ) volume.create() except NotFound: raise ConfigurationError( From 79f993f52c9c2a358c48a9d0aea46fe832e7b167 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Feb 2016 16:35:27 -0800 Subject: [PATCH 121/580] Detailed error message when daemon version is too old. Signed-off-by: Joffrey F --- compose/cli/main.py | 19 ++++++++++++++++++- compose/const.py | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index deb1e912..7413c53c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ from ..config import config from ..config import ConfigurationError from ..config import parse_environment from ..config.serialize import serialize_config +from ..const import API_VERSION_TO_ENGINE_VERSION from ..const import DEFAULT_TIMEOUT from ..const import HTTP_TIMEOUT from ..const import IS_WINDOWS_PLATFORM @@ -64,7 +65,7 @@ def main(): log.error("No such command: %s\n\n%s", e.command, commands) sys.exit(1) except APIError as e: - log.error(e.explanation) + log_api_error(e) sys.exit(1) except BuildError as e: log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason)) @@ -84,6 +85,22 @@ def main(): sys.exit(1) +def log_api_error(e): + if 'client is newer than server' in e.explanation: + # we need JSON formatted errors. In the meantime... + # TODO: fix this by refactoring project dispatch + # http://github.com/docker/compose/pull/2832#commitcomment-15923800 + client_version = e.explanation.split('client API version: ')[1].split(',')[0] + log.error( + "The engine version is lesser than the minimum required by " + "compose. Your current project requires a Docker Engine of " + "version {version} or superior.".format( + version=API_VERSION_TO_ENGINE_VERSION[client_version] + )) + else: + log.error(e.explanation) + + def setup_logging(): root_logger = logging.getLogger() root_logger.addHandler(console_handler) diff --git a/compose/const.py b/compose/const.py index 0e307835..db5e2fb4 100644 --- a/compose/const.py +++ b/compose/const.py @@ -22,3 +22,8 @@ API_VERSIONS = { COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', } + +API_VERSION_TO_ENGINE_VERSION = { + API_VERSIONS[COMPOSEFILE_V1]: '1.9.0', + API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0' +} From 367fabdbfa3db8324560bc16baf6202f68b7fffb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 11 Feb 2016 16:31:08 -0800 Subject: [PATCH 122/580] Bring up all dependencies when running a single service. Added test for running a depends_on service Signed-off-by: Joffrey F --- compose/cli/main.py | 2 +- tests/acceptance/cli_test.py | 9 +++++++++ tests/fixtures/v2-dependencies/docker-compose.yml | 13 +++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/v2-dependencies/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index deb1e912..1ffa9cc3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -686,7 +686,7 @@ def image_type_from_opt(flag, value): def run_one_off_container(container_options, project, service, options): if not options['--no-deps']: - deps = service.get_linked_service_names() + deps = service.get_dependency_names() if deps: project.up( service_names=deps, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 032900d5..ea3d132a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -738,6 +738,15 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) + @v2_only() + def test_run_service_with_dependencies(self): + self.base_dir = 'tests/fixtures/v2-dependencies' + self.dispatch(['run', 'web', '/bin/true'], None) + db = self.project.get_service('db') + console = self.project.get_service('console') + self.assertEqual(len(db.containers()), 1) + self.assertEqual(len(console.containers()), 0) + def test_run_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', '--no-deps', 'web', '/bin/true']) diff --git a/tests/fixtures/v2-dependencies/docker-compose.yml b/tests/fixtures/v2-dependencies/docker-compose.yml new file mode 100644 index 00000000..2e14b94b --- /dev/null +++ b/tests/fixtures/v2-dependencies/docker-compose.yml @@ -0,0 +1,13 @@ +version: "2.0" +services: + db: + image: busybox:latest + command: top + web: + image: busybox:latest + command: top + depends_on: + - db + console: + image: busybox:latest + command: top From a8fda480e3b414983fd2d077ed5bcfef5d9247b7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 10:41:27 -0800 Subject: [PATCH 123/580] driver_opts can only be of type string Signed-off-by: Joffrey F --- compose/config/fields_schema_v2.0.json | 2 +- tests/unit/config/config_test.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/compose/config/fields_schema_v2.0.json b/compose/config/fields_schema_v2.0.json index 7703adcd..876065e5 100644 --- a/compose/config/fields_schema_v2.0.json +++ b/compose/config/fields_schema_v2.0.json @@ -78,7 +78,7 @@ "driver_opts": { "type": "object", "patternProperties": { - "^.+$": {"type": ["string", "number"]} + "^.+$": {"type": "string"} } }, "external": { diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7fecfed3..5f7633d9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,6 +231,20 @@ class ConfigTest(unittest.TestCase): assert volumes['simple'] == {} assert volumes['other'] == {} + def test_volume_invalid_driver_opt(self): + config_details = build_config_details({ + 'version': '2', + 'services': { + 'simple': {'image': 'busybox'} + }, + 'volumes': { + 'simple': {'driver_opts': {'size': 42}}, + } + }) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert 'driver_opts.size contains an invalid type' in exc.exconly() + def test_load_service_with_name_version(self): with mock.patch('compose.config.config.log') as mock_logging: config_data = config.load( From dd55415d4f1738fb2a9b76ffb9a9d333ae0d3332 Mon Sep 17 00:00:00 2001 From: Chia-liang Kao Date: Sun, 14 Feb 2016 01:42:51 +0800 Subject: [PATCH 124/580] Don't mount pwd if it is / Signed-off-by: Chia-liang Kao --- script/run.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/script/run.sh b/script/run.sh index 087c2692..5b07d1a9 100755 --- a/script/run.sh +++ b/script/run.sh @@ -31,7 +31,9 @@ fi # Setup volume mounts for compose config and context -VOLUMES="-v $(pwd):$(pwd)" +if [ "$(pwd)" != '/' ]; then + VOLUMES="-v $(pwd):$(pwd)" +fi if [ -n "$COMPOSE_FILE" ]; then compose_dir=$(dirname $COMPOSE_FILE) fi @@ -50,4 +52,4 @@ else DOCKER_RUN_OPTIONS="-i" fi -exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w $(pwd) $IMAGE $@ +exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE $@ From e6a675f338a895d93208d1f291d1cee901bf0e32 Mon Sep 17 00:00:00 2001 From: Chia-liang Kao Date: Sun, 14 Feb 2016 01:43:06 +0800 Subject: [PATCH 125/580] Detect -t and -i separately Signed-off-by: Chia-liang Kao --- script/run.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/script/run.sh b/script/run.sh index 5b07d1a9..992e285e 100755 --- a/script/run.sh +++ b/script/run.sh @@ -47,9 +47,10 @@ fi # Only allocate tty if we detect one if [ -t 1 ]; then - DOCKER_RUN_OPTIONS="-ti" -else - DOCKER_RUN_OPTIONS="-i" + DOCKER_RUN_OPTIONS="-t" +fi +if [ -t 0 ]; then + DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" fi exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE $@ From 2204b642ef2ef6d666f970a971bd4f1ed6080715 Mon Sep 17 00:00:00 2001 From: Chia-liang Kao Date: Sun, 14 Feb 2016 01:57:04 +0800 Subject: [PATCH 126/580] Quote argv as they are Signed-off-by: Chia-liang Kao --- script/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/run.sh b/script/run.sh index 992e285e..07132a0c 100755 --- a/script/run.sh +++ b/script/run.sh @@ -53,4 +53,4 @@ if [ -t 0 ]; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" fi -exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE $@ +exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE "$@" From 69d015471853fe3cdf15f57267d3f0a7cf72114f Mon Sep 17 00:00:00 2001 From: Anthon van der Neut Date: Tue, 16 Feb 2016 14:46:47 +0100 Subject: [PATCH 127/580] reset colors after warning If a warning is shown, and you happen to have no color setting in your (bash) prompt, the \033[37m setting, stays active. With the message hardly readable (light grey on my default light yellow background), that means the prompt is barely visible and you need to do `tput reset`. Would probably be better if the background color was set as well in case you have dark on light theme by default in your terminal. Signed-off-by: Anthon van der Neut --- contrib/migration/migrate-compose-file-v1-to-v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index 4f9be97f..387e9ef4 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -155,7 +155,7 @@ def parse_opts(args): def main(args): - logging.basicConfig(format='\033[33m%(levelname)s:\033[37m %(message)s\n') + logging.basicConfig(format='\033[33m%(levelname)s:\033[37m %(message)s\033[0m\n') opts = parse_opts(args) From 8af0a0f85bb278691cc25c6d4c8f28cf7bc34784 Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Sun, 14 Feb 2016 19:52:27 -0800 Subject: [PATCH 128/580] update to description of files generated from examples, which are no longer owned by root w/new release updated descriptions of changing file ownership and images per Seb's comments fixed line wraps fixed line breaks per Joffrey's comments Signed-off-by: Victoria Bialas --- docs/Dockerfile | 1 + docs/django.md | 21 ++++++++--- docs/images/django-it-worked.png | Bin 0 -> 21041 bytes docs/images/rails-welcome.png | Bin 0 -> 62372 bytes docs/overview.md | 45 +++++++++++------------- docs/rails.md | 58 +++++++++++++++++++++++-------- 6 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 docs/images/django-it-worked.png create mode 100644 docs/images/rails-welcome.png diff --git a/docs/Dockerfile b/docs/Dockerfile index 5f32dc4d..b16d0d2c 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -10,6 +10,7 @@ RUN svn checkout https://github.com/docker/kitematic/trunk/docs /docs/content/ki RUN svn checkout https://github.com/docker/toolbox/trunk/docs /docs/content/toolbox RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content/project + ENV PROJECT=compose # To get the git info for this repo COPY . /src diff --git a/docs/django.md b/docs/django.md index 150d3631..a127d008 100644 --- a/docs/django.md +++ b/docs/django.md @@ -117,12 +117,23 @@ In this step, you create a Django started project by building the image from the -rwxr-xr-x 1 root root manage.py -rw-rw-r-- 1 user user requirements.txt - The files `django-admin` created are owned by root. This happens because - the container runs as the `root` user. + If you are running Docker on Linux, the files `django-admin` created are owned + by root. This happens because the container runs as the root user. Change the + ownership of the the new files. -4. Change the ownership of the new files. + sudo chown -R $USER:$USER . - sudo chown -R $USER:$USER . + If you are running Docker on Mac or Windows, you should already have ownership + of all files, including those generated by `django-admin`. List the files just + verify this. + + $ ls -l + total 32 + -rw-r--r-- 1 user staff 145 Feb 13 23:00 Dockerfile + drwxr-xr-x 6 user staff 204 Feb 13 23:07 composeexample + -rw-r--r-- 1 user staff 159 Feb 13 23:02 docker-compose.yml + -rwxr-xr-x 1 user staff 257 Feb 13 23:07 manage.py + -rw-r--r-- 1 user staff 16 Feb 13 23:01 requirements.txt ## Connect the database @@ -169,6 +180,8 @@ In this section, you set up the database connection for Django. Docker host. If you are using a Docker Machine VM, you can use the `docker-machine ip MACHINE_NAME` to get the IP address. + ![Django example](images/django-it-worked.png) + ## More Compose documentation - [User guide](index.md) diff --git a/docs/images/django-it-worked.png b/docs/images/django-it-worked.png new file mode 100644 index 0000000000000000000000000000000000000000..2e8266279ec0ffdaa35239562dd340e2abfebbf6 GIT binary patch literal 21041 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%0A+EZjGlQg-UrTNSA}s5N~^R8&^Cr1Px&x-pAVLa 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 literal 0 HcmV?d00001 diff --git a/docs/overview.md b/docs/overview.md index bb3c5d71..2efb715a 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -24,11 +24,14 @@ CI workflows. You can learn more about each case in Using Compose is basically a three-step process. -1. Define your app's environment with a `Dockerfile` so it can be -reproduced anywhere. -2. Define the services that make up your app in `docker-compose.yml` so -they can be run together in an isolated environment. -3. Lastly, run `docker-compose up` and Compose will start and run your entire app. +1. Define your app's environment with a `Dockerfile` so it can be reproduced +anywhere. + +2. Define the services that make up your app in `docker-compose.yml` +so they can be run together in an isolated environment. + +3. Lastly, run +`docker-compose up` and Compose will start and run your entire app. A `docker-compose.yml` looks like this: @@ -37,16 +40,16 @@ A `docker-compose.yml` looks like this: web: build: . ports: - - "5000:5000" + - "5000:5000" volumes: - - .:/code - - logvolume01:/var/log + - .:/code + - logvolume01:/var/log links: - - redis - redis: - image: redis - volumes: - logvolume01: {} + - redis + redis: + image: redis + volumes: + logvolume01: {} For more information about the Compose file, see the [Compose file reference](compose-file.md) @@ -80,14 +83,12 @@ The features of Compose that make it effective are: ### Multiple isolated environments on a single host -Compose uses a project name to isolate environments from each other. You can use -this project name to: +Compose uses a project name to isolate environments from each other. You can make use of this project name in several different contexts: -* on a dev host, to create multiple copies of a single environment (ex: you want - to run a stable copy for each feature branch of a project) +* on a dev host, to create multiple copies of a single environment (e.g., you want to run a stable copy for each feature branch of a project) * on a CI server, to keep builds from interfering with each other, you can set the project name to a unique build number -* on a shared host or dev host, to prevent different projects which may use the +* on a shared host or dev host, to prevent different projects, which may use the same service names, from interfering with each other The default project name is the basename of the project directory. You can set @@ -148,9 +149,7 @@ started guide" to a single machine readable Compose file and a few commands. An important part of any Continuous Deployment or Continuous Integration process is the automated test suite. Automated end-to-end testing requires an environment in which to run tests. Compose provides a convenient way to create -and destroy isolated testing environments for your test suite. By defining the full -environment in a [Compose file](compose-file.md) you can create and destroy these -environments in just a few commands: +and destroy isolated testing environments for your test suite. By defining the full environment in a [Compose file](compose-file.md) you can create and destroy these environments in just a few commands: $ docker-compose up -d $ ./run_tests @@ -159,9 +158,7 @@ environments in just a few commands: ### Single host deployments Compose has traditionally been focused on development and testing workflows, -but with each release we're making progress on more production-oriented features. -You can use Compose to deploy to a remote Docker Engine. The Docker Engine may -be a single instance provisioned with +but with each release we're making progress on more production-oriented features. You can use Compose to deploy to a remote Docker Engine. The Docker Engine may be a single instance provisioned with [Docker Machine](https://docs.docker.com/machine/) or an entire [Docker Swarm](https://docs.docker.com/swarm/) cluster. diff --git a/docs/rails.md b/docs/rails.md index f7634a6d..145f53d8 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -30,7 +30,9 @@ Dockerfile consists of: RUN bundle install ADD . /myapp -That'll put your application code inside an image that will build a container with Ruby, Bundler and all your dependencies inside it. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](https://docs.docker.com/engine/reference/builder/). +That'll put your application code inside an image that will build a container +with Ruby, Bundler and all your dependencies inside it. For more information on +how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](https://docs.docker.com/engine/reference/builder/). Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. @@ -41,7 +43,11 @@ You'll need an empty `Gemfile.lock` in order to build our `Dockerfile`. $ touch Gemfile.lock -Finally, `docker-compose.yml` is where the magic happens. This file describes the services that comprise your app (a database and a web app), how to get each one's Docker image (the database just runs on a pre-made PostgreSQL image, and the web app is built from the current directory), and the configuration needed to link them together and expose the web app's port. +Finally, `docker-compose.yml` is where the magic happens. This file describes +the services that comprise your app (a database and a web app), how to get each +one's Docker image (the database just runs on a pre-made PostgreSQL image, and +the web app is built from the current directory), and the configuration needed +to link them together and expose the web app's port. db: image: postgres @@ -62,22 +68,38 @@ using `docker-compose run`: $ docker-compose run web rails new . --force --database=postgresql --skip-bundle -First, Compose will build the image for the `web` service using the -`Dockerfile`. Then it'll run `rails new` inside a new container, using that -image. Once it's done, you should have generated a fresh app: +First, Compose will build the image for the `web` service using the `Dockerfile`. Then it'll run `rails new` inside a new container, using that image. Once it's done, you should have generated a fresh app: - $ ls - Dockerfile app docker-compose.yml tmp - Gemfile bin lib vendor - Gemfile.lock config log - README.rdoc config.ru public - Rakefile db test + $ ls -l + total 56 + -rw-r--r-- 1 user staff 215 Feb 13 23:33 Dockerfile + -rw-r--r-- 1 user staff 1480 Feb 13 23:43 Gemfile + -rw-r--r-- 1 user staff 2535 Feb 13 23:43 Gemfile.lock + -rw-r--r-- 1 root root 478 Feb 13 23:43 README.rdoc + -rw-r--r-- 1 root root 249 Feb 13 23:43 Rakefile + drwxr-xr-x 8 root root 272 Feb 13 23:43 app + drwxr-xr-x 6 root root 204 Feb 13 23:43 bin + drwxr-xr-x 11 root root 374 Feb 13 23:43 config + -rw-r--r-- 1 root root 153 Feb 13 23:43 config.ru + drwxr-xr-x 3 root root 102 Feb 13 23:43 db + -rw-r--r-- 1 user staff 161 Feb 13 23:35 docker-compose.yml + drwxr-xr-x 4 root root 136 Feb 13 23:43 lib + drwxr-xr-x 3 root root 102 Feb 13 23:43 log + drwxr-xr-x 7 root root 238 Feb 13 23:43 public + drwxr-xr-x 9 root root 306 Feb 13 23:43 test + drwxr-xr-x 3 root root 102 Feb 13 23:43 tmp + drwxr-xr-x 3 root root 102 Feb 13 23:43 vendor -The files `rails new` created are owned by root. This happens because the -container runs as the `root` user. Change the ownership of the new files. +If you are running Docker on Linux, the files `rails new` created are owned by +root. This happens because the container runs as the root user. Change the +ownership of the the new files. - sudo chown -R $USER:$USER . + sudo chown -R $USER:$USER . + +If you are running Docker on Mac or Windows, you should already have ownership +of all files, including those generated by `rails new`. List the files just to +verify this. Uncomment the line in your new `Gemfile` which loads `therubyracer`, so you've got a Javascript runtime: @@ -130,6 +152,14 @@ Finally, you need to create the database. In another terminal, run: That's it. Your app should now be running on port 3000 on your Docker daemon. If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` returns the Docker host IP address. +![Rails example](images/rails-welcome.png) + +>**Note**: If you stop the example application and attempt to restart it, you might get the +following error: `web_1 | A server is already running. Check +/myapp/tmp/pids/server.pid.` One way to resolve this is to delete the file +`tmp/pids/server.pid`, and then re-start the application with `docker-compose +up`. + ## More Compose documentation From 4de12ad7a1408323d14271c3c39b238b36666494 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 16:34:31 -0500 Subject: [PATCH 129/580] Update link to docker volume create docs. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 01fe3683..04733916 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -626,7 +626,7 @@ While it is possible to declare volumes on the fly as part of the service declaration, this section allows you to create named volumes that can be reused across multiple services (without relying on `volumes_from`), and are easily retrieved and inspected using the docker command line or API. -See the [docker volume](http://docs.docker.com/reference/commandline/volume/) +See the [docker volume](/engine/reference/commandline/volume_create.md) subcommand documentation for more information. ### driver From 471264239f4c9e6b80f8db06dae36303bea032fc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 16:38:31 -0500 Subject: [PATCH 130/580] Update guides to use v2 config format. Signed-off-by: Daniel Nephin --- docs/django.md | 24 +++++++++++++----------- docs/gettingstarted.md | 23 +++++++++++++---------- docs/rails.md | 24 +++++++++++++----------- docs/wordpress.md | 28 +++++++++++++++------------- 4 files changed, 54 insertions(+), 45 deletions(-) diff --git a/docs/django.md b/docs/django.md index a127d008..c8863b34 100644 --- a/docs/django.md +++ b/docs/django.md @@ -72,17 +72,19 @@ and a `docker-compose.yml` file. 9. Add the following configuration to the file. - db: - image: postgres - web: - build: . - command: python manage.py runserver 0.0.0.0:8000 - volumes: - - .:/code - ports: - - "8000:8000" - links: - - db + version: '2' + services: + db: + image: postgres + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/code + ports: + - "8000:8000" + depends_on: + - db This file defines two services: The `db` service and the `web` service. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 1939500c..36577f07 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -95,16 +95,19 @@ Define a set of services using `docker-compose.yml`: 1. Create a file called docker-compose.yml in your project directory and add the following: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis - redis: - image: redis + + version: '2' + services: + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + depends_on: + - redis + redis: + image: redis This Compose file defines two services, `web` and `redis`. The web service: diff --git a/docs/rails.md b/docs/rails.md index 145f53d8..ccb0ab73 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -49,17 +49,19 @@ one's Docker image (the database just runs on a pre-made PostgreSQL image, and the web app is built from the current directory), and the configuration needed to link them together and expose the web app's port. - db: - image: postgres - web: - build: . - command: bundle exec rails s -p 3000 -b '0.0.0.0' - volumes: - - .:/myapp - ports: - - "3000:3000" - links: - - db + version: '2' + services: + db: + image: postgres + web: + build: . + command: bundle exec rails s -p 3000 -b '0.0.0.0' + volumes: + - .:/myapp + ports: + - "3000:3000" + depends_on: + - db ### Build the project diff --git a/docs/wordpress.md b/docs/wordpress.md index 50362253..62aec251 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -41,19 +41,21 @@ and WordPress. Next you'll create a `docker-compose.yml` file that will start your web service and a separate MySQL instance: - web: - build: . - command: php -S 0.0.0.0:8000 -t /code - ports: - - "8000:8000" - links: - - db - volumes: - - .:/code - db: - image: orchardup/mysql - environment: - MYSQL_DATABASE: wordpress + version: '2' + services: + web: + build: . + command: php -S 0.0.0.0:8000 -t /code + ports: + - "8000:8000" + depends_on: + - db + volumes: + - .:/code + db: + image: orchardup/mysql + environment: + MYSQL_DATABASE: wordpress A supporting file is needed to get this working. `wp-config.php` is the standard WordPress config file with a single change to point the database From 1512793b30de1c84bff319070c4c4d7f65fd49db Mon Sep 17 00:00:00 2001 From: Anthon van der Neut Date: Wed, 17 Feb 2016 09:56:49 +0100 Subject: [PATCH 131/580] for 1.6.0 the version value needs to be a string After conversion a file would immediately not load in docker-compose 1.6.0 with the message: ERROR: Version in "./converted.yml" is invalid - it should be a string. Signed-off-by: Anthon van der Neut anthon@mnt.org Signed-off-by: Anthon van der Neut --- contrib/migration/migrate-compose-file-v1-to-v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index 4f9be97f..c690197b 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -33,7 +33,7 @@ def migrate(content): services = {name: data.pop(name) for name in data.keys()} - data['version'] = 2 + data['version'] = "2" data['services'] = services create_volumes_section(data) From 4a09da43eaf6a9a202b6b803a3824263d491746c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 14:24:17 -0500 Subject: [PATCH 132/580] Fix copying of volumes by using the name of the volume instead of the host path. Signed-off-by: Daniel Nephin --- compose/service.py | 2 +- tests/integration/service_test.py | 24 ++++++++++++++++++++++++ tests/unit/service_test.py | 19 ++++++++++--------- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/compose/service.py b/compose/service.py index 9dd2865b..e54f29b0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -925,7 +925,7 @@ def get_container_data_volumes(container, volumes_option): continue # Copy existing volume from old container - volume = volume._replace(external=mount['Source']) + volume = volume._replace(external=mount['Name']) volumes.append(volume) return volumes diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 1cc6b266..bcb87335 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -273,6 +273,30 @@ class ServiceTest(DockerClientTestCase): self.client.inspect_container, old_container.id) + def test_execute_convergence_plan_recreate_twice(self): + service = self.create_service( + 'db', + volumes=[VolumeSpec.parse('/etc')], + entrypoint=['top'], + command=['-d', '1']) + + orig_container = service.create_container() + service.start_container(orig_container) + + orig_container.inspect() # reload volume data + volume_path = orig_container.get_mount('/etc')['Source'] + + # Do this twice to reproduce the bug + for _ in range(2): + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [orig_container])) + + assert new_container.get_mount('/etc')['Source'] == volume_path + assert ('affinity:container==%s' % orig_container.id in + new_container.get('Config.Env')) + + orig_container = new_container + def test_execute_convergence_plan_when_containers_are_stopped(self): service = self.create_service( 'db', diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f34de3bf..603356be 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -691,8 +691,8 @@ class ServiceVolumesTest(unittest.TestCase): }, has_been_inspected=True) expected = [ - VolumeSpec.parse('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), - VolumeSpec.parse('/var/lib/docker/cccccccc:/mnt/image/data:rw'), + VolumeSpec.parse('existingvolume:/existing/volume:rw'), + VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] volumes = get_container_data_volumes(container, options) @@ -724,11 +724,11 @@ class ServiceVolumesTest(unittest.TestCase): expected = [ '/host/volume:/host/volume:ro', '/host/rw/volume:/host/rw/volume:rw', - '/var/lib/docker/aaaaaaaa:/existing/volume:rw', + 'existingvolume:/existing/volume:rw', ] binds = merge_volume_bindings(options, intermediate_container) - self.assertEqual(set(binds), set(expected)) + assert sorted(binds) == sorted(expected) def test_mount_same_host_path_to_two_volumes(self): service = Service( @@ -761,13 +761,14 @@ class ServiceVolumesTest(unittest.TestCase): ]), ) - def test_different_host_path_in_container_json(self): + def test_get_container_create_options_with_different_host_path_in_container_json(self): service = Service( 'web', image='busybox', volumes=[VolumeSpec.parse('/host/path:/data')], client=self.mock_client, ) + volume_name = 'abcdefff1234' self.mock_client.inspect_image.return_value = { 'Id': 'ababab', @@ -788,7 +789,7 @@ class ServiceVolumesTest(unittest.TestCase): 'Mode': '', 'RW': True, 'Driver': 'local', - 'Name': 'abcdefff1234' + 'Name': volume_name, }, ] } @@ -799,9 +800,9 @@ class ServiceVolumesTest(unittest.TestCase): previous_container=Container(self.mock_client, {'Id': '123123123'}), ) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['binds'], - ['/mnt/sda1/host/path:/data:rw'], + assert ( + self.mock_client.create_host_config.call_args[1]['binds'] == + ['{}:/data:rw'.format(volume_name)] ) def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): From 1952b5239205c82c278896ade6eb7041eb9de7eb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Feb 2016 16:48:04 -0800 Subject: [PATCH 133/580] Constraint build argument types. Numbers are cast into strings Numerical driver_opts are also valid and typecast into strings. Additional config tests. Signed-off-by: Joffrey F --- compose/config/config.py | 13 ++++++-- compose/config/fields_schema_v2.0.json | 2 +- compose/config/service_schema_v2.0.json | 15 ++++++++- compose/utils.py | 4 +++ tests/unit/config/config_test.py | 43 ++++++++++++++++++++++++- 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 19722b0a..7b9e6f83 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ from cached_property import cached_property from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V2_0 as V2_0 +from ..utils import build_string_dict from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError @@ -292,7 +293,7 @@ def load(config_details): config_details = config_details._replace(config_files=processed_files) main_file = config_details.config_files[0] - volumes = load_mapping(config_details.config_files, 'get_volumes', 'Volume') + volumes = load_volumes(config_details.config_files) networks = load_mapping(config_details.config_files, 'get_networks', 'Network') service_dicts = load_services( config_details.working_dir, @@ -336,6 +337,14 @@ def load_mapping(config_files, get_func, entity_type): return mapping +def load_volumes(config_files): + volumes = load_mapping(config_files, 'get_volumes', 'Volume') + for volume_name, volume in volumes.items(): + if 'driver_opts' in volume: + volume['driver_opts'] = build_string_dict(volume['driver_opts']) + return volumes + + def load_services(working_dir, config_file, service_configs): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( @@ -851,7 +860,7 @@ def normalize_build(service_dict, working_dir): else: build.update(service_dict['build']) if 'args' in build: - build['args'] = resolve_build_args(build) + build['args'] = build_string_dict(resolve_build_args(build)) service_dict['build'] = build diff --git a/compose/config/fields_schema_v2.0.json b/compose/config/fields_schema_v2.0.json index 876065e5..7703adcd 100644 --- a/compose/config/fields_schema_v2.0.json +++ b/compose/config/fields_schema_v2.0.json @@ -78,7 +78,7 @@ "driver_opts": { "type": "object", "patternProperties": { - "^.+$": {"type": "string"} + "^.+$": {"type": ["string", "number"]} } }, "external": { diff --git a/compose/config/service_schema_v2.0.json b/compose/config/service_schema_v2.0.json index f7a67818..3196ca89 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -23,7 +23,20 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} + "args": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^.+$": { + "type": ["string", "number"] + } + }, + "additionalProperties": false + } + ] + } }, "additionalProperties": false } diff --git a/compose/utils.py b/compose/utils.py index 29d8a695..669df1d2 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -92,3 +92,7 @@ def json_hash(obj): def microseconds_from_time_nano(time_nano): return int(time_nano % 1000000000 / 1000) + + +def build_string_dict(source_dict): + return dict([(k, str(v)) for k, v in source_dict.items()]) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 5f7633d9..1d6f1cbb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,7 +231,7 @@ class ConfigTest(unittest.TestCase): assert volumes['simple'] == {} assert volumes['other'] == {} - def test_volume_invalid_driver_opt(self): + def test_volume_numeric_driver_opt(self): config_details = build_config_details({ 'version': '2', 'services': { @@ -241,6 +241,19 @@ class ConfigTest(unittest.TestCase): 'simple': {'driver_opts': {'size': 42}}, } }) + cfg = config.load(config_details) + assert cfg.volumes['simple']['driver_opts']['size'] == '42' + + def test_volume_invalid_driver_opt(self): + config_details = build_config_details({ + 'version': '2', + 'services': { + 'simple': {'image': 'busybox'} + }, + 'volumes': { + 'simple': {'driver_opts': {'size': True}}, + } + }) with pytest.raises(ConfigurationError) as exc: config.load(config_details) assert 'driver_opts.size contains an invalid type' in exc.exconly() @@ -608,6 +621,34 @@ class ConfigTest(unittest.TestCase): self.assertTrue('context' in service[0]['build']) self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + def test_load_with_buildargs(self): + service = config.load( + build_config_details( + { + 'version': '2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'dockerfile': 'Dockerfile-alt', + 'args': { + 'opt1': 42, + 'opt2': 'foobar' + } + } + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services[0] + assert 'args' in service['build'] + assert 'opt1' in service['build']['args'] + assert isinstance(service['build']['args']['opt1'], str) + assert service['build']['args']['opt1'] == '42' + assert service['build']['args']['opt2'] == 'foobar' + def test_load_with_multiple_files_v2(self): base_file = config.ConfigFile( 'base.yaml', From 4f7530c480213d4cf526353aa3b68979ef605bd6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Feb 2016 19:53:28 -0500 Subject: [PATCH 134/580] Only set a container affinity if there are volumes to copy over. Signed-off-by: Daniel Nephin --- compose/service.py | 32 ++++++++++++--------- tests/unit/service_test.py | 58 ++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/compose/service.py b/compose/service.py index e54f29b0..78eed4c4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -592,20 +592,19 @@ class Service(object): ports.append(port) container_options['ports'] = ports - override_options['binds'] = merge_volume_bindings( - container_options.get('volumes') or [], - previous_container) - - if 'volumes' in container_options: - container_options['volumes'] = dict( - (v.internal, {}) for v in container_options['volumes']) - container_options['environment'] = merge_environment( self.options.get('environment'), override_options.get('environment')) - if previous_container: - container_options['environment']['affinity:container'] = ('=' + previous_container.id) + binds, affinity = merge_volume_bindings( + container_options.get('volumes') or [], + previous_container) + override_options['binds'] = binds + container_options['environment'].update(affinity) + + if 'volumes' in container_options: + container_options['volumes'] = dict( + (v.internal, {}) for v in container_options['volumes']) container_options['image'] = self.image_name @@ -877,18 +876,23 @@ def merge_volume_bindings(volumes, previous_container): """Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. """ + affinity = {} + volume_bindings = dict( build_volume_binding(volume) for volume in volumes if volume.external) if previous_container: - data_volumes = get_container_data_volumes(previous_container, volumes) - warn_on_masked_volume(volumes, data_volumes, previous_container.service) + old_volumes = get_container_data_volumes(previous_container, volumes) + warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( - build_volume_binding(volume) for volume in data_volumes) + build_volume_binding(volume) for volume in old_volumes) - return list(volume_bindings.values()) + if old_volumes: + affinity = {'affinity:container': '=' + previous_container.id} + + return list(volume_bindings.values()), affinity def get_container_data_volumes(container, volumes_option): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 603356be..ce28a9ca 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -267,13 +267,52 @@ class ServiceTest(unittest.TestCase): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], 'f8bfa1058ad1f4231372a0b1639f0dfdb574dafff4e8d7938049ae993f7cf1fc') - self.assertEqual( - opts['environment'], - { - 'affinity:container': '=ababab', - 'also': 'real', - } + assert opts['environment'] == {'also': 'real'} + + def test_get_container_create_options_sets_affinity_with_binds(self): + service = Service( + 'foo', + image='foo', + client=self.mock_client, ) + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + prev_container = mock.Mock( + id='ababab', + image_config={'ContainerConfig': {'Volumes': ['/data']}}) + + def container_get(key): + return { + 'Mounts': [ + { + 'Destination': '/data', + 'Source': '/some/path', + 'Name': 'abab1234', + }, + ] + }.get(key, None) + + prev_container.get.side_effect = container_get + + opts = service._get_container_create_options( + {}, + 1, + previous_container=prev_container) + + assert opts['environment'] == {'affinity:container': '=ababab'} + + def test_get_container_create_options_no_affinity_without_binds(self): + service = Service('foo', image='foo', client=self.mock_client) + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + prev_container = mock.Mock( + id='ababab', + image_config={'ContainerConfig': {}}) + prev_container.get.return_value = None + + opts = service._get_container_create_options( + {}, + 1, + previous_container=prev_container) + assert opts['environment'] == {} def test_get_container_not_found(self): self.mock_client.containers.return_value = [] @@ -650,6 +689,7 @@ class ServiceVolumesTest(unittest.TestCase): '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', + 'named:/named/vol', ]] self.mock_client.inspect_image.return_value = { @@ -710,7 +750,8 @@ class ServiceVolumesTest(unittest.TestCase): 'ContainerConfig': {'Volumes': {}} } - intermediate_container = Container(self.mock_client, { + previous_container = Container(self.mock_client, { + 'Id': 'cdefab', 'Image': 'ababab', 'Mounts': [{ 'Source': '/var/lib/docker/aaaaaaaa', @@ -727,8 +768,9 @@ class ServiceVolumesTest(unittest.TestCase): 'existingvolume:/existing/volume:rw', ] - binds = merge_volume_bindings(options, intermediate_container) + binds, affinity = merge_volume_bindings(options, previous_container) assert sorted(binds) == sorted(expected) + assert affinity == {'affinity:container': '=cdefab'} def test_mount_same_host_path_to_two_volumes(self): service = Service( From 93a02e497dd19c055235936ec4cfe3d14f99d263 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 10:53:40 -0800 Subject: [PATCH 135/580] Apply driver_opts processing to network configs Signed-off-by: Joffrey F --- compose/config/config.py | 21 +++++++++++---------- compose/utils.py | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7b9e6f83..dbc6b6b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -293,8 +293,12 @@ def load(config_details): config_details = config_details._replace(config_files=processed_files) main_file = config_details.config_files[0] - volumes = load_volumes(config_details.config_files) - networks = load_mapping(config_details.config_files, 'get_networks', 'Network') + volumes = load_mapping( + config_details.config_files, 'get_volumes', 'Volume' + ) + networks = load_mapping( + config_details.config_files, 'get_networks', 'Network' + ) service_dicts = load_services( config_details.working_dir, main_file, @@ -334,17 +338,14 @@ def load_mapping(config_files, get_func, entity_type): mapping[name] = config + if 'driver_opts' in config: + config['driver_opts'] = build_string_dict( + config['driver_opts'] + ) + return mapping -def load_volumes(config_files): - volumes = load_mapping(config_files, 'get_volumes', 'Volume') - for volume_name, volume in volumes.items(): - if 'driver_opts' in volume: - volume['driver_opts'] = build_string_dict(volume['driver_opts']) - return volumes - - def load_services(working_dir, config_file, service_configs): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( diff --git a/compose/utils.py b/compose/utils.py index 669df1d2..494beea3 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -95,4 +95,4 @@ def microseconds_from_time_nano(time_nano): def build_string_dict(source_dict): - return dict([(k, str(v)) for k, v in source_dict.items()]) + return dict((k, str(v)) for k, v in source_dict.items()) From 2b5d3f51cb7e8501c1b5481ce45d139b7e49171e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 16:10:36 -0800 Subject: [PATCH 136/580] Allow user to specify custom network aliases Signed-off-by: Joffrey F --- compose/config/config.py | 3 +++ compose/config/service_schema_v2.0.json | 13 ++++++++++--- compose/config/validation.py | 13 +++++++++++++ compose/network.py | 11 ++++++++--- compose/project.py | 4 ++-- compose/service.py | 12 ++++++------ 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index dbc6b6b2..1e077adc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,6 +31,7 @@ from .types import ServiceLink from .types import VolumeFromSpec from .types import VolumeSpec from .validation import match_named_volumes +from .validation import match_network_aliases from .validation import validate_against_fields_schema from .validation import validate_against_service_schema from .validation import validate_depends_on @@ -546,6 +547,8 @@ def validate_service(service_config, service_names, version): validate_network_mode(service_config, service_names) validate_depends_on(service_config, service_names) + match_network_aliases(service_config.config) + if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( "Service '{name}' contains uppercase characters which are not valid " diff --git a/compose/config/service_schema_v2.0.json b/compose/config/service_schema_v2.0.json index 3196ca89..1c8022d3 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -120,9 +120,16 @@ "network_mode": {"type": "string"}, "networks": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true + "$ref": "#/definitions/list_of_strings" + }, + "network_aliases": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/list_of_strings" + } + }, + "additionalProperties": false }, "pid": {"type": ["string", "null"]}, diff --git a/compose/config/validation.py b/compose/config/validation.py index 35727e2c..53929150 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -91,6 +91,19 @@ def match_named_volumes(service_dict, project_volumes): ) +def match_network_aliases(service_dict): + networks = service_dict.get('networks', []) + aliased_networks = service_dict.get('network_aliases', {}).keys() + for n in aliased_networks: + if n not in networks: + raise ConfigurationError( + 'Network "{0}" is referenced in network_aliases, but is not' + 'declared in the networks list for service "{1}"'.format( + n, service_dict.get('name') + ) + ) + + def validate_top_level_service_objects(filename, service_dicts): """Perform some high level validation of the service name and value. diff --git a/compose/network.py b/compose/network.py index 82a78f3b..99c04649 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None): + ipam=None, external_name=None, aliases=None): self.client = client self.project = project self.name = name @@ -23,6 +23,7 @@ class Network(object): self.driver_opts = driver_opts self.ipam = create_ipam_config_from_dict(ipam) self.external_name = external_name + self.aliases = aliases or [] def ensure(self): if self.external_name: @@ -166,14 +167,18 @@ def get_network_names_for_service(service_dict): def get_networks(service_dict, network_definitions): - networks = [] + networks = {} + aliases = service_dict.get('network_aliases', {}) for name in get_network_names_for_service(service_dict): + log.debug(name) network = network_definitions.get(name) if network: - networks.append(network.full_name) + log.debug(aliases) + networks[network.full_name] = aliases.get(name, []) else: raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' .format(service_dict['name'], name)) + log.debug(networks) return networks diff --git a/compose/project.py b/compose/project.py index 62e1d2cd..0394fa15 100644 --- a/compose/project.py +++ b/compose/project.py @@ -69,11 +69,11 @@ class Project(object): if use_networking: service_networks = get_networks(service_dict, networks) else: - service_networks = [] + service_networks = {} service_dict.pop('networks', None) links = project.get_links(service_dict) - network_mode = project.get_network_mode(service_dict, service_networks) + network_mode = project.get_network_mode(service_dict, service_networks.keys()) volumes_from = get_volumes_from(project, service_dict) if config_data.version != V1: diff --git a/compose/service.py b/compose/service.py index 78eed4c4..c597abd0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -124,7 +124,7 @@ class Service(object): self.links = links or [] self.volumes_from = volumes_from or [] self.network_mode = network_mode or NetworkMode(None) - self.networks = networks or [] + self.networks = networks or {} self.options = options def containers(self, stopped=False, one_off=False, filters={}): @@ -432,14 +432,14 @@ class Service(object): def connect_container_to_networks(self, container): connected_networks = container.get('NetworkSettings.Networks') - for network in self.networks: + for network, aliases in self.networks.items(): if network in connected_networks: self.client.disconnect_container_from_network( container.id, network) self.client.connect_container_to_network( container.id, network, - aliases=self._get_aliases(container), + aliases=list(self._get_aliases(container).union(aliases)), links=self._get_links(False), ) @@ -473,7 +473,7 @@ class Service(object): 'image_id': self.image()['Id'], 'links': self.get_link_names(), 'net': self.network_mode.id, - 'networks': self.networks, + 'networks': self.networks.keys(), 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) @@ -514,9 +514,9 @@ class Service(object): def _get_aliases(self, container): if container.labels.get(LABEL_ONE_OFF) == "True": - return [] + return set() - return [self.name, container.short_id] + return set([self.name, container.short_id]) def _get_links(self, link_to_self): links = {} From 633e349ab97af63849fc739eb9596be6f6f24362 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 16:58:29 -0800 Subject: [PATCH 137/580] Test network_aliases feature Signed-off-by: Joffrey F --- compose/config/validation.py | 2 +- compose/project.py | 4 ++- tests/acceptance/cli_test.py | 27 +++++++++++++++++++++ tests/fixtures/networks/network-aliases.yml | 18 ++++++++++++++ tests/unit/config/config_test.py | 21 ++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/networks/network-aliases.yml diff --git a/compose/config/validation.py b/compose/config/validation.py index 53929150..59ce9f54 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -97,7 +97,7 @@ def match_network_aliases(service_dict): for n in aliased_networks: if n not in networks: raise ConfigurationError( - 'Network "{0}" is referenced in network_aliases, but is not' + 'Network "{0}" is referenced in network_aliases, but is not ' 'declared in the networks list for service "{1}"'.format( n, service_dict.get('name') ) diff --git a/compose/project.py b/compose/project.py index 0394fa15..cfb11aa0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -73,7 +73,9 @@ class Project(object): service_dict.pop('networks', None) links = project.get_links(service_dict) - network_mode = project.get_network_mode(service_dict, service_networks.keys()) + network_mode = project.get_network_mode( + service_dict, list(service_networks.keys()) + ) volumes_from = get_volumes_from(project, service_dict) if config_data.version != V1: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ea3d132a..49048fb7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -445,6 +445,33 @@ class CLITestCase(DockerClientTestCase): assert networks[0]['Options']['com.docker.network.bridge.enable_icc'] == 'false' + def test_up_with_network_aliases(self): + filename = 'network-aliases.yml' + self.base_dir = 'tests/fixtures/networks' + self.dispatch(['-f', filename, 'up', '-d'], None) + back_name = '{}_back'.format(self.project.name) + front_name = '{}_front'.format(self.project.name) + + networks = [ + n for n in self.client.networks() + if n['Name'].startswith('{}_'.format(self.project.name)) + ] + + # Two networks were created: back and front + assert sorted(n['Name'] for n in networks) == [back_name, front_name] + web_container = self.project.get_service('web').containers()[0] + + back_aliases = web_container.get( + 'NetworkSettings.Networks.{}.Aliases'.format(back_name) + ) + assert 'web' in back_aliases + front_aliases = web_container.get( + 'NetworkSettings.Networks.{}.Aliases'.format(front_name) + ) + assert 'web' in front_aliases + assert 'forward_facing' in front_aliases + assert 'ahead' in front_aliases + @v2_only() def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' diff --git a/tests/fixtures/networks/network-aliases.yml b/tests/fixtures/networks/network-aliases.yml new file mode 100644 index 00000000..987b0809 --- /dev/null +++ b/tests/fixtures/networks/network-aliases.yml @@ -0,0 +1,18 @@ +version: "2" + +services: + web: + image: busybox + command: top + networks: + - front + - back + + network_aliases: + front: + - forward_facing + - ahead + +networks: + front: {} + back: {} diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1d6f1cbb..88d46a14 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -556,6 +556,27 @@ class ConfigTest(unittest.TestCase): assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' + def test_invalid_network_alias(self): + config_details = build_config_details({ + 'version': '2', + 'services': { + 'web': { + 'image': 'busybox', + 'networks': ['hello'], + 'network_aliases': { + 'world': ['planet', 'universe'] + } + } + }, + 'networks': { + 'hello': {}, + 'world': {} + } + }) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert 'not declared in the networks list' in exc.exconly() + def test_config_build_configuration(self): service = config.load( build_config_details( From 7801cfc5d1ac9f481ebc991ef8bd6fab7a94575c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 17:17:31 -0800 Subject: [PATCH 138/580] Document network_aliases config Signed-off-by: Joffrey F --- docs/compose-file.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 04733916..2e9632c4 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -451,9 +451,27 @@ id. net: "none" net: "container:[service name or container name/id]" +### network_aliases + +> [Version 2 file format](#version-2) only. + +Alias names for this service on each joined network. All networks referenced +here must also appear under the `networks` key. + + networks: + - some-network + - other-network + network_aliases: + some-network: + - alias1 + - alias3 + other-network: + - alias2 + - alias4 + ### network_mode -> [Version 2 file format](#version-1) only. In version 1, use [net](#net). +> [Version 2 file format](#version-2) only. In version 1, use [net](#net). Network mode. Use the same values as the docker client `--net` parameter, plus the special form `service:[service name]`. From 41e399be9880583954c6de6559886465e3f8db85 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 17:20:18 -0800 Subject: [PATCH 139/580] Fix network list serialization in py3 Signed-off-by: Joffrey F --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index c597abd0..5f40a457 100644 --- a/compose/service.py +++ b/compose/service.py @@ -473,7 +473,7 @@ class Service(object): 'image_id': self.image()['Id'], 'links': self.get_link_names(), 'net': self.network_mode.id, - 'networks': self.networks.keys(), + 'networks': list(self.networks.keys()), 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) From 4b99b32ffb346d0cb4b488a69565ea991acbb38f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 17:48:42 -0800 Subject: [PATCH 140/580] Add v2_only decorator to network aliases test Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 49048fb7..4ba48d45 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -445,6 +445,7 @@ class CLITestCase(DockerClientTestCase): assert networks[0]['Options']['com.docker.network.bridge.enable_icc'] == 'false' + @v2_only() def test_up_with_network_aliases(self): filename = 'network-aliases.yml' self.base_dir = 'tests/fixtures/networks' From 825a0941f04523a6c68e47bb3392a720744f7b7a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 12:04:07 -0800 Subject: [PATCH 141/580] Network aliases are now part of the network dictionary Signed-off-by: Joffrey F --- compose/config/config.py | 3 -- compose/config/service_schema_v2.0.json | 30 +++++++++++++------- compose/config/validation.py | 13 --------- compose/network.py | 28 +++++++++++-------- docs/compose-file.md | 31 +++++++++------------ tests/fixtures/networks/network-aliases.yml | 10 +++---- tests/unit/config/config_test.py | 21 -------------- 7 files changed, 54 insertions(+), 82 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 1e077adc..dbc6b6b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,7 +31,6 @@ from .types import ServiceLink from .types import VolumeFromSpec from .types import VolumeSpec from .validation import match_named_volumes -from .validation import match_network_aliases from .validation import validate_against_fields_schema from .validation import validate_against_service_schema from .validation import validate_depends_on @@ -547,8 +546,6 @@ def validate_service(service_config, service_names, version): validate_network_mode(service_config, service_names) validate_depends_on(service_config, service_names) - match_network_aliases(service_config.config) - if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( "Service '{name}' contains uppercase characters which are not valid " diff --git a/compose/config/service_schema_v2.0.json b/compose/config/service_schema_v2.0.json index 1c8022d3..edccedc6 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -120,18 +120,28 @@ "network_mode": {"type": "string"}, "networks": { - "$ref": "#/definitions/list_of_strings" - }, - "network_aliases": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/list_of_strings" + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false } - }, - "additionalProperties": false + ] }, - "pid": {"type": ["string", "null"]}, "ports": { diff --git a/compose/config/validation.py b/compose/config/validation.py index 59ce9f54..35727e2c 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -91,19 +91,6 @@ def match_named_volumes(service_dict, project_volumes): ) -def match_network_aliases(service_dict): - networks = service_dict.get('networks', []) - aliased_networks = service_dict.get('network_aliases', {}).keys() - for n in aliased_networks: - if n not in networks: - raise ConfigurationError( - 'Network "{0}" is referenced in network_aliases, but is not ' - 'declared in the networks list for service "{1}"'.format( - n, service_dict.get('name') - ) - ) - - def validate_top_level_service_objects(filename, service_dicts): """Perform some high level validation of the service name and value. diff --git a/compose/network.py b/compose/network.py index 99c04649..d17ed080 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None, aliases=None): + ipam=None, external_name=None): self.client = client self.project = project self.name = name @@ -23,7 +23,6 @@ class Network(object): self.driver_opts = driver_opts self.ipam = create_ipam_config_from_dict(ipam) self.external_name = external_name - self.aliases = aliases or [] def ensure(self): if self.external_name: @@ -160,25 +159,32 @@ class ProjectNetworks(object): network.ensure() -def get_network_names_for_service(service_dict): +def get_network_aliases_for_service(service_dict): if 'network_mode' in service_dict: - return [] - return service_dict.get('networks', ['default']) + return {} + networks = service_dict.get('networks', ['default']) + if isinstance(networks, list): + return dict((net, []) for net in networks) + + return dict( + (net, (config or {}).get('aliases', [])) + for net, config in networks.items() + ) + + +def get_network_names_for_service(service_dict): + return get_network_aliases_for_service(service_dict).keys() def get_networks(service_dict, network_definitions): networks = {} - aliases = service_dict.get('network_aliases', {}) - for name in get_network_names_for_service(service_dict): - log.debug(name) + for name, aliases in get_network_aliases_for_service(service_dict).items(): network = network_definitions.get(name) if network: - log.debug(aliases) - networks[network.full_name] = aliases.get(name, []) + networks[network.full_name] = aliases else: raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' .format(service_dict['name'], name)) - log.debug(networks) return networks diff --git a/docs/compose-file.md b/docs/compose-file.md index 2e9632c4..45e1ac09 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -451,24 +451,6 @@ id. net: "none" net: "container:[service name or container name/id]" -### network_aliases - -> [Version 2 file format](#version-2) only. - -Alias names for this service on each joined network. All networks referenced -here must also appear under the `networks` key. - - networks: - - some-network - - other-network - network_aliases: - some-network: - - alias1 - - alias3 - other-network: - - alias2 - - alias4 - ### network_mode > [Version 2 file format](#version-2) only. In version 1, use [net](#net). @@ -493,6 +475,19 @@ Networks to join, referencing entries under the - some-network - other-network +#### aliases + +Alias names for this service on the specified network. + + networks: + some-network: + aliases: + - alias1 + - alias3 + other-network: + aliases: + - alias2 + ### pid pid: "host" diff --git a/tests/fixtures/networks/network-aliases.yml b/tests/fixtures/networks/network-aliases.yml index 987b0809..8cf7d5af 100644 --- a/tests/fixtures/networks/network-aliases.yml +++ b/tests/fixtures/networks/network-aliases.yml @@ -5,13 +5,11 @@ services: image: busybox command: top networks: - - front - - back - - network_aliases: front: - - forward_facing - - ahead + aliases: + - forward_facing + - ahead + back: networks: front: {} diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 88d46a14..1d6f1cbb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -556,27 +556,6 @@ class ConfigTest(unittest.TestCase): assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' - def test_invalid_network_alias(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox', - 'networks': ['hello'], - 'network_aliases': { - 'world': ['planet', 'universe'] - } - } - }, - 'networks': { - 'hello': {}, - 'world': {} - } - }) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert 'not declared in the networks list' in exc.exconly() - def test_config_build_configuration(self): service = config.load( build_config_details( From 7152f7ea7662633415de0750b6cb6b3f6742c847 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 14:52:52 -0800 Subject: [PATCH 142/580] Handle mismatched network formats in config files Signed-off-by: Joffrey F --- compose/config/config.py | 6 +++++- compose/network.py | 5 +---- docs/compose-file.md | 10 ++++++++- tests/acceptance/cli_test.py | 2 +- tests/integration/project_test.py | 4 ++-- tests/unit/config/config_test.py | 36 +++++++++++++++++++++++++++++++ tests/unit/project_test.py | 2 +- 7 files changed, 55 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index dbc6b6b2..d0024e9c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -612,6 +612,9 @@ def finalize_service(service_config, service_names, version): else: service_dict['network_mode'] = network_mode + if 'networks' in service_dict: + service_dict['networks'] = parse_networks(service_dict['networks']) + if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) @@ -701,6 +704,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('environment', parse_environment) md.merge_mapping('labels', parse_labels) md.merge_mapping('ulimits', parse_ulimits) + md.merge_mapping('networks', parse_networks) md.merge_sequence('links', ServiceLink.parse) for field in ['volumes', 'devices']: @@ -710,7 +714,6 @@ def merge_service_dicts(base, override, version): 'depends_on', 'expose', 'external_links', - 'networks', 'ports', 'volumes_from', ]: @@ -798,6 +801,7 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_build_arguments = functools.partial(parse_dict_or_list, split_env, 'build arguments') parse_environment = functools.partial(parse_dict_or_list, split_env, 'environment') parse_labels = functools.partial(parse_dict_or_list, split_label, 'labels') +parse_networks = functools.partial(parse_dict_or_list, lambda k: (k, None), 'networks') def parse_ulimits(ulimits): diff --git a/compose/network.py b/compose/network.py index d17ed080..135502cc 100644 --- a/compose/network.py +++ b/compose/network.py @@ -162,10 +162,7 @@ class ProjectNetworks(object): def get_network_aliases_for_service(service_dict): if 'network_mode' in service_dict: return {} - networks = service_dict.get('networks', ['default']) - if isinstance(networks, list): - return dict((net, []) for net in networks) - + networks = service_dict.get('networks', {'default': None}) return dict( (net, (config or {}).get('aliases', [])) for net, config in networks.items() diff --git a/docs/compose-file.md b/docs/compose-file.md index 45e1ac09..6441297f 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -477,7 +477,15 @@ Networks to join, referencing entries under the #### aliases -Alias names for this service on the specified network. +Aliases (alternative hostnames) for this service on the network. Other servers +on the network can use either the service name or this alias to connect to +this service. Since `alias` is network-scoped: + + * the same service can have different aliases when connected to another + network. + * it is allowable to configure the same alias name to multiple containers + (services) on the same network. + networks: some-network: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 4ba48d45..318ab3d3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -185,7 +185,7 @@ class CLITestCase(DockerClientTestCase): 'build': { 'context': os.path.abspath(self.base_dir), }, - 'networks': ['front', 'default'], + 'networks': {'front': None, 'default': None}, 'volumes_from': ['service:other:rw'], }, 'other': { diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6bb076a3..6542fa18 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,7 +565,7 @@ class ProjectTest(DockerClientTestCase): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', - 'networks': ['foo', 'bar', 'baz'], + 'networks': {'foo': None, 'bar': None, 'baz': None}, }], volumes={}, networks={ @@ -598,7 +598,7 @@ class ProjectTest(DockerClientTestCase): services=[{ 'name': 'web', 'image': 'busybox:latest', - 'networks': ['front'], + 'networks': {'front': None}, }], volumes={}, networks={ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1d6f1cbb..204003bc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -649,6 +649,42 @@ class ConfigTest(unittest.TestCase): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' + def test_load_with_multiple_files_mismatched_networks_format(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2', + 'services': { + 'web': { + 'image': 'example/web', + 'networks': { + 'foobar': {'aliases': ['foo', 'bar']} + } + } + }, + 'networks': {'foobar': {}, 'baz': {}} + } + ) + + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': '2', + 'services': { + 'web': { + 'networks': ['baz'] + } + } + } + ) + + details = config.ConfigDetails('.', [base_file, override_file]) + web_service = config.load(details).services[0] + assert web_service['networks'] == { + 'foobar': {'aliases': ['foo', 'bar']}, + 'baz': None + } + def test_load_with_multiple_files_v2(self): base_file = config.ConfigFile( 'base.yaml', diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index bec238de..c28c2152 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -438,7 +438,7 @@ class ProjectTest(unittest.TestCase): { 'name': 'foo', 'image': 'busybox:latest', - 'networks': ['custom'] + 'networks': {'custom': None} }, ], networks={'custom': {}}, From 0cb8ba37757d8e075be9630d96b08475aff8986a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 15:28:12 -0800 Subject: [PATCH 143/580] Use modern set notation in _get_aliases Signed-off-by: Joffrey F --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 5f40a457..8b22b7d7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -516,7 +516,7 @@ class Service(object): if container.labels.get(LABEL_ONE_OFF) == "True": return set() - return set([self.name, container.short_id]) + return {self.name, container.short_id} def _get_links(self, link_to_self): links = {} From 068a56eb97f7b8a0522f94eec7b66a95fff80a1d Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Thu, 18 Feb 2016 17:49:28 -0800 Subject: [PATCH 144/580] corrected description of network aliases, added real-world example per #2907 Signed-off-by: Victoria Bialas --- docs/compose-file.md | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 6441297f..77c69734 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -477,15 +477,13 @@ Networks to join, referencing entries under the #### aliases -Aliases (alternative hostnames) for this service on the network. Other servers -on the network can use either the service name or this alias to connect to -this service. Since `alias` is network-scoped: +Aliases (alternative hostnames) for this service on the network. Other containers on the same network can use either the service name or this alias to connect to one of the service's containers. - * the same service can have different aliases when connected to another - network. - * it is allowable to configure the same alias name to multiple containers - (services) on the same network. +Since `aliases` is network-scoped, the same service can have different aliases on different networks. +> **Note**: A network-wide alias can be shared by multiple containers, and even by multiple services. If it is, then exactly which container the name will resolve to is not guaranteed. + +The general format is shown here. networks: some-network: @@ -496,6 +494,35 @@ this service. Since `alias` is network-scoped: aliases: - alias2 +In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the legacy network. + + version: 2 + + services: + web: + build: ./web + networks: + - new + + worker: + build: ./worker + networks: + - legacy + + db: + image: mysql + networks: + new: + aliases: + - database + legacy: + aliases: + - mysql + + networks: + new: + legacy: + ### pid pid: "host" From 630a50295b30a4d30e233326a2f13d1d4b0d725d Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Thu, 18 Feb 2016 18:05:30 -0800 Subject: [PATCH 145/580] copyedit to make show as file format Signed-off-by: Victoria Bialas --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 77c69734..55d4109d 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -494,7 +494,7 @@ The general format is shown here. aliases: - alias2 -In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the legacy network. +In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the `legacy` network. version: 2 From eb4a98c0d141063c2d112c4de879e226a3c96c5e Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Thu, 18 Feb 2016 17:49:28 -0800 Subject: [PATCH 146/580] corrected description of network aliases, added real-world example per #2907 copyedit to make show as file format Signed-off-by: Victoria Bialas --- docs/compose-file.md | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 6441297f..55d4109d 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -477,15 +477,13 @@ Networks to join, referencing entries under the #### aliases -Aliases (alternative hostnames) for this service on the network. Other servers -on the network can use either the service name or this alias to connect to -this service. Since `alias` is network-scoped: +Aliases (alternative hostnames) for this service on the network. Other containers on the same network can use either the service name or this alias to connect to one of the service's containers. - * the same service can have different aliases when connected to another - network. - * it is allowable to configure the same alias name to multiple containers - (services) on the same network. +Since `aliases` is network-scoped, the same service can have different aliases on different networks. +> **Note**: A network-wide alias can be shared by multiple containers, and even by multiple services. If it is, then exactly which container the name will resolve to is not guaranteed. + +The general format is shown here. networks: some-network: @@ -496,6 +494,35 @@ this service. Since `alias` is network-scoped: aliases: - alias2 +In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the `legacy` network. + + version: 2 + + services: + web: + build: ./web + networks: + - new + + worker: + build: ./worker + networks: + - legacy + + db: + image: mysql + networks: + new: + aliases: + - database + legacy: + aliases: + - mysql + + networks: + new: + legacy: + ### pid pid: "host" From 520c695bf4f4fa7c41a0febb00234f21be776d43 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Feb 2016 17:55:21 +0000 Subject: [PATCH 147/580] Update Swarm integration guide and make it an official part of the docs Signed-off-by: Aanand Prasad --- SWARM.md | 40 +--------- docs/networking.md | 14 ++-- docs/production.md | 10 +-- docs/swarm.md | 184 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 51 deletions(-) create mode 100644 docs/swarm.md diff --git a/SWARM.md b/SWARM.md index 1ea4e25f..c6f378a9 100644 --- a/SWARM.md +++ b/SWARM.md @@ -1,39 +1 @@ -Docker Compose/Swarm integration -================================ - -Eventually, Compose and Swarm aim to have full integration, meaning you can point a Compose app at a Swarm cluster and have it all just work as if you were using a single Docker host. - -However, integration is currently incomplete: Compose can create containers on a Swarm cluster, but the majority of Compose apps won’t work out of the box unless all containers are scheduled on one host, because links between containers do not work across hosts. - -Docker networking is [getting overhauled](https://github.com/docker/libnetwork) in such a way that it’ll fit the multi-host model much better. For now, linked containers are automatically scheduled on the same host. - -Building --------- - -Swarm can build an image from a Dockerfile just like a single-host Docker instance can, but the resulting image will only live on a single node and won't be distributed to other nodes. - -If you want to use Compose to scale the service in question to multiple nodes, you'll have to build it yourself, push it to a registry (e.g. the Docker Hub) and reference it from `docker-compose.yml`: - - $ docker build -t myusername/web . - $ docker push myusername/web - - $ cat docker-compose.yml - web: - image: myusername/web - - $ docker-compose up -d - $ docker-compose scale web=3 - -Scheduling ----------- - -Swarm offers a rich set of scheduling and affinity hints, enabling you to control where containers are located. They are specified via container environment variables, so you can use Compose's `environment` option to set them. - - environment: - # Schedule containers on a node that has the 'storage' label set to 'ssd' - - "constraint:storage==ssd" - - # Schedule containers where the 'redis' image is already pulled - - "affinity:image==redis" - -For the full set of available filters and expressions, see the [Swarm documentation](https://docs.docker.com/swarm/scheduler/filter/). +This file has moved to: https://docs.docker.com/compose/swarm/ diff --git a/docs/networking.md b/docs/networking.md index d625ca19..1fd6c116 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -76,7 +76,9 @@ See the [links reference](compose-file.md#links) for more information. ## Multi-host networking -When deploying a Compose application to a Swarm cluster, you can make use of the built-in `overlay` driver to enable multi-host communication between containers with no changes to application code. Consult the [Getting started with multi-host networking](/engine/userguide/networking/get-started-overlay.md) to see how to set up the overlay driver, and then specify `driver: overlay` in your networking config (see the sections below for how to do this). +When [deploying a Compose application to a Swarm cluster](swarm.md), you can make use of the built-in `overlay` driver to enable multi-host communication between containers with no changes to your Compose file or application code. + +Consult the [Getting started with multi-host networking](/engine/userguide/networking/get-started-overlay.md) to see how to set up a Swarm cluster. The cluster will use the `overlay` driver by default, but you can specify it explicitly if you prefer - see below for how to do this. ## Specifying custom networks @@ -105,11 +107,11 @@ Here's an example Compose file defining two custom networks. The `proxy` service networks: front: - # Use the overlay driver for multi-host communication - driver: overlay + # Use a custom driver + driver: custom-driver-1 back: # Use a custom driver which takes special options - driver: my-custom-driver + driver: custom-driver-2 driver_opts: foo: "1" bar: "2" @@ -135,8 +137,8 @@ Instead of (or as well as) specifying your own networks, you can also change the networks: default: - # Use the overlay driver for multi-host communication - driver: overlay + # Use a custom driver + driver: custom-driver-1 ## Using a pre-existing network diff --git a/docs/production.md b/docs/production.md index dc9544ca..40ce1e66 100644 --- a/docs/production.md +++ b/docs/production.md @@ -60,7 +60,7 @@ recreating any services which `web` depends on. You can use Compose to deploy an app to a remote Docker host by setting the `DOCKER_HOST`, `DOCKER_TLS_VERIFY`, and `DOCKER_CERT_PATH` environment variables appropriately. For tasks like this, -[Docker Machine](https://docs.docker.com/machine/) makes managing local and +[Docker Machine](/machine/overview) makes managing local and remote Docker hosts very easy, and is recommended even if you're not deploying remotely. @@ -69,14 +69,12 @@ commands will work with no further configuration. ### Running Compose on a Swarm cluster -[Docker Swarm](https://docs.docker.com/swarm/), a Docker-native clustering +[Docker Swarm](/swarm/overview), a Docker-native clustering system, exposes the same API as a single Docker host, which means you can use Compose against a Swarm instance and run your apps across multiple hosts. -Compose/Swarm integration is still in the experimental stage, and Swarm is still -in beta, but if you'd like to explore and experiment, check out the integration -guide. +Compose/Swarm integration is still in the experimental stage, but if you'd like +to explore and experiment, check out the [integration guide](swarm.md). ## Compose documentation diff --git a/docs/swarm.md b/docs/swarm.md new file mode 100644 index 00000000..2b609efa --- /dev/null +++ b/docs/swarm.md @@ -0,0 +1,184 @@ + + + +# Using Compose with Swarm + +Docker Compose and [Docker Swarm](/swarm/overview) aim to have full integration, meaning +you can point a Compose app at a Swarm cluster and have it all just work as if +you were using a single Docker host. + +The actual extent of integration depends on which version of the [Compose file +format](compose-file.md#versioning) you are using: + +1. If you're using version 1 along with `links`, your app will work, but Swarm + will schedule all containers on one host, because links between containers + do not work across hosts with the old networking system. + +2. If you're using version 2, your app should work with no changes: + + - subject to the [limitations](#limitations) described below, + + - as long as the Swarm cluster is configured to use the [overlay + driver](/engine/userguide/networking/dockernetworks.md#an-overlay-network), + or a custom driver which supports multi-host networking. + +Read the [Getting started with multi-host +networking](/engine/userguide/networking/get-started-overlay.md) to see how to +set up a Swarm cluster with [Docker Machine](/machine/overview) and the overlay driver. +Once you've got it running, deploying your app to it should be as simple as: + + $ eval "$(docker-machine env --swarm )" + $ docker-compose up + + +## Limitations + +### Building images + +Swarm can build an image from a Dockerfile just like a single-host Docker +instance can, but the resulting image will only live on a single node and won't +be distributed to other nodes. + +If you want to use Compose to scale the service in question to multiple nodes, +you'll have to build it yourself, push it to a registry (e.g. the Docker Hub) +and reference it from `docker-compose.yml`: + + $ docker build -t myusername/web . + $ docker push myusername/web + + $ cat docker-compose.yml + web: + image: myusername/web + + $ docker-compose up -d + $ docker-compose scale web=3 + +### Multiple dependencies + +If a service has multiple dependencies of the type which force co-scheduling +(see [Automatic scheduling](#automatic-scheduling) below), it's possible that +Swarm will schedule the dependencies on different nodes, making the dependent +service impossible to schedule. For example, here `foo` needs to be co-scheduled +with `bar` and `baz`: + + version: "2" + services: + foo: + image: foo + volumes_from: ["bar"] + network_mode: "service:baz" + bar: + image: bar + baz: + image: baz + +The problem is that Swarm might first schedule `bar` and `baz` on different +nodes (since they're not dependent on one another), making it impossible to +pick an appropriate node for `foo`. + +To work around this, use [manual scheduling](#manual-scheduling) to ensure that +all three services end up on the same node: + + version: "2" + services: + foo: + image: foo + volumes_from: ["bar"] + network_mode: "service:baz" + environment: + - "constraint:node==node-1" + bar: + image: bar + environment: + - "constraint:node==node-1" + baz: + image: baz + environment: + - "constraint:node==node-1" + +### Host ports and recreating containers + +If a service maps a port from the host, e.g. `80:8000`, then you may get an +error like this when running `docker-compose up` on it after the first time: + + docker: Error response from daemon: unable to find a node that satisfies + container==6ab2dfe36615ae786ef3fc35d641a260e3ea9663d6e69c5b70ce0ca6cb373c02. + +The usual cause of this error is that the container has a volume (defined either +in its image or in the Compose file) without an explicit mapping, and so in +order to preserve its data, Compose has directed Swarm to schedule the new +container on the same node as the old container. This results in a port clash. + +There are two viable workarounds for this problem: + +- Specify a named volume, and use a volume driver which is capable of mounting + the volume into the container regardless of what node it's scheduled on. + + Compose does not give Swarm any specific scheduling instructions if a + service uses only named volumes. + + version: "2" + + services: + web: + build: . + ports: + - "80:8000" + volumes: + - web-logs:/var/log/web + + volumes: + web-logs: + driver: custom-volume-driver + +- Remove the old container before creating the new one. You will lose any data + in the volume. + + $ docker-compose stop web + $ docker-compose rm -f web + $ docker-compose up web + + +## Scheduling containers + +### Automatic scheduling + +Some configuration options will result in containers being automatically +scheduled on the same Swarm node to ensure that they work correctly. These are: + +- `network_mode: "service:..."` and `network_mode: "container:..."` (and + `net: "container:..."` in the version 1 file format). + +- `volumes_from` + +- `links` + +### Manual scheduling + +Swarm offers a rich set of scheduling and affinity hints, enabling you to +control where containers are located. They are specified via container +environment variables, so you can use Compose's `environment` option to set +them. + + # Schedule containers on a specific node + environment: + - "constraint:node==node-1" + + # Schedule containers on a node that has the 'storage' label set to 'ssd' + environment: + - "constraint:storage==ssd" + + # Schedule containers where the 'redis' image is already pulled + environment: + - "affinity:image==redis" + +For the full set of available filters and expressions, see the [Swarm +documentation](/swarm/scheduler/filter.md). From 4b2a66623199ad4c281b688957d1cf7ec282abbd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 17:30:23 -0500 Subject: [PATCH 148/580] Validate that each section of the config is a mapping before running interpolation. Signed-off-by: Daniel Nephin --- compose/config/config.py | 31 +++++++++++------ compose/config/interpolation.py | 2 +- compose/config/validation.py | 59 ++++++++++++++++++++++---------- tests/acceptance/cli_test.py | 2 +- tests/unit/config/config_test.py | 34 +++++++++++++++--- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index d0024e9c..055ae18a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -33,11 +33,11 @@ from .types import VolumeSpec from .validation import match_named_volumes from .validation import validate_against_fields_schema from .validation import validate_against_service_schema +from .validation import validate_config_section from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_network_mode from .validation import validate_top_level_object -from .validation import validate_top_level_service_objects from .validation import validate_ulimits @@ -388,22 +388,31 @@ def load_services(working_dir, config_file, service_configs): return build_services(service_config) -def process_config_file(config_file, service_name=None): - service_dicts = config_file.get_service_dicts() - validate_top_level_service_objects(config_file.filename, service_dicts) +def interpolate_config_section(filename, config, section): + validate_config_section(filename, config, section) + return interpolate_environment_variables(config, section) - interpolated_config = interpolate_environment_variables(service_dicts, 'service') + +def process_config_file(config_file, service_name=None): + services = interpolate_config_section( + config_file.filename, + config_file.get_service_dicts(), + 'service') if config_file.version == V2_0: processed_config = dict(config_file.config) - processed_config['services'] = services = interpolated_config - processed_config['volumes'] = interpolate_environment_variables( - config_file.get_volumes(), 'volume') - processed_config['networks'] = interpolate_environment_variables( - config_file.get_networks(), 'network') + processed_config['services'] = services + processed_config['volumes'] = interpolate_config_section( + config_file.filename, + config_file.get_volumes(), + 'volume') + processed_config['networks'] = interpolate_config_section( + config_file.filename, + config_file.get_networks(), + 'network') if config_file.version == V1: - processed_config = services = interpolated_config + processed_config = services config_file = config_file._replace(config=processed_config) validate_against_fields_schema(config_file) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index e1c781fe..1e56ebb6 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -21,7 +21,7 @@ def interpolate_environment_variables(config, section): ) return dict( - (name, process_item(name, config_dict)) + (name, process_item(name, config_dict or {})) for name, config_dict in config.items() ) diff --git a/compose/config/validation.py b/compose/config/validation.py index 35727e2c..557e5768 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -91,29 +91,50 @@ def match_named_volumes(service_dict, project_volumes): ) -def validate_top_level_service_objects(filename, service_dicts): - """Perform some high level validation of the service name and value. +def python_type_to_yaml_type(type_): + type_name = type(type_).__name__ + return { + 'dict': 'mapping', + 'list': 'array', + 'int': 'number', + 'float': 'number', + 'bool': 'boolean', + 'unicode': 'string', + 'str': 'string', + 'bytes': 'string', + }.get(type_name, type_name) - This validation must happen before interpolation, which must happen - before the rest of validation, which is why it's separate from the - rest of the service validation. + +def validate_config_section(filename, config, section): + """Validate the structure of a configuration section. This must be done + before interpolation so it's separate from schema validation. """ - for service_name, service_dict in service_dicts.items(): - if not isinstance(service_name, six.string_types): - raise ConfigurationError( - "In file '{}' service name: {} needs to be a string, eg '{}'".format( - filename, - service_name, - service_name)) + if not isinstance(config, dict): + raise ConfigurationError( + "In file '{filename}' {section} must be a mapping, not " + "'{type}'.".format( + filename=filename, + section=section, + type=python_type_to_yaml_type(config))) - if not isinstance(service_dict, dict): + for key, value in config.items(): + if not isinstance(key, six.string_types): raise ConfigurationError( - "In file '{}' service '{}' doesn\'t have any configuration options. " - "All top level keys in your docker-compose.yml must map " - "to a dictionary of configuration options.".format( - filename, service_name - ) - ) + "In file '{filename}' {section} name {name} needs to be a " + "string, eg '{name}'".format( + filename=filename, + section=section, + name=key)) + + if not isinstance(value, (dict, type(None))): + raise ConfigurationError( + "In file '{filename}' {section} '{name}' is the wrong type. " + "It should be a mapping of configuration options, it is a " + "'{type}'.".format( + filename=filename, + section=section, + name=key, + type=python_type_to_yaml_type(value))) def validate_top_level_object(config_file): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 318ab3d3..f4392693 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -159,7 +159,7 @@ class CLITestCase(DockerClientTestCase): '-f', 'tests/fixtures/invalid-composefile/invalid.yml', 'config', '-q' ], returncode=1) - assert "'notaservice' doesn't have any configuration" in result.stderr + assert "'notaservice' is the wrong type" in result.stderr # TODO: this shouldn't be v2-dependent @v2_only() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 204003bc..c58ddc60 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,7 +231,7 @@ class ConfigTest(unittest.TestCase): assert volumes['simple'] == {} assert volumes['other'] == {} - def test_volume_numeric_driver_opt(self): + def test_named_volume_numeric_driver_opt(self): config_details = build_config_details({ 'version': '2', 'services': { @@ -258,6 +258,30 @@ class ConfigTest(unittest.TestCase): config.load(config_details) assert 'driver_opts.size contains an invalid type' in exc.exconly() + def test_named_volume_invalid_type_list(self): + config_details = build_config_details({ + 'version': '2', + 'services': { + 'simple': {'image': 'busybox'} + }, + 'volumes': [] + }) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert "volume must be a mapping, not 'array'" in exc.exconly() + + def test_networks_invalid_type_list(self): + config_details = build_config_details({ + 'version': '2', + 'services': { + 'simple': {'image': 'busybox'} + }, + 'networks': [] + }) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert "network must be a mapping, not 'array'" in exc.exconly() + def test_load_service_with_name_version(self): with mock.patch('compose.config.config.log') as mock_logging: config_data = config.load( @@ -368,7 +392,7 @@ class ConfigTest(unittest.TestCase): 'filename.yml') with pytest.raises(ConfigurationError) as exc: config.load(config_details) - error_msg = "service 'web' doesn't have any configuration options" + error_msg = "service 'web' is the wrong type" assert error_msg in exc.exconly() def test_config_integer_service_name_raise_validation_error(self): @@ -381,7 +405,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \ + assert "In file 'filename.yml' service name 1 needs to be a string, eg '1'" \ in excinfo.exconly() def test_config_integer_service_name_raise_validation_error_v2(self): @@ -397,7 +421,7 @@ class ConfigTest(unittest.TestCase): ) ) - assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \ + assert "In file 'filename.yml' service name 1 needs to be a string, eg '1'" \ in excinfo.exconly() def test_load_with_multiple_files_v1(self): @@ -532,7 +556,7 @@ class ConfigTest(unittest.TestCase): with pytest.raises(ConfigurationError) as exc: config.load(details) - assert "service 'bogus' doesn't have any configuration" in exc.exconly() + assert "service 'bogus' is the wrong type" in exc.exconly() assert "In file 'override.yaml'" in exc.exconly() def test_load_sorts_in_dependency_order(self): From 0d218c34c7b58144188085f748be031efd316d1c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 19 Feb 2016 12:35:05 -0500 Subject: [PATCH 149/580] Make config validation error messages more consistent. Signed-off-by: Daniel Nephin --- compose/config/validation.py | 33 ++++++++++++++++---------------- docs/compose-file.md | 2 +- tests/acceptance/cli_test.py | 2 +- tests/unit/config/config_test.py | 21 +++++++++++--------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 557e5768..6dc72f56 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -111,30 +111,29 @@ def validate_config_section(filename, config, section): """ if not isinstance(config, dict): raise ConfigurationError( - "In file '{filename}' {section} must be a mapping, not " - "'{type}'.".format( + "In file '{filename}', {section} must be a mapping, not " + "{type}.".format( filename=filename, section=section, - type=python_type_to_yaml_type(config))) + type=anglicize_json_type(python_type_to_yaml_type(config)))) for key, value in config.items(): if not isinstance(key, six.string_types): raise ConfigurationError( - "In file '{filename}' {section} name {name} needs to be a " - "string, eg '{name}'".format( + "In file '{filename}', the {section} name {name} must be a " + "quoted string, i.e. '{name}'.".format( filename=filename, section=section, name=key)) if not isinstance(value, (dict, type(None))): raise ConfigurationError( - "In file '{filename}' {section} '{name}' is the wrong type. " - "It should be a mapping of configuration options, it is a " - "'{type}'.".format( + "In file '{filename}', {section} '{name}' must be a mapping not " + "{type}.".format( filename=filename, section=section, name=key, - type=python_type_to_yaml_type(value))) + type=anglicize_json_type(python_type_to_yaml_type(value)))) def validate_top_level_object(config_file): @@ -203,10 +202,10 @@ def get_unsupported_config_msg(path, error_key): return msg -def anglicize_validator(validator): - if validator in ["array", "object"]: - return 'an ' + validator - return 'a ' + validator +def anglicize_json_type(json_type): + if json_type.startswith(('a', 'e', 'i', 'o', 'u')): + return 'an ' + json_type + return 'a ' + json_type def is_service_dict_schema(schema_id): @@ -314,14 +313,14 @@ def _parse_valid_types_from_validator(validator): a valid type. Parse the valid types and prefix with the correct article. """ if not isinstance(validator, list): - return anglicize_validator(validator) + return anglicize_json_type(validator) if len(validator) == 1: - return anglicize_validator(validator[0]) + return anglicize_json_type(validator[0]) return "{}, or {}".format( - ", ".join([anglicize_validator(validator[0])] + validator[1:-1]), - anglicize_validator(validator[-1])) + ", ".join([anglicize_json_type(validator[0])] + validator[1:-1]), + anglicize_json_type(validator[-1])) def _parse_oneof_validator(error): diff --git a/docs/compose-file.md b/docs/compose-file.md index 55d4109d..f446e2ab 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -477,7 +477,7 @@ Networks to join, referencing entries under the #### aliases -Aliases (alternative hostnames) for this service on the network. Other containers on the same network can use either the service name or this alias to connect to one of the service's containers. +Aliases (alternative hostnames) for this service on the network. Other containers on the same network can use either the service name or this alias to connect to one of the service's containers. Since `aliases` is network-scoped, the same service can have different aliases on different networks. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f4392693..6c5b7818 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -159,7 +159,7 @@ class CLITestCase(DockerClientTestCase): '-f', 'tests/fixtures/invalid-composefile/invalid.yml', 'config', '-q' ], returncode=1) - assert "'notaservice' is the wrong type" in result.stderr + assert "'notaservice' must be a mapping" in result.stderr # TODO: this shouldn't be v2-dependent @v2_only() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c58ddc60..1f5183d7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -268,7 +268,7 @@ class ConfigTest(unittest.TestCase): }) with pytest.raises(ConfigurationError) as exc: config.load(config_details) - assert "volume must be a mapping, not 'array'" in exc.exconly() + assert "volume must be a mapping, not an array" in exc.exconly() def test_networks_invalid_type_list(self): config_details = build_config_details({ @@ -280,7 +280,7 @@ class ConfigTest(unittest.TestCase): }) with pytest.raises(ConfigurationError) as exc: config.load(config_details) - assert "network must be a mapping, not 'array'" in exc.exconly() + assert "network must be a mapping, not an array" in exc.exconly() def test_load_service_with_name_version(self): with mock.patch('compose.config.config.log') as mock_logging: @@ -392,8 +392,7 @@ class ConfigTest(unittest.TestCase): 'filename.yml') with pytest.raises(ConfigurationError) as exc: config.load(config_details) - error_msg = "service 'web' is the wrong type" - assert error_msg in exc.exconly() + assert "service 'web' must be a mapping not a string." in exc.exconly() def test_config_integer_service_name_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: @@ -405,8 +404,10 @@ class ConfigTest(unittest.TestCase): ) ) - assert "In file 'filename.yml' service name 1 needs to be a string, eg '1'" \ - in excinfo.exconly() + assert ( + "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'" in + excinfo.exconly() + ) def test_config_integer_service_name_raise_validation_error_v2(self): with pytest.raises(ConfigurationError) as excinfo: @@ -421,8 +422,10 @@ class ConfigTest(unittest.TestCase): ) ) - assert "In file 'filename.yml' service name 1 needs to be a string, eg '1'" \ - in excinfo.exconly() + assert ( + "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'." in + excinfo.exconly() + ) def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( @@ -556,7 +559,7 @@ class ConfigTest(unittest.TestCase): with pytest.raises(ConfigurationError) as exc: config.load(details) - assert "service 'bogus' is the wrong type" in exc.exconly() + assert "service 'bogus' must be a mapping not a string." in exc.exconly() assert "In file 'override.yaml'" in exc.exconly() def test_load_sorts_in_dependency_order(self): From 02535f0cf159b74fef8123a17e29c660e1891565 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 19 Feb 2016 14:22:55 -0500 Subject: [PATCH 150/580] Fix validation message when there are multiple ested oneOf validations. Signed-off-by: Daniel Nephin --- compose/config/validation.py | 4 ++++ tests/unit/config/config_test.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/compose/config/validation.py b/compose/config/validation.py index 35727e2c..fc737a4f 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -312,6 +312,10 @@ def _parse_oneof_validator(error): types = [] for context in error.context: + if context.validator == 'oneOf': + _, error_msg = _parse_oneof_validator(context) + return path_string(context.path), error_msg + if context.validator == 'required': return (None, context.message) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 204003bc..ea90c50e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -371,6 +371,27 @@ class ConfigTest(unittest.TestCase): error_msg = "service 'web' doesn't have any configuration options" assert error_msg in exc.exconly() + def test_load_with_empty_build_args(self): + config_details = build_config_details( + { + 'version': '2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'args': None, + }, + }, + }, + } + ) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert ( + "services.web.build.args contains an invalid type, it should be an " + "array, or an object" in exc.exconly() + ) + def test_config_integer_service_name_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( From ba39d4cc77f5d9ffccff68ae738e741278f60ace Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 Feb 2016 12:56:54 -0800 Subject: [PATCH 151/580] Use docker-py 1.7.1 Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2204e6d5..5f55ba8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 +docker-py==1.7.1 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@bba8e28f822c4cd3ebe2a2ca588f41f9d7d66e26#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 From 0a06d827faa554f07ce515106fcc3e42af340e7a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 14:48:56 -0800 Subject: [PATCH 152/580] Fix warning about boolean values. Signed-off-by: Daniel Nephin --- compose/config/validation.py | 12 ++++++------ tests/unit/config/config_test.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 4e2083cb..60ee5c93 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -64,16 +64,16 @@ def format_expose(instance): @FormatChecker.cls_checks(format="bool-value-in-mapping") def format_boolean_in_environment(instance): - """ - Check if there is a boolean in the environment and display a warning. + """Check if there is a boolean in the mapping sections and display a warning. Always return True here so the validation won't raise an error. """ if isinstance(instance, bool): log.warn( - "There is a boolean value in the 'environment' key.\n" - "Environment variables can only be strings.\n" - "Please add quotes to any boolean values to make them string " - "(eg, 'True', 'yes', 'N').\n" + "There is a boolean value in the 'environment', 'labels', or " + "'extra_hosts' field of a service.\n" + "These sections only support string values.\n" + "Please add quotes to any boolean values to make them strings " + "(eg, 'True', 'false', 'yes', 'N', 'on', 'Off').\n" "This warning will become an error in a future release. \r\n" ) return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ce37d794..4d3bb7be 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1100,7 +1100,7 @@ class ConfigTest(unittest.TestCase): @mock.patch('compose.config.validation.log') def test_logs_warning_for_boolean_in_environment(self, mock_logging): - expected_warning_msg = "There is a boolean value in the 'environment' key." + expected_warning_msg = "There is a boolean value in the 'environment'" config.load( build_config_details( {'web': { From bf2bf21720c88b08ccb273c46c943bb72a8dccf8 Mon Sep 17 00:00:00 2001 From: Richard Bann Date: Thu, 18 Feb 2016 12:13:16 +0100 Subject: [PATCH 153/580] Add failing test for --abort-on-container-exit Handle --abort-on-container-exit. Fixes #2940 Signed-off-by: Richard Bann --- compose/cli/log_printer.py | 11 ++++++++--- compose/cli/main.py | 3 +++ compose/cli/signals.py | 4 ++++ tests/acceptance/cli_test.py | 6 ++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 85fef794..b7abc007 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,6 +5,7 @@ import sys from itertools import cycle from . import colors +from . import signals from .multiplexer import Multiplexer from compose import utils from compose.utils import split_buffer @@ -41,7 +42,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func) + yield generator_func(container, prefix, color_func, self.cascade_stop) def build_log_prefix(container, prefix_width): @@ -64,7 +65,7 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func): +def build_no_log_generator(container, prefix, color_func, cascade_stop): """Return a generator that prints a warning about logs and waits for container to exit. """ @@ -72,9 +73,11 @@ def build_no_log_generator(container, prefix, color_func): prefix, container.log_driver) yield color_func(wait_on_exit(container)) + if cascade_stop: + raise signals.CascadeStopException() -def build_log_generator(container, prefix, color_func): +def build_log_generator(container, prefix, color_func, cascade_stop): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: @@ -86,6 +89,8 @@ def build_log_generator(container, prefix, color_func): for line in line_generator: yield prefix + line yield color_func(wait_on_exit(container)) + if cascade_stop: + raise signals.CascadeStopException() def wait_on_exit(container): diff --git a/compose/cli/main.py b/compose/cli/main.py index cc15fa05..5a7ac8d4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -774,6 +774,9 @@ def up_shutdown_context(project, service_names, timeout, detached): except signals.ShutdownException: print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) + except signals.CascadeStopException: + print("Aborting on container exit... (press Ctrl+C to force)") + project.stop(service_names=service_names, timeout=timeout) except signals.ShutdownException: project.kill(service_names=service_names) sys.exit(2) diff --git a/compose/cli/signals.py b/compose/cli/signals.py index 68a0598e..808700df 100644 --- a/compose/cli/signals.py +++ b/compose/cli/signals.py @@ -8,6 +8,10 @@ class ShutdownException(Exception): pass +class CascadeStopException(Exception): + pass + + def shutdown(signal, frame): raise ShutdownException() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 318ab3d3..23427e99 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -746,6 +746,12 @@ class CLITestCase(DockerClientTestCase): os.kill(proc.pid, signal.SIGTERM) wait_on_condition(ContainerCountCondition(self.project, 0)) + 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']) + wait_on_condition(ContainerCountCondition(self.project, 0)) + def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', 'console', '/bin/true']) From 15b2094bad405e6524be7c365f7db055976fe93e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 16:46:09 -0800 Subject: [PATCH 154/580] Stop other containers if the flag is set. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 11 +++-------- compose/cli/main.py | 7 ++++--- compose/cli/signals.py | 4 ---- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index b7abc007..85fef794 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,7 +5,6 @@ import sys from itertools import cycle from . import colors -from . import signals from .multiplexer import Multiplexer from compose import utils from compose.utils import split_buffer @@ -42,7 +41,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func, self.cascade_stop) + yield generator_func(container, prefix, color_func) def build_log_prefix(container, prefix_width): @@ -65,7 +64,7 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func, cascade_stop): +def build_no_log_generator(container, prefix, color_func): """Return a generator that prints a warning about logs and waits for container to exit. """ @@ -73,11 +72,9 @@ def build_no_log_generator(container, prefix, color_func, cascade_stop): prefix, container.log_driver) yield color_func(wait_on_exit(container)) - if cascade_stop: - raise signals.CascadeStopException() -def build_log_generator(container, prefix, color_func, cascade_stop): +def build_log_generator(container, prefix, color_func): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: @@ -89,8 +86,6 @@ def build_log_generator(container, prefix, color_func, cascade_stop): for line in line_generator: yield prefix + line yield color_func(wait_on_exit(container)) - if cascade_stop: - raise signals.CascadeStopException() def wait_on_exit(container): diff --git a/compose/cli/main.py b/compose/cli/main.py index 5a7ac8d4..3c4b5721 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -662,6 +662,10 @@ class TopLevelCommand(DocoptCommand): print("Attaching to", list_containers(log_printer.containers)) log_printer.run() + if cascade_stop: + print("Aborting on container exit...") + project.stop(service_names=service_names, timeout=timeout) + def version(self, project, options): """ Show version informations @@ -774,9 +778,6 @@ def up_shutdown_context(project, service_names, timeout, detached): except signals.ShutdownException: print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) - except signals.CascadeStopException: - print("Aborting on container exit... (press Ctrl+C to force)") - project.stop(service_names=service_names, timeout=timeout) except signals.ShutdownException: project.kill(service_names=service_names) sys.exit(2) diff --git a/compose/cli/signals.py b/compose/cli/signals.py index 808700df..68a0598e 100644 --- a/compose/cli/signals.py +++ b/compose/cli/signals.py @@ -8,10 +8,6 @@ class ShutdownException(Exception): pass -class CascadeStopException(Exception): - pass - - def shutdown(signal, frame): raise ShutdownException() From 176b9664863144aac7199fbd293cfa5c720a68ac Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 17:17:20 -0800 Subject: [PATCH 155/580] Update documentation for volume_driver option. Signed-off-by: Joffrey F --- docs/compose-file.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index f446e2ab..514e6a03 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -582,10 +582,11 @@ limit as an integer or soft/hard limits as a mapping. ### volumes, volume\_driver Mount paths or named volumes, optionally specifying a path on the host machine -(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). Named volumes can -be specified with the -[top-level `volumes` key](#volume-configuration-reference), but this is -optional - the Docker Engine will create the volume if it doesn't exist. +(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). +For [version 2 files](#version-2), named volumes need to be specified with the +[top-level `volumes` key](#volume-configuration-reference). +When using [version 1](#version-1), the Docker Engine will create the named +volume automatically if it doesn't exist. You can mount a relative path on the host, which will expand relative to the directory of the Compose configuration file being used. Relative paths @@ -607,11 +608,16 @@ should always begin with `.` or `..`. # Named volume - datavolume:/var/lib/mysql -If you use a volume name (instead of a volume path), you may also specify -a `volume_driver`. +If you do not use a host path, you may specify a `volume_driver`. volume_driver: mydriver +Note that for [version 2 files](#version-2), this driver +will not apply to named volumes (you should use the `driver` option when +[declaring the volume](#volume-configuration-reference) instead). +For [version 1](#version-1), both named volumes and container volumes will +use the specified driver. + > Note: No path expansion will be done if you have also specified a > `volume_driver`. From 4b04280db83b5d8c4b259586df8ae568eee5f3a0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 17:30:42 -0800 Subject: [PATCH 156/580] Revert "Change special case from '_', None to ()" This reverts commit 677c50650c86b4b6fabbc21e18165f2117022bbe. Revert "Modify service_test.py::ServiceTest::test_resolve_env to reflect new behavior" This reverts commit 001903771260069c475738efbbcb830dd9cf8227. Revert "Mangle the tests. They pass for better or worse!" This reverts commit 7ab9509ce65167dc81dd14f34cddfb5ecff1329d. Revert "If an env var is passthrough but not defined on the host don't set it." This reverts commit 6540efb3d380e7ae50dd94493a43382f31e1e004. Signed-off-by: Daniel Nephin --- compose/config/config.py | 6 +++--- tests/integration/service_test.py | 1 + tests/unit/config/config_test.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 055ae18a..98b825ec 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -512,12 +512,12 @@ def resolve_environment(service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) - return dict(filter(None, (resolve_env_var(k, v) for k, v in six.iteritems(env)))) + return dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) def resolve_build_args(build): args = parse_build_arguments(build.get('args')) - return dict(filter(None, (resolve_env_var(k, v) for k, v in six.iteritems(args)))) + return dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) def validate_extended_service_dict(service_dict, filename, service): @@ -827,7 +827,7 @@ def resolve_env_var(key, val): elif key in os.environ: return key, os.environ[key] else: - return () + return key, '' def env_vars_from_file(filename): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index bcb87335..129d996d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -916,6 +916,7 @@ class ServiceTest(DockerClientTestCase): 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', + 'NO_DEF': '' }.items(): self.assertEqual(env[k], v) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4d3bb7be..446fc560 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1975,7 +1975,7 @@ class EnvTest(unittest.TestCase): } self.assertEqual( resolve_environment(service_dict), - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3'}, + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) def test_resolve_environment_from_env_file(self): @@ -2016,6 +2016,7 @@ class EnvTest(unittest.TestCase): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', + 'NO_DEF': '' }, ) @@ -2034,7 +2035,7 @@ class EnvTest(unittest.TestCase): } self.assertEqual( resolve_build_args(build), - {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2'}, + {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': ''}, ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') From d4515781525f57e0cf92e115379191cd1f3a1e9a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 17:47:51 -0800 Subject: [PATCH 157/580] Make environment variables without a value the same as docker-cli. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 +- compose/container.py | 6 +++++- compose/service.py | 11 +++++++++++ tests/integration/service_test.py | 2 +- tests/unit/cli_test.py | 7 ++++--- tests/unit/config/config_test.py | 6 +++--- tests/unit/service_test.py | 6 +++--- 7 files changed, 28 insertions(+), 12 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 98b825ec..4e91a3af 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -827,7 +827,7 @@ def resolve_env_var(key, val): elif key in os.environ: return key, os.environ[key] else: - return key, '' + return key, None def env_vars_from_file(filename): diff --git a/compose/container.py b/compose/container.py index 3a1ce0b9..c96b63ef 100644 --- a/compose/container.py +++ b/compose/container.py @@ -134,7 +134,11 @@ class Container(object): @property def environment(self): - return dict(var.split("=", 1) for var in self.get('Config.Env') or []) + def parse_env(var): + if '=' in var: + return var.split("=", 1) + return var, None + return dict(parse_env(var) for var in self.get('Config.Env') or []) @property def exit_code(self): diff --git a/compose/service.py b/compose/service.py index 8b22b7d7..01f17a12 100644 --- a/compose/service.py +++ b/compose/service.py @@ -622,6 +622,8 @@ class Service(object): override_options, one_off=one_off) + container_options['environment'] = format_environment( + container_options['environment']) return container_options def _get_container_host_config(self, override_options, one_off=False): @@ -1020,3 +1022,12 @@ def get_log_config(logging_dict): type=log_driver, config=log_options ) + + +# TODO: remove once fix is available in docker-py +def format_environment(environment): + def format_env(key, value): + if value is None: + return key + return '{key}={value}'.format(key=key, value=value) + return [format_env(*item) for item in environment.items()] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 129d996d..968c0947 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -916,7 +916,7 @@ class ServiceTest(DockerClientTestCase): 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' + 'NO_DEF': None }.items(): self.assertEqual(env[k], v) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 69236e2e..26ae4e30 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -138,9 +138,10 @@ class CLITestCase(unittest.TestCase): }) _, _, call_kwargs = mock_client.create_container.mock_calls[0] - self.assertEqual( - call_kwargs['environment'], - {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': u'bär'}) + assert ( + sorted(call_kwargs['environment']) == + sorted(['FOO=ONE', 'BAR=NEW', 'OTHER=bär']) + ) def test_run_service_with_restart_always(self): command = TopLevelCommand() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 446fc560..11bc7f0b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1975,7 +1975,7 @@ class EnvTest(unittest.TestCase): } self.assertEqual( resolve_environment(service_dict), - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, ) def test_resolve_environment_from_env_file(self): @@ -2016,7 +2016,7 @@ class EnvTest(unittest.TestCase): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' + 'NO_DEF': None }, ) @@ -2035,7 +2035,7 @@ class EnvTest(unittest.TestCase): } self.assertEqual( resolve_build_args(build), - {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': ''}, + {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index ce28a9ca..321ebad0 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -267,7 +267,7 @@ class ServiceTest(unittest.TestCase): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], 'f8bfa1058ad1f4231372a0b1639f0dfdb574dafff4e8d7938049ae993f7cf1fc') - assert opts['environment'] == {'also': 'real'} + assert opts['environment'] == ['also=real'] def test_get_container_create_options_sets_affinity_with_binds(self): service = Service( @@ -298,7 +298,7 @@ class ServiceTest(unittest.TestCase): 1, previous_container=prev_container) - assert opts['environment'] == {'affinity:container': '=ababab'} + assert opts['environment'] == ['affinity:container==ababab'] def test_get_container_create_options_no_affinity_without_binds(self): service = Service('foo', image='foo', client=self.mock_client) @@ -312,7 +312,7 @@ class ServiceTest(unittest.TestCase): {}, 1, previous_container=prev_container) - assert opts['environment'] == {} + assert opts['environment'] == [] def test_get_container_not_found(self): self.mock_client.containers.return_value = [] From e6797e116648fb566305b39040d5fade83aacffc Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Mon, 22 Feb 2016 18:50:09 -0800 Subject: [PATCH 158/580] updated Wordpress example to be easier to follow, added/updated images docs update per Mary's comments on the PR Signed-off-by: Victoria Bialas --- docs/django.md | 5 +- docs/gettingstarted.md | 2 +- docs/images/django-it-worked.png | Bin 21041 -> 28446 bytes docs/images/rails-welcome.png | Bin 62372 -> 71034 bytes docs/images/wordpress-files.png | Bin 0 -> 70823 bytes docs/images/wordpress-lang.png | Bin 0 -> 30149 bytes docs/images/wordpress-welcome.png | Bin 0 -> 62063 bytes docs/rails.md | 4 +- docs/wordpress.md | 169 +++++++++++++++++++----------- 9 files changed, 112 insertions(+), 68 deletions(-) create mode 100644 docs/images/wordpress-files.png create mode 100644 docs/images/wordpress-lang.png create mode 100644 docs/images/wordpress-welcome.png diff --git a/docs/django.md b/docs/django.md index c8863b34..fb1fa214 100644 --- a/docs/django.md +++ b/docs/django.md @@ -10,10 +10,9 @@ weight=4 -# Quickstart: Compose and Django +# Quickstart: Docker Compose and Django -This quick-start guide demonstrates how to use Compose to set up and run a -simple Django/PostgreSQL app. Before starting, you'll need to have +This quick-start guide demonstrates how to use Docker Compose to set up and run a simple Django/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). ## Define the project components diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 36577f07..60482bce 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -12,7 +12,7 @@ weight=-85 # Getting Started -On this page you build a simple Python web application running on Compose. The +On this page you build a simple Python web application running on Docker Compose. The application uses the Flask framework and increments a value in Redis. While the sample uses Python, the concepts demonstrated here should be understandable even if you're not familiar with it. diff --git a/docs/images/django-it-worked.png b/docs/images/django-it-worked.png index 2e8266279ec0ffdaa35239562dd340e2abfebbf6..75769754b975748dea24a9896083e6e9447d121f 100644 GIT binary patch literal 28446 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 a6Y}y^58ih#ifY@Kcf`{7Q?LtYPUU~rH#6%1 literal 62372 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%0A+EZjGlQg-UrTNSA}s5N~^R8&^Cr1Px&x-pAVLa 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 diff --git a/docs/images/wordpress-files.png b/docs/images/wordpress-files.png new file mode 100644 index 0000000000000000000000000000000000000000..4762935baeb03568530e962231687bbcd38d075b GIT binary patch literal 70823 zcmaI6WmubCumy@+(PG8jp}1>@{m-!@36)^37pXM znD1VM8;-&1Zfiy(c}Mz%d*mQZ$||2=Vyl zQhFrjpVjk}~W489Mo0X^!jJwzqhl!NuGG zB?E>Q{0P(A!OFODmGJA)V0^>3itn#sG;8ix2{K9EA=RL=fDB{CB$OW&AMDa1qIw475R^CS5GDylsDwBL;Kz~1zRR^kG(x=%)tdKQLm-_SSwxL*qex;%(hVtMRbZ6_ zSxHzSSfLegf3H0tU$XPJ#0a?Uu?Q`mWN<*q#7b>`p{?xdKVMq5b#ijjM1_ozru>Uc zZOB*Z^l-m~LZEt&I(W=8qON65bFnb)PUK8`S*Fq_gjFaR)F;b0mNtiyfmEd zb;EI(pohU0C9Sy;a{-TirS8K*DqDM_`bLgfZ^FN!T;gmc=!WF46Z06eq|yeg81##f z-)7`EQBst@O0h%&nltl&PU41EpHVtxnXGL)m$kFmB-mhZLCM0(e8_XOvgl-Y& z;Xnl54yJ@uVES5ZUZ(q_V=Ixf6PUm*c5Zb|Ysj zIO9LZW0@uJhh(nbuS$p_m;NfHEpgF;W6-CK8W^G{DK0G`HLwoaM=b`e7Uz8lwTGb} zY-~8NK+DZ=L&*UYcQaU`r1Gr=+2}g1gxV|lVrMt+MaP+(#)(0I? zzFJZ~)|(6-`5gc{QJGJPWIUC*eHS=>qS0Ffp;vXyelZ)6tAB+oIOkX#aY&+Hx;P>= z=G|MS=~h6ZHed3Kw#5qW$|B zA;ZtQiVpsxkLo^Fj^r)f9mORq3)#6RG3&Yq*5GH46OM6d2a$-a554!ze;KioNB4+> z7(FR@t>QQxvRPE;qJA-p{JwurT_Y0w)5^!33Di4ObWi)PKXl#NQ2(F;QDH0xwbbS#j(kyPFc8Q+(u5@ zbAO-qcFE1jsA3rzJVPHtGMk}H;;n7RS6AvCH1jxt-I-BaX)mL>Lz=8zF!J?OweenL zZmW`cLUOGPr)yQ_5{(#uSG*<_bs-k*Tf0Z{{pE0?ruT@tm%DWr@*f6TD^~<0)Ywzf zE~*#P-a%Fuvz8Zg@Cq_h$O2KM1ta^M0u>&1!q0dVWrdzzv8M&Em$mIVDHCr27*Rx; zQ}wZ9Ey)R;7&-TeknN^Ra_dV1j=@9MeG zCGP5NICFdn{?EQNWKy*M)Ai)SHCFatruD0xg)$rle%>O z)*L=8$)Edz0e4-+oiOGs=b7CLp^bTqUnTfR(La&_N~gb1E~&P6zQw(7p(yX+qjG(V z4`f!v56j#>(=RE8RrnI{!MlPW`+)9vRpFCiumOad|wqQ;A!8y&#g+rWm#2%_oLX?FiyP!}KBi`&?`%^YTDyT9sF z*4oy@o#2!H3)*YS!g_;;f{N{9|T*xWa z{n?0&o@m5fxZU?ty};jxI!I%r(d%iQNY^J&{*OiH4R_v}5vCA`-Gs#k=f&b(l#iwe zXKuE0T!>AvYUywLZN5w5mhtn|K?8g7RRkIagEQZc^+}l4?lMk93EI_3=nM=)+xxJl zd#_lfi|qr?*e9>9xBv~tWXAhD7$p68*DK5%LA)NvXRPBjtd|&;b({N%=^j0>=*C%6 z?0WY%Bn9HY^LDH6%;~|uBSOAc>c;Hva(_Sh1N;Lf`A)VJd3PZ$lO-J3*Y7N2&=s_u zohc*S9yZ&Ro438FmUQPUBTXekmxv>}2f5>XLHAGf@9o;*_FyqcT-X)%U!Mc+iD7R*opz)D08nauasAb8%d&J0cCMA-TS45c9j3 zbkjY~bDcYRA082^+~kQ-i#_6Y=NP`QKnneAev*ln9b)H78;=eRM%?>oV(FZ^wa^4e zAA=J5^-@AAy4=_89Zn+BDKPUojN_JNR%h#Wi1os=|(%< zkV3ssbxxD!G<%nSKuh-^IMQnKB6KXs3+b9k%443ouhCv?^U^cv&Hzwg(!;;4=cLHr zlw~^JK$nHbEoP+5z7SzE&@e-+Sz0Eu&D-nf&#edf>_qB0!9yR9*iZ9jc6&(mQ%NeP zK`V93MDR4OnOC@wfkBBU3*}i8Y#dO zk*d}jYjY5t@@5~}qfxcME6925ZFzJ?YsS;VR$@C*4Tys+tl83SX-rO{`%x^5B*B81R^kN^JK+rhyi`j*=^+>>`MHjMCd{pvgVZE751I*TZBS_pq89iX8S-=BY}(${V1$VbE8r#hnN) zA3s}g7vV9T@d=qcOp+^<6X}Tg1Byl(BS!Wg+_mSQ3KsXKXm!L*aJM%rV36tMkak3C zNzt2WB7P*vpy2a`%SYs8qZ^JB|KHg{co!3R+aV11O5-63?{@S`#OUu;{+q6TvHlOR zKH2QlLbT+oxaM@R%`iBE7x9ZOADJ%0U&zZU2MM~I;#?VZfX^)0@mXQhT^e`{0d7UAL>$Aq28?qi8%NL?1*HAK!Y zp9l(5s1I{qgl$Gaf|Oa+5^zd*_u%|`GgMvF_^q=bj7yCF%qnk=GXPT+L%M>G>@19Z zCw&#_J|AAdT5g*=oTS5!!teGGDIePIAmS@YJ>4S5uBeu(Z}S3(J!AAN?oN5Pg&FkD ztQ0Q;l${FEK-=T0pXf1CFHH=JsHNAoTA+BE+z0MC`7szPJ@$@Z_DsG1R%8twIpHo@ z3$8VfPH5b~Q@if`!|-<&aWVaQAY(-5pq3tDz#i6W`K=)c)FZ%4*); z(WCc_U6FpFLi9>c{2jeL=p(TJVHd!H)ehTzm*;H(`<)G8)=0s&g1W_5F2i9_dpeB9)hM)jYMvPW#__ z-|Ku+sGhj`+2D(Z9GPdJveg=c@b=m}&{H50;3VfFJ?p|m&dKi#f0{;Cj+5W|#HUK_ zqxk-b1xC%5@y+M{mOa9f+71Vy5eE1$7tbwQLx)+YY9A#5(y&k|$sqndJrl(a#}p4E z3t)^bfYO7N?iHfWcuB;sD90@ep}x#XOXT3+J|VsQhEym-k;Ia`f+-i4hz4>IyD;C3 zYKoj)E}}L)vETBcA2&myL3QQVeO7fmt-HxJOhy5V+pc|q^XdTe+%(yQ?k}0qd)|s# zTB=L3HKm!3au2oPuSR12X@PNE%g*u=EPJc_sDnF;yVPx#9emqEA=~0afT7rLNHi9m z*nk70H-xQZUNr)hmoB2I4q+V!>Z(fY(a4Xo`h!3Vb50GD>sN7qFP5{Fel1-CJ+Z_8PN9EEzaj}sQCRNS!Z{#X@w z7fvNpJX3-O0`=&9Rpj8MQOb=zi0nQ|^fIp>|IdbH^C_oOj(*XQKGWmxYxIj+Pk^D0(cm7=2xnOsqI88NwwAZ!m(+B0=|j`(87 zGp&q`?L2GmbSwEuSV*587PQA3UvAoertt>bTXbAii)6`@o-Fvz+tSICh;=BCxEpdh zf5ZGpCs(%yPIuaimlVW|L%isLUj*D?y2$wPyGZMxx*SAa%$GEAh}#Sgj24W0^?ZIB zVfr=fYH|l`3v;D&g+sgxpB614c$TNV(&H0;Y`bwbS#{fI^iNq^8c1V-jTsA=#LHS> zMhYQ2X1N?;NgiM*f^#xI)BWRGkN)}bEwaQ-yCq4&-#!ub1_Dt>YH;!4?UY`3I>3)q(t-xQKdjUe29mDzSp0H-WR-YMZku!% zb9A`~n8dgfG`(%}bA$I>R?MIJalrv54p0_;JUClXB|ekM5)83LeM{lWE`7CKjvRp- zDY7&D$nKe=R!Kv;ACz73Tfd!EnP!OqtNMv$&;T8pT$aoq${kl1?&TCWq(~Y_!VN@Z z+SMA();WRG@s>WvDw+UG3(uq0`%m{(HD;9nOLY2HPb?qLSBo?G_Kfz+*H?_O>D)m- zVT&x^tOJe_ca}U#we(kx@hx-Y*r|KJ(Y^~Z zZRfs9$Y;4+86>kTIu9jV?FqiPi}m3EW#g6kmgvJ9n`zfE4n_G-`x&0q4GVrruzw)K zl_8>$O}TKM@>l0iFgdEuH~i&Fl=g?ME6!}9r~tuABXM)+99jMteP>_2Z{12WYg{Y# z8N5~Hl&*C%Fn&E()0tN{EO(^!_S}g=6M}o z$I;~dpVMJf3V&)6bg{=|E>|0zuO`BRPyh^BEP%NbbLdobn-7gIGk&C^3Papb8O^&S z&}K!dPkZSYm}+ivVSPfjRvi|j$gb#DH~}})%dIt^e(CCxbUjUBzgcN4HM8O1oe$y;9Z%;c5|(d|v=bZ1??Y?>$PZkUH+M=ATC}<#JyG+WS^u zM?@Y87O!Uk^jEdjXkggWHl*fL@@_@f`v+$@T9KIsGCyI_vDdlc^Voxj9lG-)W5Db4 z@_Fq314K8Csh;LJP_48bV1;Ui}QP<7uwYIOkd1FnLZ_ zNc(ouYx@)SW8MCm{*)91L5kh`+M5WWIu1^tYO+C-U7Y~8Z z>Zo5i7i{1CY(3F(eJ`=}3~wsbF*VB6Ka~Vc(!v5cx}V(4cd~fnyJz)czN)N9EJ#lv zqaFDt=@q(7<${E30og{$8f}?m!mmkUk67gD0EZu{hDt0z1X5IBk6HiGslvIweXrB7 ztyw4(sFKvfN#X-|?|t5G1Il;Dye5`e*0Y6A_C=)z zQDapLr0AeZ=w|+E?5~Jzwpd#MOFtAeeWMty{F&Ra>+$s9`6A9fEp3->@Ntqen_@i6o}Ztxh)r6Z<%-vV=}{gI@wCW zMah>-W<=7!T)HI9Nyh*^X3r{OM&JY~FP+k(%vLP=zbE2=cX%n&^kUrkuqG=Ie)m~l z#VX4+_#u*mHrLOB%p~|~+K3~bdW`1k)dqr6x zkae)2?ajVn6vZ9r5JfwMJ#ZjoNvo zB!hdzoB37I5_wZlvgCQEU0kTjNxI2{3=~TQu>aVsM1Qbz2oFJ4sKjZhVnqqioV$?P z0o|tOmq{;uKW2R3~=0MJGK{1J1|DpqXck-H+%nB>HIu(@pS@G^et!@b2oNcd@X#cT$-;1&7 z?V2JuaLN7T!(Bb@QXdCC)4$qnH&y@fjM(HD_pXLn;DJXYi3)U#)Y)5A`f0P!?Wvgg z={_)GIFm27NM4JA?6kFDs>}Em&J8A0WG}S1(GPZDrHFEFjDm468)flzG*t~KsjQJ*Tcla;cEa4>Ry(6pUa&KY# zA2jQnS8hJ(&HRjct?zkzvhNuo-@F(VdzZCi7h~-y*&b*#>Z??-a(Lbjq(ZYd&e@0f z>Ps*)2;l(=2ugZ=&j?5)fq0^bUlu1B%hqeKQ9laZnw)%HwuNug49b{8h@EMrMuL|L z(VsUbF*$*m4(s?FG6`=-vyR`AsW$IJ!j+Ea7?5@P!DJ1-@Z)~2(!!i+Yk+?yF5)weLH=^tB8l_&sV43WXT4z2Tr!x>_+ zckc|ObI^175pOqcMsdDA%}1>jrdPAlU>#JQN?EJI_*ukAzko^jI`_=<{&e7`zUSGR zuT;46_CY1>dtUn$z!(|1;?H))*1k6?>p^7A?tO#+V;Dug4KeNi48%`p_ha^vcW@O( z)!L4}4i!$#p{Fpslz5XQXS`C|YzTtoN!YDOK&d7L>?+BZk~pYOrd&vckeXjaE!7tB zV%5!9#*X?JReMh&R-Ieq1R;Z8;3iH~?v83D8H3)bKG~wjV;+Xo8;-`po%8--lMFE& z`N-KY=QF+lvF#5x#nWrMBwdzvG207c{3}kfNpk1?-tDI7%6#i0qFJalG~hh{E&Yx5 z2$HEQN^_?N%9tLW-%|VG0@m2@Q1ufSxqFViIH;hl=jn*Vu%7s(b0eK4=!{xyCrK>7 z#yA>CbH?2sf8|ZQk{h$|5;xkz#ljCgRMppGyA-H(+h=0G=|+9B6bgHx>&WHzZhXN0 z*P|oR6ajv48>(_a$enwCwVqlItALuPIky=7PQ>tLO8ScmDS8eb{6~||bQQWd6Wf2- z1$ki3vfSB^uqf22h}NyQ?8-A%McE@3HNF2h9~_XyqJTuurfjK@q9?-aJ#Yehc;wkF zx3;(`KWKjd_(~c5@EMZ<0q7A@Tvw5zZj1%OP!e|`vJ}_qBm%BTL^BJmbZD#LE7;oxG?evfl2Y&~sVUXpio8k~Qch z{xQqINP#?q1!z~cQyL!<`|G@MH$%gojk_Gr%sk#+yMRM&dG{!>?_{0Bw#vGUD^o(C z@mcx}k4w@B6KXWhM)lt2FCiM5QOhn4zy;pxAF}OM0KyIHaek%WGq? zhV5Bt^(+&ajbvgrRDVL>RAD~BgQJ6Z@{sycqPgzK*~nTWmY@5@G%5X2*sYZ)MSPX9uwkhg*_AO3CZZ4N%gN zPL9<6qTtbISj5?9f+`NN9ph)Mu?qw-UrEH-QT^pf61rQN>jr<|9E}0oDMpW|P*5H| z)_bRDDmL~QWvEzv0CvH{pMu$@g~+&t*!^0G<@Q9cwTCP{@VQX%M?0&`r%WsRx&FPp zWwyrIn+qyF9?Ht%2HMBdBRlNGE0pZ~F~f4QF-J4xN0#cfKIsdhdr{9AQQgHC5OcCr z&p2`nvp5PC}6lZ1Xa0h>4-m%XFhjpt|J+D zP~~N?$>W=@)dhv}ODWW>=&?7vFLLG`ncnNVg>tO`&7ZYUEAPghcaQt|z-PQaU;N&a z^*Ytwi@Kk%_?vaLNh1yYN4kCNN;%v-4py{x7j>dZrv6?1&^O=w1rcYJT3H>%I+l*V z;&!!jbPYSg_>1`B7@pu^61ueIedR{6<2b7oje#MfId1Bnzxu4Nv%Ri-d#1ei#3^Vf z@wGxe7z$h*YcS}3YO0gzG3;Q&u%J<>zAbvDRH zmi_dWx&Ld0N>mF`l<5rpHA1%D{-n+);<&)m$O*sP60~PFAup2B#q^(0EK@I4JvkBI z_(e@4m!xd0`Z+5=x$z2)>B-cJrHoJZ!1SLR8jcC78r?gkA`B>BY^6aG;qRC75A;@~ z;6ZX^!GHB;t4WiUYMBd^RW=~il!@u}x1C>Y0+S=>TFGNLo*fc6)cj8WrMTP)484|y z58C+V(T31X?b3w^)~pF{PYB`t`KrINn~WQy^`FGYDc<-qG6UE_XT;HCZOmsNW1f?=K3P`987 z*Gos&|Eu`Z5X_)k<3K+-#kY}RW_i{BjO1#)p45obzgak#Y-5{iq5|zQ=jccwWw%U` zH{QvD!`J)nu;Do(*Ql@unRpK+ih%2K-#@zQ^y< z&_Fg|>xrAZS${-hIpwIT2PtTS$9g#xO=%FbA=*`ZggqqoIIHZ3FF&-XdFHW0dcQ{{ z3=u{k>$e3oj|esPo#Vhb*->rI^~Pz(lowhiz5dH*35n25bMC*a+u5_dRKU8y_fr&5$d{11asE2xH}V~RGnKqT+o#kz^z1w7XFJZ7A( z_RP8e;rW=2hvO>|$5-nf49>MEr~!{hS!Hc*jEUS9ENG0~Luc(=3DAOT%bti z!C*~=8U3N2)sv}-UQrRiwz{3AimCs0-uo#7?m2@yo$eyPkX~pYRxbKHSyeaqhW5)( zGMss;ej^K36K4Awp2LFe_YJI_22%}n$fAnVkIZe>Pv|7YH3RBFEIz`gl#SPLIfZ99 zHjrE?pzm3~f&#|$nkt~> zPK}SJq+$-wY1oDpQyKOmqtQmrHWI}3sAlZxDbH`$g5gafEz&X;Q$+uB6F;O}9hZ9KyOCt>Xtr zdLuIk1aKg6pZIz5$DXMFM{npSE%TUwtY6lBNhvi8a8bnaW?>pPhw%CyguZt`Ot+1DwJ0%{e1BMzm+2VM4qwup+ z)`b(n(ZSc#9;f!-d!{|Nx86)B0G+f8vQ!&5pJOo6+6_;+AHn+MZrOI8OwQ!+H<|dB z`1^Cj_A~|~a!nwV*j^1M=IS0fRQ*hcfj7?zu3fnTaG?|}%@WUa4vpDbE04i&*FqIs z_FI20Knz2V7%077g+0mdoZBAvf&e|8^XW|d*TpQ)2*oVc1~OVb-jwUr!%d9U`MK+m zG*sNtK2UJ*p$~YuSocD1O!cN<$#N6_TPG<8wvmxC9lsP+K z|8pwPU89>AdUO0edss9JFgi6w601pnJ@Yb=8#Z?<-Q#|bc>0N~o{&lyF6`y}m6p8PMAJHpV zx&3X?d{atAv!fwHrPji$m;z^e0rw}PazDl(`RRogm8MwTS$7>fe*Y%qR-p@-N(5Uw+I+ZCv!Vefe6@gYAKziEge& z*4_bk@tzI&^mjqqpdXj*<3sS-`u%hlxeC6w1HVTRes)}$y~v+ zeW;_jbzh}joV^NiC3*%y1*^?SQq&|;N=!Q&zf1C@fM}ypnqMcxY!&xujCjKV@^$>{ z5R{3Ms&RC8yf6;XygAT|WP@0>wdp(Ts>|TyCT4`Ar8z>m;;E-AFRIaedKtu`S6;FV z7394!6mOtIWhugglsyXae1}=wKHq&JW!b+e09yPB#vabGqY6;{@B%sTcHB&ZU#M&! z^us=Gs^QoW|4+5R+e!xMpnRgA$1~8u0W2JghNE#jz5ZMhM7VoYoR;k~JOr5Zx(0`e zPr?Ph6Wi8LJJQ86Y^$biL`@jvAp}b0GJT@=w&Mq$?KUvOfw0&hP6lp&zClk9(#LYY z*K0X-o!<;;Z>mw;j!7u?ls%F>qP*R2|D4Oh_3g8u-)O*fUgFZ4DAhC^dWxyH9i2Rj z{+}<}ACN(F?j9Rcga|pSkiIKZpt6@#3K~Fm5s!;1ID4N(gkDU2A^orp2_8nFca4r z_*8xlQ$~rz=1QRBqSWi)hG6!r1{piFS{xing8Zp)5>@@mCh=_!a^vB=YXW515u)43 zfwc>*zS&n4NfB#)?N2$%;@W=eGny{=q}{>#wMK6}C>-5OpaxAKG?pT(L$@eMGDJBS z;qHjz>$oKmOr_)9VL6T}nGH@=g<^-oOivx+l3pkMF5tdYpd5{Yk0;l)Sz0nD8(daQ4($BMawsBpE*`qGEeZk2$={vO55^61rI92=_p=^G6a1?z$yi zQnAF{gP3B0z8#lf11E5f_L#tYf+O>6p*4jFIw?2Ch3}Pnw$sLDe>RzZ+y_4AmJQGxmoFOr-%TbWTw0afYXc1U+%1RH zmrn5j3Yn6wD8nCnNO#ziowGNT zx}-48>S+VJl2+)~BnNJ~Sr1?I2Ir?$4{Z)G<+jfGw=V!?EEXbSCXbUGV)TI}M?XdU z0PxP9-y zX^B3O`GD>cZX?UNPJ;c6;GGTQCc9DD+_`iKSga!gBf7{e@NV&p?F57h=i!kkXKVO> zoe+6MW9AI_w;5tLH9HNp^zr+3|9QCV^LMPd31fZxQt2h3G*2D%`NWJE;{{?a{U2&+ zNcAW*>&UF-EpO7i@X?IE8NuqRcW_raY%*kUsQ2?t@L!l?-%d)ul5HUC(=i^=WaV;p z8wWi%8%Ld)r>Kq7g&Ct)B0R&};WpIY7mG)KPYbLe>?a| z@h^r05ht+beEHy|`*CAXHgsifCs27911;Dxdy7v*5sZZpcTh_SH>ee>#kGAyiPN1B z;`s;zk;ZcI#J9b|!04%JlD<8VcCdvDta0=m9sGtL&Jz<*zflw6uA1BQ?X{1Zk~%M( z-4(5}R`?Ah0ez`+KkknRLynofP{U4QkV5KBnHRsOh6k4qVp=k!e;YW!ocRrGenR{6 z#-4X^!-(9~ZJWw%?s4h@s?a5};LvEyyb^LzCl*QE*0tz^UO^$MjIzSU-2Jo4LWJ%2 zdKLAjnJ+%rM>n-JtkxR789kwcWh2NS1WYq;>RN15LXN}>44vQXEf(+0Uz|uOaxs`x zlIV>mXuYKt>xOd{t2RwhHz2PQU{5~DWZ#pA+y}-r?=Hh}e!jHisJygfc0@M1s1g~6 zZXkbT1hg3&6N0+>tct}XbS5p=MaN$0Zufx|F|AU8jaCQEEq}+%7aXOEjs-AruLPdY z(hr=9FFg2Ug9}tpVhXgu%E6OllC%9Tu2}K_#zazQUF5 zB{PHWWkwpIA_}D7L@rJSS(Y9A*+l|^$U4pP^~gKw&VvW5aO~$SJ}0B!m)_QLsDo@) zSD}S>JR&hwUByt=Z~FM}RRed*&n4otbBh0*8|(as=b{Ht(k5MBa|`Wmain@cVfXqq z+o?EqrlYweM>uHwRafrw?BaY;Zh>}9(Ah@tUH(yO6Um)C83FG5XX0eGs2z6}`PUDI z1H~71K0=$f0U7cLAOsNY3?drn<98=@Gz(gAi{F8-VXOz;`ay(KVZQ`er zPbEWNF%LVybuX@kIU@90i;J);^aVTbEK10^H8F(Ph3DbNB%wLy9esbwlmc_-fy46sp1@byfJB z-)mOpo9J}Vz11CkXT-#2+g-J!s6sr=-w2oSK7nE-(&g{y{0Ips1A}|pMg6_>akZ2V z|MVy&Wj7>=-hIg?EUt5ey%E@!A2GHbo={ybK&&;{S;p}}SnCPX^Yxcg|p26fWjWhV7ZB1G?gx5SE0itTP&*JPe1ORiKoG2G7pOJuEw7-!S6oM9A1|_WSoNOr_wj|se>&~%3Q{B zId!bC6C0?ln%xA&yhfl^&%h$vnQ3_gSvS@ldPU;{2s)mk?;86D{t#w*`{q?LZMMun zPk2%l;G97#LDFdQbJXZ)x76l|9dSQO{_2-|vwZood z3|xQ$bzofh(`{s%iD#0GhSiY4$!d#E<~-|ELdXofdYsHJ{+O%xp_aPGV*1epd8}Q8 zGz!t0rt@-+AHOcIqPWgJ;-vSZH1;x)p=-&NtSK{MH|4R@wKw{9V0ZD%888onw`01xfw&Umo!PIm5rxHo& zmh@06r!q31GF`0**VEZ=C#*~PD`QMaEj~FMy9#sHrKVgI+y>YQQkJeOMkJBZ(^O{$ zsTeH=a`U-805nJ9%xuVZj_TOqVBhs9ANr{Z!*-rngsNPUP6wuu{E+RTQjl z4gCxL@HiP39@dKR3I3NXelfa~moM#M=ByEiUO6T2KKsvH-IuQmZncuv@BCilrr6Yi};Jz+gQ9k8y6I^kav;>woEt zKEks(_(hPx=}hVU8q~DP7ngu7s61$r@6rpMF;NhhX@Emk_@IM5*KE}QGkE*+=2pSA zy&^IBOe0NvH-X>mTe|6Ps}_+_CQ(d)V(sD#ATgqqb4e{h%1GBXs-lMf|AMxBWW_-M z^n=3mas_Jg#o=<=h<#vlC4Fs!svbSL@Y25%yl~#U6SdSD7p=0(!ke#k)(5aT|x8QN3mxzD<^D`t~FW3UKcJh6xP-OEZ;aIn<${w(;c=ta^GpN;cgWbHXj?%m5zyXz@W zMY3Qv;wamgoLh@*hn(Esa1s8J&mE&vqJ3?NsTe2NVrT%n7Sa;SGH?164gQe}#~n$m z3ju$xSoG5&jh7USFC8YGe2{o%%UgISJnE5#naxo88R(hpDHQ>HVKI%>A;pc$=xD6P z1qH!N96n$9iiP31+kKEgr%%E7t()(D2*1(CH0<3_e(R;2!7O(7j&TO(u`EuAATd$7 z3)h@*8z-?q4MAgX=%4qQCjVc2MY49>{-$us7VD^MH~XoQSegOelgzCcRk!~ONfeiks)E^V=GfC5#nUxGkCDT zw0>y(e7mxv5QO}>a^mMl$C2ntJ)wNHLAMgiWhpywby6P=M*%c}0%`6K$AT^GVR)yc z3^w(Hn&4y-PGxBmk!fzkID-&ab?x^__Il-q7D}qU!|>Llt9iS=g0oIV+7-N&B^?h~ z@!fvu#FDuOf~H2yQQ@d~?_Y*Px-&Ln^2-DXGp6jSDF;CXs@ z{vhcZbJ~WX)zI+tH~#scYwBzpi{aD!`Q)Dz*gvZ1f}JLglT*LAGorq_YmrEXEk>4L z-%3v0l%Sq@AwbU>`UZyuzxFwe=w>V-?jxEL1n5Pcv~LZ72(mV{k`tq3h}F5LiwM0Z z`q%_6?skQw1c=fLUaghgihbRa&CfjLcs)yMxISxD&B7#w7rr8+35^*dzZM+Hn~hF* zus%JAwBWto_9L(mJKRivCYD0~U2UO!#K9RrL}^1^<6y}@#S?Q04Gf?x>_?lDLzHhC zlsM2Hh0=F$S;k6VY&f0vGmLnY=|o1poX9K-or@%n)13evS}(OEKVHuHygjrqh{34r zq{mW7W6i*-an$hHx}v$V@zMP|N7=JZ!;+}~fdq+RVt2v#u6cgk_uTs^i}v0&pRhAk zL{}9$*BJ!6DO$T3#D`xs($~=<(^?Nt(E|g-Z1|(d&b`ET&PIt$>~x+fGg#FJQoO4j zS)<&}<141Hwt))OXgt3(|DY}#i`#}P)Ufb0n*HM{`7Ep&l-BLMKU422Rf}6dE!6OJ z;Hm=wXSCUc1JX?xfL{6b?IGq)kvApVXV|ob@ktClP`Nl38SEy$9IJ4jc#0dVep9nS z;bctEvGQnWC;OURMCk2cr$ zrze9*5J_315!O&LCPwgKHvDDP^*SBa%5~}$`p-l1KBun*YE%e2JI{bj!sPLOV8%MV zh!65=v~6qk!N+3Gu+c)I)vqz>HoI9Zg9v|MGL49AffdyRjoudZZ~A}$NC<3w1hg4N z?cDs-g%*98@S98{U#US%Dk-t>^hH}cfCcm}eSl2FeK$3^a}x4a7B9#-h8$x9I*jg^ zCRDxG%Cr@UBy_uY9!dDngAai%$A#QT&lhIOa@SE?o&kin81Hqc!b6;?&h$jbR9SWA zvgl-~W9n!c{BD9yOBjaO0@U9+2?fH`-zS3Ku9JQc;H#@Rq%Q{6GN9*;x#zJse@)`y z$4osc>4p}#l{gK*mqEt9TQu_Va{7Nq{l`# zissy>*D|HB8`ttxF&pVf=Nu2H*4e0s zwHkQR)VXM$4a+`~7Uj2eos)i^RTO?w03k2HZBuk)26X`{c*9i$g>XEvzL{dp6d|5IsZ*=Rs-Ktcb4QQ zFZ)<07a7Vo5mx4*!WQX&1%|_DMt~HOvXYv;l|Y^DSp^puFYV2pC8=A2P9LWRL2Iuo zCw44>Y>`g2u-jcLE#nlF%Xx>j=Gys>lqv|5n|gA2pRdf7Ogz&~gP}N5vQ{n2@HIQS zBUb2{U~b!CV0z(*DOs8G4ZvtWtA1uz-yS65e_W-f{lDc;(7F46p<}lgC2`)D;{6}! z^w<0F5VB}z+><_)WMe;Y?78a?@yV{nE7dO92nFb5k-}Bw6r>_lOJ5U-(@#x4aZTim zRqh{c?}!7)UF0%~v-{SLhFhw)sqEo+FoNRd?TN|~T2$SSwlxe$2QQ1ARvG0;8h>_Ajn6nrBj z`|{2)O5^2n(n3RDS_qd|_D$yBWR{fTE3WP{1W4ARP6_8l%8-qW5EH@0IjUgu{nrP&h`@pkW&ujqRM|#7<+Q zX>8j`V>Y&J+qTWdw)LI1zxVy#yY5=||AU!5d-m+-*|VR?tGCdSRQ#r4$D}q)J`DHK z76Ij(=w_7LWS!xzd+&sMKlA#QR(p?AkzaG+uVxd!dgEX9hmamwvi}0I)W#P`eLbnL za7HHO{?WxGNcQ5x0O?Rmkr1iBtUv}of1#0;VeW?@{9-pzk*gYkgJ(j&Sq8aN1ZDB& zEo^a+#n)zFcYiw)WCwNPyODu+z&1<-lLV`d!@w+wx;!saZALLOsU3;Dj2iK5rquMx_&Q`zdGw+3I!MYI>LCE0@+Rn*^4sEmoBKi;dns=N0ZhWH=HK(# zcxQyL_O}1<0>@SaS>-iE&6F##B7PXb^d7La78E=5gXv#HcvZxNx1i!KeYz0`Gd{;= zRed9ynQdg(pv5qn)3%bsh6jD_-+`YZ?>%^<5bE;o1y4#vIW_qw((n>Uw{7K4;CRQj2KJ^?nQ`*TdL9j#YUS{79$?~#N(;^ApiQn z@!0OF;`kz3?6g^zWnHFDN1)Q8upkS7QE*!mtn+YGD7&XG-Zc!a6yAIRhn6{$zmA4r zJzdJqKRupHY>`LA!5Yfolm(4=S2iQ}Nlm|Ree=nLs#q2$?`a7BN;)jTN;Ay8h@=-Y zykSW2hRq~O|D5tnQuP159I5r>BOUfXUrP2lJyc8kTa|8t+owdIB>p#JN($c){bw)` zzY@bb_+iiU=rp4gZJ(Gfk@s6mfB&IgcP2iZGLA)GY_tsuLCpz*0}wYfq3&L0j#NN6IR1{K!U?u;1L$%vD|mD5vh z5N{K4WWVHg-ob&5wYctgbFvfGaEIHaH55#Sc{f?!mvXYdnyfnG&_+?5q$uMpJg$V#wY z(m9l4O68z?YMWH4v*PDwTH%>{XEbN(b*Xt;pXXnuBQWnunKey#pg4Ch2*u;z? zXQYwB#m)8qea`*gFjQ~FOE8*cW%$)@c$I13Z=R&ly%zX43=y(i8L;nEvb;alX#A$> zlbygr4|KM>aC%y$QfHwP=!5rD+J4m60njS>#GoCSn|#WvsS~z{bDC}SN_dgn8)P>X z;~cpLo3CRX)y;qzUW^D zF8ypPK5b|2s21;T^f!;e_Akz}4gFp)HnSIU-a>KGUT-+6bdu`+_~?~8=p~PqdKtcA zlm0Fm#z7KI_5{kt`UKWjKlfk+tn&0I{k$e8E1|RrD%R(D78H%N_Ri$TtJ4|Q2#T*$ zT2t8^%xs`)6MF3L8|*M_PmZvFgm{`;3bUyAI_tU}n=EUtvOO<#$5TykWhU^8 z_T!(7xWJ2s*y|7SqI3j1Fl|~+S1j7LNaNz>zFiB!*o!Enu^UQdY8Ykw3%M3I*SwDw zjcl`DMhputf6dX{+odMyAFtdEJPStuBT8zw?vP=#IKNy&`Ok|G`sbsk1}`2^2D;jr zeRLCP3f{Z9O#3>jlM^SoZ+thPAIEFz|Fx9?8!N9rykz$7ALL3aQdJ=U4jCa^e~2n3F|2r%igIkJ*RKl)#0tbH6H{WsPX(hjJYxOD{i^98GZ-WB;0f7IdZ zD5&Y9s$Q)kyuudJ(!4*OTo|YST?w0Au8xuSS>#n_Hulxadw&7r4zW5Hx%G*h{Ki)K z5alsJMrFGlw{Del!1ge*BUanxo6j%q*~A*)TNB!P(#y7FIOewbYPF1zn??nv+|?EH zC-Acj5cTu_2d*1&EJ^?)o_}1AoYzZ_`F~DR&$CokWQS!UvG-w2EwyMHnIM3tf~!R) zhgq4rL^nJrHk-=j8RMe$x|&8*zd1d8V)Wx%>Q=xcMYAKNWUzaLA)Uh3?(y10NKFDI zBZ(xtS%%Ysns%*xFtf_h%gCmYN6}H#%*5!}Fum&f+C<;;xIh1aBA-sHj3lPhz2Bwh zHJ9i2Dyvx1e0F-v;O7SVIQ;1@-lpYR&tS|^S%v-wQaq{8xhmRnDY3boRgg#K=CkZn zc&s`>y>b1tW=q=(Bc4M7SG1|c{n^srsajvlxOFz9NhP7j!Er{E03=FQ8&!&++o@`Z zW(mcQla^KV(PTQ*1d4hkQ2&UMY!#iY1E!5I#s~EFIUUrizlS!6>K$J&sx70GRWqA` zwlC|@{&Lm&ec(48p+;#p^K0C&d3z6h|S_KkJmxIj(w}U}S@3jrLj7N{nr0jC0HmtQ|q_Lz504uEt|3lVnN`n>! zk^$^WYIII=`*Bc^M~+oTW62-ts6P*l%cfE8QA62eDLW~dnM@d3yys@JMp0I%rif7E zG^1m>f$E25Lu^8^$uT)B>Oeq-((v?sE4=Ayy$r32%#Dk(iP^0Zw9F~$5U_m+zU^$O zG=on@$WYwVq{V3k`~c=wzErjA4|xoo(dBfJwT^aGjTsXah>Q&vjRN0G&)PK;$G&M3DA@6sENFDFCgToXL?$9!uwRCsPo<|Ur}6fcovSDMzucY zv3^i`xLwER;$Pl>cT!VgM_5f{m$iJRDWpR4*B#jQ8z?`1#Z?G;|j;1yD58(e(wum zCqmxnfprU*mX<_+xCvTAowNFTY=by$CcFzNO0d1N8^kPj-&;CvJ8jkpx}v8l(TTIm zH2lqxV>KlD68J#5b@*8oPS-0LINIwo>M=VoTbS1aUhZ^8H=^1JE@HW}_9=QD<-(_~ zf@XC`#Xl9Ek$wa99sb{|!8x@QHbLob@kbu|`m?Q8fs1zc9tcM9^=oA)w~ts8n9q0E z>}8m0len4q;C&$&?w>Mx*~JGD02GcxEUfsna>v&=Fo=MRzYgRGYPjPgLcg%rJCnM@ z-G3ZTA2hIQig(UVO{<6uYD@SPO6O_uEIVNsayR3&1D$YfWIm6O9v~{w!#~wxvFR>t z3}}m!rh7UX?3nzddFex%%qw9>y* zuLHOdQ!Df3`cAMDtd6PW>uE^2&xq9QC*fqAh*8c&Qh_QuppBj!Xyj0rt%wz({Gl^N z%U<#RtIY9yPfD9GY!6}WnTR#Lk;J?sue)-7cIi!dFDce)v0^GGd!otN?CXV1lBE`3 zY3XRd@+yXdMbR>O`atO#+KZQpA{&v}drM3%*uZ**f%$xykxENH&~Y(h93Z(Bk})cufuvVF%aN(RaATUGZsE%TsH}g2PfMPf=|8Aj0pB|FFDlC7 zhxc#hUva{Fy)m8aUrsL(7?~iL^;zRwZnUiW!|r!q26LeT#4_CO!}G(HM;;YG1uj9} zhT79JTGY$#NdII$_3&rAxu@#xPKbZ$?x&pP<6;*V6`u-^{qpN^#JTymNe@^;zfGxU z&w}vegnO@SE@GXtPqGdf8=qACwCMrm`mlg+XL}gx)z;0JQWDAm6glhccC9}^zE<9a zh_AB8%LB0Vc1M_*b#4~3s6+n9#Ap9^GfRV!8hOsYjXF4E9IE)O)34`=UQ--OYaZ}@ z%jQ=u^~Vg2s@JAQ*7iDXnktFFNoAu^-1J<6bvXfnjX&VYHsOeK;+mo`r-RyhxA7$u zfTU)+Q!G?|ePVtw5`sZ}PV*9R(b9b`bzuAITVx8_n9DRt|MF?pB;dI*a_VXx2tM$R zoDB)iDK-k9>a99i^3dPspNwpe5*Aucsuer({6p zT#9Rt@gH@uX=u+YrK(j|+b4LtPGK8$X9iEX0=Q_RP&qstSuT23hIha3tbo`4sN$OUq@~@>;M9~xV zZWz&MWUk+`_z$6>`A(&c&>PigSrw_Re}^r*zcQ#l@A9%HXetB}SG6ih7BN@o-0@bN zvg2v2dO?a5ogRIu<8duH*Dk%_J_rwMf8&d%`;g~qSZPgYx9M??iSusjCBF`AG z5|3X)oNfPl{H{jeF+4<3JH#`U-(lD^OW~8fjML$Hgn?$i<|)gRJjV|ZobAs@4ysmUiNgMN z@+oSqG_|}Gk5lZvHuhX9rsgy`dqG`?0(OyspRb)nJ?Cwh2&4&{Ht0&f7UjT3&HOI> z!U&u!T-!#if`^lCTobFe9x;)mh-a-yG%~DAte=UvnPmAIytMDXXWdo%aPN(vE{PLH zNl(obB(&v1@*Qka&%ObpYzT>>`kSD)TE9WyI(txUNmJDNC_gjXhqbA}tA>`S!$TVP z`fu2C0sb?g_D&S^HJ@B3jBl^P)Sl?BD0p87)lj}8IcKrjl-MXRPjB1J&G`35f6X;n zi}RVlzPjR`!TI3O9G&@w;@|DPW$DMqFm{F57-jt!joA3;g>GN`Wdxjkj?v5CscvOfPFCQalk!8kn@x zsmZB{$N)AvnmF3Mzv%*5gDZcEi!J?m90sYw^D{U-3zF~BXRre?1>S5ezMU|5QYclI zt`#Ce`=%B0_5#RPvAXLkx9+%+9$b4QTk%K&6ZXKVJMANOhHWhU7Zog63xJ;;O>J8J}O+NtpAnJl$4hu%>w}-&zsX!ts%bx&|2M z79tg~AV+(>Do9RgB4kaCF*7gGUHk@}PiWS*MB9ODhX|!>Ls^^*jL{|q_S{kktR%IL zb>xN^9ZtoV{Y)zs_<96zIMV;k$SXWuz*>nGuWUZ}wKb zXE2pAMx0T{Ja)aC+^Ax3^DAF0^C~I)uM(aLnX)^2v!F*OsD(yXlp zT5G2xF;87k{6z4nVRTHk* z(I=n+#2LqAw1YH-@o$YeItqLFhF@Kx3S?fQ=^bV$O+gvM-e;DEC}vVKc49ZuB!IIx za5;0m5e7^rn@(^wnIai>TLa}j5u~i}0Q)at(q7CpJ zrOt?0om4_OR_WR`<`AYS(A8Sw5x%TAqS=1J6>sx8$J%cxO{g493}dd?BU=khQS4HD z?|s!ZXZ=hWb1%7(g66HImX{x24Z$B76B2vW(bY$4^S9}FWnViXpOJ(tk)taLn_(3H zc9Y36`>zX99&}M{z{4a({8Ej*)}3ri$V8wg;0bOY(dl-|2McY92zK8{Gdx_0Oa*RU zVI7znL^;v)met|}i%Z9HaJ61A#JRciEPhZ(+p+#05o>%yq zrDMmVl7EGlpYu)ra=OYNiHn1o4_Oc*)ca~j1!YF&j-GV;vKMz`E>B$(p~^!$!nLTi z%^mTC_8Xn7dLF!ogeWxgtWP9^j=D6jn?t8ooc=3h9cd0+9~h)2TO&>%hA+PA-lvhg zcO!x4#x-n5T68+LVx$ONSy@%lj-P7i@g{-~DZpppt`7nfk@#(i4!^|eiNavWBk|OH z{Lyqfo6V!pex;vvvxUlE0v4YBHJ-B_kG=&lanPQ(r_3XcaJC<;E*67CfV2g!+$Hmu zo7{&7y_Ja^rI}LjYN`6To}|TYOwR;TcXwsjte_h^uT^Mt10)k8ifMg2jM4-cJ3;Fk zVtPo1iIp&e5*FQIDzIjdlM+Dts-#es;QP-v8m>SbJxl40ZM3fX^%1nI5dLd@DL@03 z?0f=y+^XZY_2g{JzN!{V*Ut`fgTrr-bQp7YljFJf1P6=E;0`?Og#>SQ$La7r>ziY> z2#`euL!dS%Lww|NUJA5U`1yS*eZdR&mj>W*{l2%H{WE{t>xa@N0{*L7ps_OSXu9DU zGZ&ns27mnkRlOH=2rF$SGb`pk!y+Moq_g%*(h%p4zFxKc2d_~Hl&|&KD{ZO~<9eK% zF_6E6IwOz`&5_hP7W2zh@v~ofz($lPt-$Ah03d}R)Jhhuco}?B@dG*cw%ZeCl%_4w zZh|e|ZMQb12$z^YQe%rOi*J+&8Y?HP;T<&GEV9^6Rl;>j$R0x7e-9g_B!E1F01tgW zkXM%y^{i&G!ku@fN3dN!6e(}DB+B2Q%RU?`23^51({c+P>w8_$=?%Af4-XhbN5Yg6$6dg zyz12%sydP_WAxJW{2qUhxF7t4(8d+m~+qq%ZhgBjFs?u!|>QcCVO5u)C-$f~d zDIarli2bizjDO`!)S=vK(H7&wVTl4ZW2bBB0O3A289t3>v2hy%0}8cyh$D(o7$S>)JA*Q5vYld?jQ77F?F_9)DeNymIDZ(C+WD*s|TdlWg{Fzan?Mv`q)lwLn&hHf3^BN zsFCM_rHk>TE3!9lkA{vnr4$J?({cM{F^0V4w0V?=KMMG)@Za`%YOZH?wo&g-diYF|*yxIVPOu{&h^{p; z!~NmV=}fynoLcAiU>6(i_M{pH5ZWFe0-7w`AI5rroSkD@z`$|y>qZQ6#>RwiQbcNZ zICoFLoi-RWN1GQpB_ugQNU`m~8|ffL?64#9jsDIs^lDPZ-nSQRSLKHeLzfmEzTfnI zlX$c=jYa1YV1g=d4dflOC#qnVWfMNLxCDZy!Kl7+pLi>NZ1*J!_n-TnIg6-@9V;E+ zRo3pR(O~l5jL^Zt3F#vX-folpjTxXO=^xTJ_$K<^6P9>xVp7(nCi#%nG$GLaG?Z?A;{!h?=<}3WPLORR<84nyxX%lKK|t&{iqib#ZOQ+%FxR2p zXD%hu7k&_t&lVa&^Nn3x%1X%y&|g4uzg%KmZF7K6YJ765l=z@m}O ztD^MpKn(Z!bD|14evM!aMkT9){{fLRnB^F-{^_Qi5xBuHY zE&Z%1FVu>PN9B3@qp3Gb0o3z>8jU%vKBgSby2|42&Vj#H3 z1vrBdo;7lqE&Va>%6}eGqoL!yUBN9NAaQ^Gk$U6*{P-MT72&BT1`m!OzQZWh^7o~` zoVabYi7KO?&rqb1?OVU)XwsGMnXgVg8w8MIaqS=-9y;qd!^}n7^fT}fPvAVHb8r75 z-U4Tk-Tq`4niOl^hB)Y7{~4&EI=QxnmhF0+cD8uO2qudgyRR+*z5qFYd`qtr`pFV% zbu#&ZHGys^+W^r8!2~hW=odaa>TjfLTYlD6i%Ho0JTsy?M|K*7K~d2>N75G9jE}U8 z!z*$z>_Mqw2m)60hmkD=oCs3H0;ISypY;VuH6E^M(+2ycb_x#G?bh?t#s;Mhzq<>q zr#a>Z<_FBNZ&wSR!xNv@Uq4|l)n(0>+LIYo(~l0!Q1>&g&xYMRtTylUWxLq?0g{sC zlB`uUd%^v40p_z{^ISje(+Ybc>@yR}@BHWSiusUUu&2UW$~X!lWPUVCisUu@WDKn> zQ43R>?%5YJ)yO#z%`Z?`J=ZA#p2Ho<8ei8qzs^Y?EIwmF(c90&r8C?f*0s6C{uhZ0 zryokZasf1hWGu6}s(POH&4@+qp1Hg)_!oEDJK!`-;ugkT)e_z=W4UaxDEEIM-fj|XoXmjq-f^dk>&hqB2+=D&9 z(3_Fx$r{7m^%hvO1< zlJge%4dL4B9fpwzTTkuyL(_qf8&8|2{V@HsR7ZHdvE(w*a}2g6H)^`AS>aMoClhP= zi1o_v0VdfIq0#Sed@#v}RFIG2Fj>h)s<}pM;eFJk1@2@kmoQAHP=K_)w{IC5#_p|y z3T?El?=*hitFx-OV36Wv+?vy8KV$00fKCkFn&+*#B72?JH8%8L0KL8+Y23OyvHKY& zh!oNIQmG>am#4h_y@0uU1DY%mgGZDpW@eda?tH_0gF(pw6ONA?xP>oIzLORj??LfS z+C2psxc#%egdZ)6huuW}CkTX?aYbI5ad~~(y>D6>*VJS@|NZ)Dl)n62dvEfvLsI3J zwwC+FjrqVp_u#{tnB!8`>5g&%W<_P=&_x!>m;fqQAjZ&<$;C|^k-gdjye%QxJUdQ| zC4IF5LLsRdvOo@)4tcy!tDbG9J(0?Chd2Rjd879vIxKDw-!fbxrPc%fhr#^Q?+IvJ zMJq$PO~FS|PlCk#{znjjoY8zU8r51BqQeCKMM=l$Se7QFpI$?_mkZN0;ZHac%k@6G zv_NN;b0G1^vTgdEnG0s;eFF(?+R=ikAG>PCBZ#j9JZNBg9>d(dIw~94=LZ~L@{KO9 z;sKkdr{NC=s#un54~})B|Dy-tJBeJe_S8odyAiA-Okq@fE$qa%dd33k(g z3(B7l(MQ~+U3j!UlRNt8P`Efvqw$9`C9F7j78TxO_UW%a zN?9~-azaJ-J$WduFF^26O(+g@?v<#M?-G-_GxgQqz=7dkQ6x~)_?OqHUf9HMqnjK? zLyc=HJ8gBwV(J}HcZgV7+Kw1$jvH|g1!McR;@wsC$uO0P zR$Wa?QaVX}C-JOW{c{4j89YyMaud=o2;BblMk=AWM*1w5Z9@RPcqI>h(WL3QpAf3< zKF$|4xZ^6#ad!r{OTVs(EP=kXbV5q z*Qybrn`=+_Cy&rmQ{)~*2vcBf4ccK9r!Jy?Vgixc`g^58{a)vy9(s zYhGw3Y5VOzFCQL9l^!CTemh8PH}BUHgyuQ?Xz!ug$FCAzbFHj{0#ev)iKRF@Y7FST zTn_>cn6>$wi*r-mKJ$7(t>7=zxDoKEl$XH+H}dvB?0QXpquDOuR%i|XU-~2BwCvQ` z!ik#i!Az1*}L zk2sVgz0Slh8PM6UC!7TY@%bcJ@B&zqWRjl41CVO13^{g#Pb)%l(jZtui)5BcydFSr z08(8&df|~ciuC%-8LC^E6t~9v4^yeF{w_z#;%N+OAj=P}zUhRrb;aFFjC?kisnFLB zv50RNwGfMx#!81LnOpDN(tw?3_(|zEiP0~Bvtze_z|0o73*HdqDl1FJV^h9OOGhJ$ zr7L}R&7Uiy8lOmFszKByr`@T9Ic(+-xfcg7tbmwsP#!brx=u z`e4MDH>;{2>)l}RAI@GPSLV%TH{M`?Q*z0)OtDWwo*zQSMvzyf;q@>1mA*tU8%;T# z2|m#X91JN#bPHba$*g#M=GzFQLB7X%J-g9{`6((xWc`R>KgGOSVK0p$Vd3Nz(tbVg zh2xwn*hNA%lEiu)a^o>lmtxbB*Y|Ami?fHeW*eaH$gvy?c(^>y(NS;r_{=z)dO$?l zH_ylgpbwQHNaT#3s}_VD!@T^Zo$C($3Wfhh6U@l;KFJVSCn~ zD*sP(y!GPViA5Dg+R4HUaVWIC9@L@5u-$}eJ)eg?P~<&ucWPk{@G0VmUhH<8U`jtB zB?D8aDB(3LS4k%Hx~C}j3NZuL5oaU6T)}!=LSrQ6{*`ld^_lc&_-6U zqE@Kv!R2}kAu&W>TjQv}x8;Zg$VBCZ@mnCJ%Ik^E9juoZwW$I~N3f>yyN$Jg_Cj<+ zrRk6`!h|SRl#bmT7sAi2n1wyvajBlL(K5{rd{Mf3?^HuYF%9p&ktMh+N403qVzbi; z?4Pq(*wjZk7~xhgt(`RE&>-#Dervq#sK_doDLy|~E?&KIyDyoOwKi-GK4mq z1H3JlOHT8(o0erjw)lT?Oz`E}eCsN6uBzp3oVwxL5DlIZ%dQ{9Axe%T{h~tRyGzj% zg>LN#Mn}mO5jx&^oy#sby%${Y)AlP09gB@<`LcvRd$Js$zeWw@@OT%wa%iElvJy%7LG<$g$S%5w-qQgfGGG53ZPO zAzZ2tT}OO4-!mHeAn}%Yv@G*iabcd(->`g(@=+87legiccd$HHlL1OGd=VrO*#S%- zijQ&<){FOEi*fRB#ZT6MG6&+!Kj54BVaI8UVi`SzrSQ^m;N;c8L);40ZblZL{8Dh8 zfbpIlT&Pv+An`8X9tc7W5+BYX_7K!3*%81blqXdhSUWFO>M^|DWzsDA{!5oAO&(r6 zF^%na&@ETL^a~w8xECOAzA_6`ok|_Pe|ST_zK7l9trlI5prFjV8v1(B#N?lJoPk;! zCvY&rv^s<-;=4A`CEc#Q+T`|yz1%&R1a`%`6zc5;(RVV^#cd7p^>dRgxYJ%iNka|L zh6lo3GDnsqrS2l0f4xu5bt*o+4M=7tk7~56P&?r+i44>fN>tJ z9q$Y037H3%^X~l73RU<@7VC3)%rql4vTSAHdT)wGR4Baw$f~j#lgkSq&cR%<+?#`a zqZ2V|i!LY7qx|XlxPP`Z&=ut*#s1NUQa=xzll}g-Q2V8#XNl{8i)=$5p zdFyqgU;lBsxjUN^BTRzXHta&uQq4V@p#ll=*A zK~7HX6789du0^n+0QZX0ObIWKzbUWDE9Ik;aQiy}zNL{e%IT?!?%1ohYy9dCp}hTq zTW@CtHFI$b^`_ZXmqAKfhTJfrtZzHKIh#?xCtMsFJmXt;f8ATv%YG zx&|zB2UY@1V&8X~?d8pD@p4QhgUI&2_zGI->A2*NzM91G1a`7|Yj}~F&h}eG!snpn z`c78{zh-ocL?kk-29(!nAZ$5B>g%8%0scfq*NgO!>vDc%2;{XFmD(LkNY4(t^3L0A z$s~3`oy;pPHQ2#1nP;XIi$(u>2fG3TYBxLV0bbVsnWTqrAsfKwJkXV~Pk`lZIYaL! z#WyEnOG2g#vmiCsvckqLV~7r3st&E9VpX(B&u^6pXE__Xpg9Es%~IwY7k$0zp!jv% zc7` z13vf6bxPzOqcimQyW`guY|pA0-~CEE1nsU=8@L}AuUeV>hdl^B0G%#u5E$SlWUs21wFaSF`#St) z8{5Bo9SQ#_!sUC?V$9!lAb>qh=M#HRmx-anzh4gdL1o_1$$4h7X8si=DWg^*lvub5 z*F5V}0bw3o{V zDeceS1-2F%X;#6$S~g%!r>M1&*il;zh!$x|;LuiGIesuM;rq!5v(EiR3|q!>iVuB<&N|7gON##)-h7qVumYvbJ%CgdS+!JD{Q#ya>1{`{!Z+ngO{&+q1JCjm&9na9FhMk@7v`gR{T-1a1g)ROAxCK(fW(6?kQF@nI8cs65hlw< z6emVYQwu7W?njP@0~~FI%2@(y_|D!3l;R2qV8KiGwA%XFL5O~k7~mQAg3-z6fW~j? zOgrk?XAe0602y0Xg9{Y2&5$ItU(%<3n&Z+EdrpG@?!L{!`cE(CvPzi6J+bM?Vbyqs zoH{i}T&LkxWY%m~)b^W{%_OVvful zqS|g~uO8h!_$dQ%+gWLPv28;vE_E=07UpKnvFv|pRjmwtR!oZhEl+vdg^hEu33D(A zSIV!=gt5=2Mwq6cNTGG=Kf_u#cdBK7$TvU!0vEdrxp~? z0@8TzJB()qL7-XmJZYIUV|oG`Z>Wi9D6-S!az3k!nHG5AH1=PW&SUMK z#13xXA9Fmv9vXYT**d-UmoR$aMF;-sSmv1Ycum=O27{bh$?PdUJUfsDaE3)XEirWE zn2Dp^HR#FsAAtk=>TO!r5SXz8S=S{iyY*lIctGBdlT6z+)Ids5nQ5^Bj_R=>e2H(x*frjT!Uum!3*uB2rE$(={f4!xF(lsMc5jGiT`PC}Evs znB*K|>?%G7M@Z(^b#`505OBLP`YL$#LZfTLg)_f-CbM%Pv zxokLGnSgrecP&pH?+Lc0r(hNBH#EogN2X;B4Sutvd6qH@UPqjKzA}Ch6GRXp4V`sc zE~%r3(=pQa)xUn&X~C*CBE4RJ#BHGR0W^s<7cVMDf}d>AT;;oaw_vU>1U)Xg$gL|n z-J^!}!m`EZRsk+%tuTbK8Y@3K#E3>bXefn^6>#G9^UaA;W(`!27CcaW@>xumrIY%;cjwfIe&a_&^?ZNk+i^ItV@IwgQjJ;=&8Iz^}9ams#y@0O4VXM{?J@-!~QmnK* zE|-+Ak@_;sonDz$h0_@urIX9qtmJBZIiTxf*Vds|4|fj)j$RG->yoY~*nO&~Ekbgb z!k_Zsr!}+Dy-HBIh1HU^zR~FRW}@Jbe?C-wSbtnyQ(JeqU!LA>ov_32TuXD>OJ$z$ z>y52dB)=;-wsY zIbJRb2sSBhchpLIwu_5WE1br|L3}6`iB-mM&vRR|DwB(uzV`pZR#CmciE_zab7oinf$;=)7nD@{Txi6c#rNgI@5@t`DQR{H@cY%_#cif7-o3OKs+a_XdN z3dSeh;6blHc-<~Yybp9JEx^8?6@5v|n(|Y^CU_!hpIzIfbE-;MR5+n+nP_K=o{&I$ zd|6bu7eDeCFGa31?xHD^RwMVCB2Ndeh^U9TD;F8Xe+w4{q;bGGpd9ssHQs?Us4w6m zh_T}^dGq6)AH$2Tf#E?ajKM0U{>mk$kTH3i+yZ%fwL?xyBCH3vWqGc;DYVXKJ<7$^bGus+xmvBbBR6K(Qp#pM+Xbd% zK~Lo2`8P9)j8j4$P#41sWP-Q0>XLM%h-t=vccS!yXW@e}*{W#-L;b|@f^nTFxp#Cw zJU$O02xwEpg#c=-JZ46)wB+xcM0i?x8Lis8Z`sXUYF(NEMI(mpz3v}~QEgVCrRYW) z2yv((9n`<`F&hm;CrAz;`!7T{abICiDHFKfiAG*_C(p+{Mi-tw`1yI)?WwT2OkWmp zxwZ`0>Fj+8d*Ssmi6J*Son2UvJzfp&DPCmJ=y+c7n(iPSRVf#TOi$vP>h%AZTRv&* z5#A3Ai>uan%$d-#NOFR47v_YV#^kVg9PG;X%aP&=-re@#CB9VgbWN!{{fAY1Q<2ku z7Vs7B%=cl1G!_?{kKZkJ%2iszOy;=_G$J-Rr-Me!g>dm8QE{$t;cb_q;=&FAI-b-| z@kFA;wHjv4K09evS+g#PFze(n9_D%>ev;BUIL{iQ`fw6;`7D5@r1?C`<05;Jlsq~| z)Ycbx{D8`wp_2GMC~lxS7^aW*Oz;6D@i*;)af@p?_KE(8uTJTm$t9j|c$C>pvmkb- zZ<)cWIYP!u@}Arc8(`R)*OhwfYJc{l(Q#Jm%ofYdhqr58H*)c{d%cM9Hl3QH17q&g z(u|Mu{8w9lIwYTL18(NoFW#Yb`@eJD(1df)U$qCWVkRpnw4ThL1}0Z%2@q zQn!X|$cMIdswk|^^hpE*`q+NEq^5b!MNAy?Egd=Q+p!ojTT#Pv?3W&rwNu8rjGR}I zbmsSVIRAQ7j|k+fJ7$sdpESbcI6nnbZS zvDT*eJ}YI2Lovl#rf&nTY*C&-zFiQ|sM<*~EJ-Dx&R3T_;eBLKL)SSr^W#TO-X#%> zp?gb&u#Y~ys;CFts6O}tu#h$|C}8*j1v|Y33z&1uX_#HB5Kl(^JS)t5VzK`bcr*nJ zTzjr@;X)YbJ*e?7`kL_;QSKb!417u$owDv@<88OSWXB_NNC%JQAU?vYd0* z2$nE`uW{% zqXoU$VkLjuHzOoamu~Z$zp(7rnlX0Hc>R2`L!GRBec(d0;k?ah$+juC2mfP|nUe9s zQISub{y>17_VxLEc)DTSPn^j5I-O90v#cI}jj4h173qOI;oPt)$|o7)auf80G|FUOQu#b-`E3%(}*@eP9hgi90mmE&Xf z@bmMk!@C1@S?us2*J_nT<`cX-6t`gBzV+{seUdHQHopmH1qyL`=A9t4Rel!z3JcF% z5<7^o9$<@!Qn-|Wt#e}8xoQf^eYj?s0Tvc~E9L<-}Kr{PEUf=#t^7csp?%ACP-8UF>HoR>YwxV$-{rPM%n^-^7gGc zvgD)#wL+gy`3ZQAQ+fV8l*i06vA(cS;e_YmQNa}fEs5@w^^=24N4piwWk_=d3! z+V(F9DcQ~}gmtF5i07*u1tnNG4%s#tcjuQ13;jOJJTu93I%O=kIeFwjSKDE)<8~~u z9-}BEj2|YYSu(O`tJCybpy6K#yQ6O0r0)iA_E1PaiU)&F2(Jef!1AE+CHtS(z%C-3 zz~8~R`L}`IOgpa(^qd?p=lZvresUTa2Rjrfge=oPZg^Z?VaoS|!XuCXjUs5!1A_;) z$KT|`mTPL{pf%>rUY9Z4zu0ZoV$rj$=L`w3;RyxkP7 z?v@i=b`M;zx+8&Ft}go$S)TPE(^9^tgqjzp1yO+X_qMMe_}skPI@5KnDn%Em>5dM2 zgZ<=Svj9KsrFHKkVWEXb; z%@Nf;jhVPA&p73Zr`TqWG1K&3LTsI^)UFWd;X^b+_!Y8LBp_N+AUiR%MIZwUmMqfTbD5iE=NIVoI#>*(@x;A5T zUPg7Q!?=+v&djRbCL6gzZ{lt&c4n)Rits;bT){<$LKk&hkZC+k#^UB7X~qR*=e;VI zYVKJ7b|(%<8u8L);m6KjJ4FH5{kd#_V>mkfEIK!9O}(F3X8R6DtNe<&6$Jt->-X|O zPexwD7?In$BkDY;o3=kRNV#at!ANjkqTkw+cw%fmgv^d4db$ZlVnNDHR9Im> zTSV3ZhV*Qph*lWilP5`(%(RdrVYUQ6jXRH`q2tXVL*r z@Jmr()jV8K6)eMd(wv{MIx~A2LVImZXsHCRcXq#J`cE0I<$a+XWXW?@z9^&VBkFLe z3|s;Z3s~;|hhE)3RX?`n;ELZhNIYr(jw68sD>=>BSs<0@lBSi_mN*mFdZh|0>@Ygz z#&9(I?DkZEl!6$PlnFXGU63g)({0}WkFB?isv}yqMuFhL-Q6{~LxQ^#9D=*MySux) z2X_nZ?(P=c{cVz*bML$3qyO~iz4q!_wW?~)s!dm$6n4}t+HZd%BZtY>V9UND;O!MO zmByz_?{=iq|pa|Ye= zQSVS1^gr#!$eW&av^oV84Husr+h}rVPv~smc=gmaluXSu{DRptm!1l&rw%c_6cvPn zg5VXcogR|}m9#5tFY7Hirx+2G9)azeO>v#Mj^hYC=*)yK{%+VNq$vHVgqkI}T}xo) zin*pNd>=gm*DRZk0knMMrUPp2r95WP0Fs8jA@@_n{BIF*th<9eVpxo{Y$p4AqF3EN zO`D{a^54G~maJ(oP+C&0{UZC@@KEn>%f$;=cTnx_|KHl|V#ZZ$wtK%7e}p1wocQU7 zZq+b*e7l#i)5_Yfpi#VvCqt&kcg{o+j!G!Sb^XB23)%Rd)P7ahp!N7s|+VvSgjM7m+(z1@-@K z9^z?2zu#RS{g&qtl#Fbr7de{1!Q}KQKBPQ=rMf|R9)eRaiMH@N@>;H*eTHssN?5pL z6quA%P)mfl?!ShNeIHFD|DQV4`osya{^D!N7-8uyoQT==GU%AgN1hr|In@j%_5Wz;)j$6y{;^g>T5S^ZQCI`u z@t-A-c1-T@KYMa|M<70PKe2Q(brP(xe<>)8qB9(~Bmn_W)@WTN8Mi0FU6I!&ja*r3 zEfZwUe+Xj@i8Lk%Iy+>Z@sJWn2g=Qi9$YE9HdmAj4V!j^Kf| z^0%yl?_gD2~HYI6MBr)cDOvjN>M*MS|P*IaJ?*T z53%RFvlWqt)e*YPskniu&jl~|Eyv#&EvCLkv>j^CUVi}p@x8M2m)?Hw56apuV-r7}8W`60B7S{GR z+2Nhjpr1dQzYl32^*eb(4^Bqw0w)+_MNn<5nj%Y410me{3$7a*ZXM#~+Sn_8k`g== zg+i_%=FzC;QO-)zhaMzK6(MKc6!MQ6H~$e{bcXP_BPk9i05Q+2&YM+CaAX802C*+H z^2sq>Cq(vh2#~uVG9nNHVE`-P+D-n47KDewW7oM1lD(H5Ftt zy@>JzmRd)9Z@NjSmJ%M7$jV8~{~(5ac;ysF%K$hUb2TihZT3Ilw_3*7ak?`p^eJ~3 zt6->FGavi{lvujs!PBCWf)&_lpTL5zXd@k3c-5T!x*E2Z?iB$%tnqlST*9kDrhRoh z`OwZoczC)>*T2idG6rmL(GnmlqsCoOqp@V7g8&(vZdk4FCS-CfG)O`zt`e^QEh|Q4^Eo!3B<`iDeF5qweimXVFX$#Kq=X*@kDI82i#JmUOC-tqwE3IP-x(1K$U`kb?nw1`S_qHTu9gBNe(%&9+Cd|ab+Ndl4#2nY!p>Xjt?hbF6(Y8g+` zw3B5pRXsE8{2ud5{96*KA|#PSCZhFFZq9Nkdt)9Hxv#=@>gg&m_4Cr;n7tMnVY4c` zMW0ZqKV9e#ZfehXAVOL}H8 zqDrI5Io>ecuQ}euD=0=8myA()LwljgNsoDT{Yuj~QUP*pKWEM-$$zi4Rv2j~B4+GE zdhp>W4L3fc`i<7FFZ=3|gbhx9Ua)Rt88RXx6n zmz^1Rl8k5vq2(rQ6B#HV#+WFQ+PghAJhU=>5q^w7Ir71ylPu*-n9N|V$j_2vy>_|^ z7JtHKPDnu=pyGlfwRcj}w~ztFVO);+>w>`HrLTjwA&-aSGD#$R&qd@~rH)iN(82@ovT4l${#? z{p}j~`!Kk>oDp&e6B5piL%om~dIwnTpweju*4H*0(3KCYD&X&2WrG=V%XZy8s;$+hM z6DeukSG~?7N#p9HwvR~xLK~mN#aEQ;D7VNMwXk4Kbms8u21~mHnUL6K zB#qQJO+DGR(F+1=N1+PjU4-4_j9eL|iXWP##(P9JSxqWl7J;XvMZj7=2V`e)FSA@> zUDJvdlMSHXP2rcbYX{)Zxb3CCjzJwSaJy$!`Rdgb)OAWk#fo9;y9ReS1dsq)J} zyv!Q(z5Ai%Dvcwd8#7v`(E0Z;A39vOMHXt?!hEP4ptt(!_b97?T@A5igNxHzPX!9s zzM8sGI4x!dA&N);GZiA2KGDX81G(V&FH-sTS4{=8;piLeiIYp4q!LfRm}8o##}$&s zKZls*wljUI0IEBwC8F=pfR|e9I!uouKnQKiX zJ=0%|QRbY8x-`-Li{~VjFje$;mVOyqZ&bvU7MOV;_KEt|jc)nQNG+Cv-2mE?*;9G2 z_&y+S?>cXP4(;8QTiSJ(ezx1LX9dz(t7y)p{!Ql-U`Zb~=VAfs$UBU4Jg9iMCO5RttYvO&TCg?2pfn!7s6~M&anepek+bRku4(Yi12fHrET?s5 zmh|Zfw^|TAH&@%pfk~K__IWqaBk}Yei?ps~xh*Tfm&mZ4S!uoQRs^;2`H|?JI0*w` zVN{VdYF*uAXv_T}HhMAOm@I0{pyArK&0zw0UF0TnfKMRv>7hr)!e*lXA=!?Xw+g2- z>ODq2J34J8q`C!Un4GQ{!JDoAE@{G{O8>5Upd58^q*D!vpU;Z>wa5+p88!<5xWkqEHD}2iu-Y z>}--s&ngAQb{gY{c*A3Lqwoc|6!Y< zyKM6fi|=#VX+@p}Zy$02oaaV%xR$pXhl>GScM3COCZKJ|KvTVs{c}dNvVtVlBZ)z6 zP_33{PYsb!l}*+m)VW{aYa&gY6v<6&^$)IjiX34Ds5RR6#k#D=?HZ|36@C3OHh z@-Wge_l{~>QCY*z@Z+%U&0DKgMS->-Q`vFH@T+`XJx`7lNxU|FpTv>JSn0uICL5EG zuBygG?WoP&;|Z`DDueSVP0sWAZtR_EMr{YxIB7gn<1i~&NSuoWQ4YGQ{3)j8a~lXi zkPk4y)#9xW)vB>Fv09p#me;wQYxZQFGG_v) zDdQ!hI2A@&Q}#rnMJ$AtG&;Cy!QVr};-!TAWlI?w+uc{Fva#}BghOw>!lRQZ!IzF# zRGU6I#nTrW@AYj^Y_?1GFa|0>?foK5v6qh*EkH)us>ZB+P&gaqT=SZ62ak?&^kvGgkiuU+vQnNnLC z8Kqn|85_lRscW%F6}ja32bdc*`7_3-B@Wjlwc7hT$Y{`fh}U zh{Q};F|Nvkzs=X>l}M?yAPZOEPA8?HlyYT<|Ltk_B!QQSv~>PiPSxPi{D)D(2HYgG zFpEWI&jP;mT&M!=3;2Q3E9AGKqc+Zu6$9plhT8M+>Xd3KE-8D4enhrOl{jj+xAUFg zs(L{L`TY+1S1)*$*b|pq~^(UK8KEIq@Ze zcqqKTx#{mGGvx@KV06%KR075UmVT^(V0(Y4cn>sYUcRz?Cw_k<*(kj8$fhU?Gevp+ zB?8d$h@1g{fmg40@0ao5?L0U;@o3qKE*VXHOCr0+XcoSii%xZJlUW+^;}jNZ53CNV zojtcSD`zmbZcP~-(5r=IsV^Sil%Lep33!|nCY}Y_;jyin+Al_N$_~K6i2Qo1e?`$Q z(M;-ZGQM_u^toHkV{q?VH7+S@CNwQDV?Ii>XH3}-qzHFR1Xw47w_8#N3g;aqMG%?8 z%%HnRX+e{rV`bg9VnRRAA>Ze zSCJ=vxsByREXqeDj-n*>C`q42cZsszCR~FOcR3Enpkb1i%wsOb4c1f)Ei&_Wbm}Z( z+OLl5Knx8m(9eVkp9~Jpo8s4WH8p{5g0J)t9Wo+3t~qP|Atpywh!_@)Sme#}-O5x* z|I&5{LkRi4;7dryIynzs!R1Ztm6W5@9gA&NY)NOzYcpNPl$M1~2)8}KY^F}C^wDnh z6HfVlAkK<{e$|dCT>+^zk-6khU-k+TKww#ZgGB{0k4Gv|`jWh{R*j zUEG0`H4h%S<}k-ZnIDD)z!g5gHCRqP`>mxMjJ$WF_KEfin=g1H4j5o*@K83fNTuT4 zW-X+>27>xjPShicl=#vPMI`sKY7A!w8&n$?qNSt#vETxp4;H`B+R ztR)2FbB6^Fr--l2q>7?v)^~iyoe24Gw~g^8Xt@$0E=;^eDh2u-(N|;x%Mz>bl#Mh} z(uq4sMD445yyNUHT4py90~Y=8vPWOH8w+~9Fw;zoH@2JIrSS-DAw1OLkV+#s)FY$n z+i=davuJW=lO(TS`7PfjBI7VlA^$B)2UL^4&`~PHW{z}!6+y|=1gm_+%LAUVevxLh1t^-kjY{DMS1l`p4OA$5Y z#ZnaWd;KZfG=4mWbWsY0agsx6XW28qy4^SOsr_9SO8z0;M6{&?aHv}zOEQ0V8olu8 zA6+BQyfP1XuP{)wlh3%wE0=lCCRLFKE(uF!Mhwe{e`6w|p4TSN?#1-lX#_>(IECn^|hjV9oq6v;ac`EX| zH7^=6@Q6|Ol@n#K3Cd0Dl8HUOpnx$qqO4?~k=Av<61)V@v&AuQpHzdY(V3wmEtC&? z)OrvLwI~fb45@5l$Z7LEBJb42O367s0ylRwh*252?lC=PqG?OYcjTggT6`vq_XxqL zxS8?s=M0^S{2RtYG1@v6&Ia_44q^D}4JcdVl?gJgBI;jq@0>;u(XhnmBMuT4h%tdJ z%j~APi0}T4aP5nlMVg z@$1q2h&{!GzBnm#elI5tmY-I7848%cS8I93%2bwdFWgEOdqe};=ilWbWm3-$n$MIi zbn`9@eQ8VaLc@!Z_?~YUlb8zSfGcV|YFrKQ(3fTMw+ZBtix`IG4;1{9iWR3&Cuz8@ zqbjYMW!>1`nDPi0h=pgclop>304o<)O;?t$^MOOV!=nil{d^c7MqA}40nr3-&M*TO9+~4*XcEjyv=sADj#!fSN z_FUEleC2Pt`XS37?EIU|exT_8Gp+saxQ~-An@<4F1R|Sz&jmKbu<;pi>0`EUv{F-D z2G8UysCTwbf-BZ!8y)m*=ZQP$7ew zZ=LaZ$HJjk0b*A#;DA8GIWVyN!74uF0pOC<<&^QQcAEczT(%YW*~VA0x_bNc-u9v760<9F1Zn?}2P%V5avd{ zw1tu}OdjsZm>H1aKIDsPF-+60o`jHUw(rXHQkwJ*>WPbKQx)9^trMp^i#qw*$`~2- z{z!p#p;5hpnGt+-Z475qh~dAci_1195`Ud{QTxq%bV(6Wlob4YIi6jNSLlu7XOYqr z-9&bdjgfP-B*&>Yw3JkXA34~^B0+PmzMGLprnCq|22!$_$+%9}}@b z`8aNecGqoOXZMdl32>PU4KD+Mq%|}-9xw12AOWkZGUWdQ4Ly+a1~VwW%XYSNwQ>i6 z#k#|b1j-55coJ&GH3YGU)=Vm zo>MAQp*-{{ph|g9b1EW15!Is_eg}wbA0@-y%>o%p{iVjb#@~%pBLa##CX-Fp|MFtW zikjn9M7MzA@F@?!LPonfCCN}|o9Zlux{bKgC{KcY{;*A zruZ@hM%v0Q@M_Ap%2vFw(u6}F7US=KW$P|||Ff;0k^BXWQeC)KP{M}tnaZQTEfvaw zZd*fkZsAK6j}4km;$$ngNbWiJeANHsH5EP61SC?yla;X$AF3o|-mF;rn#e^fTF&HtoorwwLrSQ&q#=sOmA79+PdSxl0Vy-1I@%fARAw zv;8h0DXX=mD%dGy{E~~>jk3>;K4WIz?iYv)qptfr1|UHFEf=51?doPY7Tu+T^F&DO z?JWH+8n77QP|af)lQPm{%AyAcjQl{@Kh>N+;rw5<84%e2SKS#f>i>?D-{NC4cF*$w zwvBWd_wx8RKv8_SleV7z>o~(31U%r{N5R1BcUpiQwg^hldv0J#icZ(4d08SeTXRlL ztVG!j5@}#b%C0xRp<&@-V|ygQbV1_jNHm(yEH&B#@noJ+W#FEBkw!+_)1stP^U>C0?jc#z*9*26DN z(05=cp7pyuFCl)+Kpl)g(s;nZ%!bk%sEhkZpZK0|s|xbM^LqD5g5uvKPwz3Z1?O-L z&co5HcX@WLuDgT{ZuTT_?m@qfsmxv4fsJm`Y{Q;^?Cn00nwY{2Xn863FXqk@=?xE8 z+%)J_iNu{g3w)B*f47Rs*BoeQ#Ih!`*5tKgZX)7G>*=-J*f!8$7Wm>?1v0tkK9AXxmF$z0(yo=>GY+#;VJHL&6y4mv}Va^H& z%c9uMT0SeWm(CK4@XLQCAEwSx;8{dL9_9)ZFckmQ_%nw+I6S~WO?5Wc-}+3NPrB0} zbhrCwH5~Bi02(53F!Ef6=x4qz;Hbc!82Sn|ih-14*7W&Nri`>()TLMGBGg!k0${P~ zH(n;K<#I}^^-8Vus>sb?{#+c_;IITll7{x5$H{5Ek2PPUgBjQxdq0<1CA)m85N&pY zUl9{-l*6TCKb8#PF63k(iTxet4-Kd6xSgwI88OEroc=#TBbjpd#4PsVTBx|o(3kau zT+}_JqcnxXl8#->+%;%=^Y)0zNJgvmi5D~?pz$zV%u<)qe*ftM1#vgzY=+FW-nf>le}})*Duux zyRO>kpQ)7VkTjd?yw@nC(A5}VXpZEnGW_1iewz0_!vp?LMyz+FVIzKpx@7YPeun*O zwuYs7h0aq;LWva5PO{TTtb zp~(Tb5KT_6Gd63C`xzuGF{!hXdtq&&eDq-}3T>=-EQ!;!kcMXePhz`8K{n0SW2zi= z&m#X)j|S{x`J2cgJ@A_q(cWg|PXOWy340_H~F{|~E6BjgerfhpI(h1{Q zma^E2iU?+j>#k zBKS>TM31QuMEVw?v|&%9tI+K9fp3|t_Q37>!GIrwwVw*&Tc)fK`7e4naoWl(fH zvROW18nm!JT}T7KfqKH#=Q>J9yOU_AlvME2$Osy`lW>=T_@zm{K(iR_qL7MwKR92} zuEd!5b*5cu(cXv(#EA=rG5>hv(tfkeQ%@d`SD}-dgs~~@CR8ixQD;_8@!fGFCdq)` z!MfCM@}@1h`R!nvbxq#;JugCiRS!$R9p8pK@aJ$HCNB5!x?YK<|@M zq=5IYmIj{KL#6jYY%%{Pkj-xw&{!ipbdiYL~+?wQ3ysu^kA*fP-qDywXmN z>|w)lNNJq$RWW@c#ZP9X=sQ^SJM;MR*uthjBiZcjF58QiC+Iacp9WswF6+P+kM*l= zpw)}zw$J_y@WUB1HKtI>D5S_49=oGe_jX~_wm_zP#A_O@HQqV|;H_A{JW65CIGMQ7 zO}9+ifX?@{w`#Ql&-Wc3&K@`VJslH6bBDNS-7+^H^H~ zD+|siorWMDiL!Rnp0YcNWeTjYK1Brd>qqf`U4yp8>Chbt!-={#{ke8=+!( z97>wQe5n9!T0zVjO6~ZxQ{%dimAU$tJ&nZ&+8tUmY?E*k1z4g5L0f|_&|#G4VI6roOC~RH zXSG)tmD+)wRp{MEl>Y5_<#u@-(;6n@%uX#3_VkCDhiAxZT+i!O@hl~0g;*_(ZD;X) zx}@P4{FwYYX~d$c6S`AOXa(jbAk!}b+<5JNt!!zNKRA5ygqA4YSz-OK`9ix6J76Bp zov4?ZyAC0_VU4!9>qL@B>%CerW1A7yTgM=mMU>k;XWUM?HOQ~FxTC+N72KqZjp0+j zw#X#)EUb$I6^4gMWeF9y3ET&tp{_BtI6W1h;dj{(_?>aAJ+n2A10<^c5J;7VYQ*g{ zPsN?n`*KU#Uh5s~z*Jn$ndEa36RR#a)$<`U-dAgP6bUrj#W@-ilbjB28iA;tLEff! ztxxaz=Of+M_q@)#j%s!-$w;3wK${{xKt;(gqf-$v8Y#`_mfn)5+Vk8*?_*VNbK z!%$A+{R?L6m&FS~lJpTrQx_Sh;tHI#R5zNu0Htf;s56vQP8R_dVZweWr(mxhMX8 zxmk~ey{6Z)U_WuRe!?oSRO>idt-1X!_)~>u{scjQLVzNSRQ#sy`_0l01cs|M?n3HB z?s0F_AlvW$0(!ol+G5S?GbTg-bm_KH;~@3~Kh1$F9T~4jIoUhfEPkp-;Er)@$W{&K z)_&m$?@TZrRi-`5N5xvNN>J3n#>9o^OrGL_|LHAfwo?KOl2%RQ= zuz7b}U3`Jcr0*761~)ixTs+stv-JH28VIq;p$%KKrM^c&^u8wT->&tOv~a36$0(ns zUUUWFbAJ3BuN}+wK=hw}xDDrFg4^ zT3QniV|EJvzoOQDNCuXTR&qgc-TrMmJIo;tE z8U-plzFxB`ri@H-vnT&N5%P#+STur2AkH|me+kW+NX*N>K%jE8^r1CXi0M2)TbwTn zDZlIbv}f(%COpegafH2D%-YR0Cvlk@Mf@L-g54<^^%G^)_NaKhQ8Xenu;M`bZf%i8 z3PE|5oH{@@-z)tTu;JCSI4NbX%^uR-ZlV&r?2mHT8RazKJkVZM8xiS^H1W=H+Cd2$>$NS2 zJ+z&m#wnWGG%i0iqz%idJ^3{i4!TNM+^-7y5Mm!KEiIPUTTF~693(}=c@W3gIG_EJ zM|^xU#OleDo3d>6RJ@Z5pFT#i8Ef1JcTVb;b;U@7-BnKebgL6RVCuqgvy2r*v58Pmc{8_X{53Hs^^Pq4O9?@W*{PdF?3?b{1D6f5 zB8&`@S&j90K1+DernybbnUdp_ys#A6?^aN5?W#FI41aZ~V z%UjIG$Wf8gu^%Bd4sd=!MEb~Rr3BVu_%-#-Vud`p$m4BFIRE3977eK#G z-%7t?TMiT$}5@D7+V&W5+K8q&W?@0p)R1B1jR)hJ?IiAR+7)Kbm4h>|cym$`MFX5ka zK)ZI9CtN=s9rM z+`5qw?m672gm z;SGo z#75)1{y2_pa36k>i;<_2w()={OsBXB0KZ|5x^z^7KTKdrWb{e88t=Nf0dc?`kV33h zoJx}yq>Y!mR_xTe&4r$$*YfCax-I!UrsWR&gX=V$xb1t8O^nWl`tPSl}G{p&B;2jKSeb>=*G@>&~2}L zap*S{wY=6`G6Gt`ciA$c=tSD zly>zD+tNm^|6!m?ir^841T7C&g64UT*YTG}uq%evaQJ~QhQy~;BDx~)K*5Z#qY%&QHRRIp;u$@?f{}5f^ zP}U=p9hte#uos&9IKboNU>M)4u^A=A{4J2@SZ(fZ;CSMIaduRD0XZ71mDbuAbEdBj zrm2N2I~6p_1U`QrYKxlN1uQbdZ4)ygyY>iQV&;`fx;Z|qeGF%=i>8jya9n{sp((u276)T?NanPHqMuZTLv zVaoe}w5GnhH;o2WV;reJsEyM472P7`uhOPkQGzFmji;(Ld-xr<>U?=y^krp_KOdG| zvjsa#eHpy+Ga$6b)0zR_<9b8?;V?DpBgh*1EmSCV=f~55@nb!B9K2OU5limhAQs-4iwhno%vui3)(F#g>O_?3$jQ*3fb#S%;ajn>UAjUQ zIzcj~D)c68FbynZ-M-nYF&d7d_k{F@GJztP$Nf(fpCf$>?#upt-1z5ppnDR37ntR? zMEZddUYaRYUEm|;KtZ<9zCH`FVP~&bORn;y2_4tpblhQPZySvNvPfO9gTNmfg^61p zxZlf3uTz*5Ma^rzF_g&ob$k<4&(?`bgniLNI7aFs>uy(gh|*`9F%>f5THF9uj({51 zKxG#b_aZ`0r3X@d2!qc#p15Xp#u~Lo)9;r4SpjVGkYL7--&3M5S>-({FiF!N{jhIl zrL@nHam6c*W{`K%FT~klQjzsPyLle~UC*8Vj60tiAVOOmO+ioxKehi}i$Jw}vvHrb zLPRjD9Y@GmE3JUJ3QGo@L$5{sQh~tV6BjdDl}CVIStwz>Cr`!<=O1cg(9OpO8H6#h z%n&L?irqqs$)ZGhc;4PH&qh`KhkwbUjZ~{^HXgp8;>{->5z76JWm83DpkK{3mr4;a z?W9%nJAL$m^^ZN59F|l=KpPh_Waahm$5hXzl<-l{(%f|_=|ufTQAl6%CB1~#Y{DMM zY>0x@WVIn4J~}NUq*I$aVj$1y@XK@k!v@YjZ?1O#WI>f~E>jReV1~^v6ru%2Yoq}0 zdo8nR#!e%Chjl}#Dkt-<2ilTw{$7tDo>oAXMZmrcd@x=hC8dl2-S0EWuJ^eP=R02` zJkgCk2wrWXgA@}X0hktSPV$U9efPsMW2jW99G2JCJj#pxZ19;hW_cD|d7t@z!b!D8 zk;JSNm4gz2Iy^$?CUVo9<^jK>4Ga>(abs^C#~9fyo}}we;xia?W?0}POiTm}5_|uR zS>f8__NKF!U(c#Uh6XPY zCHOnC^K{H_=Kb-ftnQK3=sT%>x(xn|fTMUEd9&(5NM)xD6eGnsO+j|=nUKz4foo=+x$Vb_1XnOn(>&KG#`8L(s2ejSsEUB zn!UibSO!!ee zh<^SA=R^O5?tmw8s)(?T8F%PSuC1cd0kStGQVACRQ}cwWZ3Dol!br6NiwdeRaD}ZDkmG>CMoP zYg(>2t@mZ8X%Pt+D#_E8)myelIyhg__L{Mn@r7q^){P(y^wh!m*^1tX@6NYG6u zeD3N!oq7J+X#{)#dbMg`{_35U=F09Z2pABTLDO;B$kY!ic$QNsb{%N`FHFww4i@0FPN)h$Yg9Ry7RHwM8L5e@izW};pkT@E9FO&fP z(9U20@tAjwH{GE*cK^9;)1Ol2Ei3Qo!9z30N0l{Gn)KBHBVlP7r)Feo+2mWpx7X&B zA6X-p=H?8058e6q)s%Y*zh7MAYUu1YwN6@sWd9cDJe<#RzXs+>q0&HZ-(YQYi*HeyAG;gO4!cE_DHq8o9 zz^R_ot=wt8O5r9W=^yt~61cHkpc_?Anen@c5S^mV;(g9xu)AI5_rV~?pX%*L^AJ=J zn1%opKt2-r!`4H+!k+y2O!XEjF!p(SRg_KY7A6!}!`$CeE&rEIi?dcw|73(L2;Zvi zhd~!W)w%Z zX;Dq|1Afy0E%mog<4Pc(Zq625(`fw#&n*$o9y#|Kgw?Z!zpu(jAy@f&^bKOtj_RMR z*l7yDuDMywE}H++>uSPc3rF#}%A$;v-02~~`? zbNf%T6JL-M$f-N2m0)|NH`9H}fdI=F9=`n52$!hc_?zCtf?Pd&4m3y%$F`I6GjHHX zG;oOni2jymE&I0P%``vk#_A1bM3%Zq}ycVy9!~dIvvpX75 z@cL8LY_&D4v@I@Ed3Az;O<{Z9s7mtqAG5+8G1l7bw?IRZva@P;^(u0e){RM#Lu^Qf z$koJ`SRD+#!XfH?4pugQ1O5z0Wo=xA8SYU#^V114+14*5kmhs4KFf zU6_XjV1Ie@Y3>OUkFN+%ba+#}f5LAKeDyvD%=OF@#z-QrCUrkH`IaLXwC_uR zHtBQ6k&b^Dc+#oH9l^ta1qB1u1}f*eCmW;mXJCE2GcGOf$rcqz9zEtmLVE){R0P)= zE0QO!CzhN*K<(aBo8J4ieuR(9`#^neLE~vzJ!X=1%3JhY$W72toVp((bxGA)Gvus(8S`fVc7 zNuP)GAW@qwZ#Y}Vrfv?xk85bB&zY%2PFGn$3O2bO8%}=DDtu$Ea zhFz-P!@QHSEu)&&*YxL~M!Y8?RX&e*TMz*$u4rmT32BqK`Cy)<_Fx68A!t!$6r~`_ zi(gYwh#z2WnouY_i&4)c9iMtg=p`nAZMXyZ*!_}W{#a^=8A@2#N^E>Jp2U-E{B{z+ z^8b@hpqk+ze+}VL_LZ&1Jgx{CPO(#6QF@>p73-A}zd5r54k;D0Es4_OA;8XvN?u7Qmy!7%d(mx!P?^nVvP3{vd8=ha%m2Q7O@dhWPyu8Rk`NMOKL|!L-bj@FZsVS{G+X_ zJTa06Qdw|^f@Cw+$?hh(=7u!M(0P}T+JG<$CI&5fBUbJ4II7-j`$@zH>a4Qc;G5jT zskS?qe}5}_hzW`xbEAoBYaDOrknh_E%^H^gl(xTCk9gHB0E&eG!U5oxX83)hx2~f_ zp2aVh_ZoEsHj=(81dL82-?F?JK*={}=Uq6Zg0^U6NmE7$XcHF9Ag7txnG(9!eyHK` zKb!Tb4F`9+gB@yUB2x#-GIU^whM$JfIKM;Zg8=u})Eu_(f#=*c-4ktv>qhP0(j4*N zyztZZ%v4L(p)s>@A$xT?V?~La%Z^swzMuUoUxo=`k{+yn5uQ-qbQyl83T90g$W8X9 zsv_WND#Gzwi}eUSF<$a!Uq6a6;NMvb>tzh(NS*O5|CJ9fg!xuBhJB%F z2yR*`KDpr*q3h7D|LtEA8n)*Yx${vSrtgfJZ^Kh+^icL<$1-c6)kMNwI@I?~tne@M= ze?2ULtz3m&c9n$U{JgxWf_2hXhvN_U6>?T4m6Ps#7w6X<`NjMsH)|JH=dfh)1^$5+ zNw8dav*(i>kg+O?_jHV;TZfp!@P#HDoqV`_d1cZ2-GB86;UyEbCKS~SX;*?@jQRgD z_LgC7bzQe`sZ)x(l|peX?iSo#Qmn-(?hX~)DONNDC@#f{lj07+-Q8V-oP@i*pZ7WE z{jTc+e(WpRd+A(bjX5W4?B6Ng&v|#;rl}(ovH1IlF}07ZxgrR9O#*OX(KL)zc&RAt zet9}4QL}5Wo~(7tC{Za_aY*-+Sq#p}FPzXm?7z4=^T0$iJjIK}NJ$Y9(d>Nn8^nFn zAdQpxJp&%=Ay)E0he0lApNc@bCc>KE0O7uGfZ;boN5O=}zY{GQ+iyTZTpL_q)^qq) zp>Hc%H?9N2rS5_IHj4eNjiaWpL+9rZZ!r#}lSzU662 zzDe<7xa>*C-BAgrdEZPx4Nj3$gCqO3cf)%l*c-`GG1jb|PYD_dtF8}~t?>n{0U53> z$v)Sw5|TgCJsqEvr2Gf%lP(;78FDvh-EgL4(%op{9m@_FHN5fda&r}LhfNtK;1yB}K}ot@S*`79%^oi>zUIQv5-zZ%axzgg8D zNR9{fhq*3mPD`kR7~6l?axGhw)q1A64AymrV0xV4K#7To+n%-s@z^isy#(dN2>ou) zij+8u>E7r6Wi8$h7R=>JP%c#IEt=;{|1nfTk+#gTB~rWXnpmF{4Fn) zEv~T=tZo5r6~o@Ps`iJ*xTK=drsppNn) zY;q~dmZAUsm4DdI>K2&Hlz7qO_fVTuD1)ua!aatoeNS9eWlvqVd(~Wyf^x_sB6Er;{^ z<5p9&`iwSu=pG-DE%q<^@s)*YFNL~Fv`H8`MTsMv9B#>O8Y8>m?JYfa(j>bZhKV=a}? zA{tKLq?qRHF%T9QY!}!S@;sxO&(5?Sruj~TF}f1F*gQTOVE6jLuT$%juuUL8`X1f@ z$PmnV`N)1p)oIfkHl|K8QT5d@5V16a>6jsZT7rtho| zj27}1%Q0dDdmFyQG$;AUsP0wKr~93) zxLzSvuQEL0V1E|#Pf()q-PnsSee^tn+|d#u75_5@!t&;G(gR^ji};6@C}~Kma3!Qz zU3(_x=aOHIY9yc?hnR&ztCD@!<}GCqzuvW$O+lT-0KZ zBQrA^UtyyOMhR{6F}Ej5ckm2OTjKfLDey|JXICPb6+|)wh7zAywd65@K4|N)(;LrU zJ<+9!X!eD8hMP+BF3Su=q>&5uY?$#=7U%{HFg;G2N*XY~n1P&IhMVmryNBgr7Hg@8^xe{XBwuKM$N?{QvrS1)r6Xj5FrFE&XC$hwCQ=K z?LXMR4N1S%eIlz+b1`?o)Hr)c^-_mx!D>rt!xvMU+xKDD$s?~+ZVZ>21DEjdG$cV3 z59$-E43LKJ5RB)|S2OCO(-AX4iz64d4m3`c+ZU?!(0*FYjg<<(-q4bOg{t1uSftv zWtq#-_9e&!KUB9{TbP|(^_zbfC@vbiBO&5ubr5SU{jQ7I1W+c|Y3AL)9Iu^B*%Q$V zr9Df*vm*WJU)J}3yR*Rdg8j67(bK6;AsSPa?y!l(@EB+I612y3v(9ndVTy5KWFPg* ztUl|L2p5hkfx3kZ&L4Hyp|0CSJ}rG^UM?(sIhm#~|3P37$9j;UXpm(0lKVI02|MWN z*CzSgMCgD>MPs>%FsZxhiEJ%^ssfl$b{bjvObM1tZ$1N!5x06^gw40N&=oRHsSlZ- z;Rd2I=+4Knstz759e<-fUMptBE#BpCRPtXY7)5^6T?A{bpnmD9@)9ohsA%zZgpMc< zt0T~Q>W5aaX^!Id_56)`(0b^Qlfz{%GwEDiz<6mR8<&S0%jxEz=I%7dkw&>DXDT(0 z!xU+jbH@jtZgAAl*D=%+x8C=0g}`EbGo?w7p#hz7fddxpcFJGcc zN|a2ora4-UfyY(T0`$@=wsVPNC-&4ugjGjCbf zpyhU&_QQoB=D=}?rS&H4Sb(_APMpd%f;EXKO29{U*uyDE;ZCUm{ighx+4WgyRSC(- zEHv<}a4fshvGVkZC7TTn@wa8sJ}FhO=N$K~|iwH&>SW#LNeJuTF#`y^@ZZ)zSGU>=-p z9@=gmzBWJMMfCD1JJ=M^=f_sOdAx5DL{y`w&l4?h)!LuxsjaTCV2@uuacN{zISE?Ko6cP& zcYGj{uDFygQnZDKir)m9NJ)xA=u=5`HAjzXGRAjC3t|bCDnzg=MBh{Z%HP1oGQfmN zUz^@jkH~MFbAzoIF8hJ;PsN&}gc=>2O9<S) z?9tX8P{$q6_u2$&xCa)10x@RTk4iDHOB=X-94NIMsLw6PJ?aX#8d~g{31^zfb(`sC znpt&QcxN(^-lZc?rAPmrf!svp78VQXNx^63j{8@bdi;m6UoePuWuJ-ZhVlJo zbrhxu3MUAT`yQN1@^pp$W5U}crazp3JEwnb`1{z=fLNmz01fz;Js)g)sUMM={2irSMmAguRa% zf-4YHVD~7B=6r?r)(Sf@wWg+HEu3GIcJKr`)&yAA1VYBk;BTqTG&__}6XXzgP0%R6 zP9C>xr;eF0ijh%Vk&IZ8?O#EAW`{&$_ejd_iJ9HAKUi{C?uyRm_)G=oH>mo9HGA$o z07t+c4&U0G?%F5^&~yR&@96nzlJ+P2F$P{g2LQz2zhBcszrVZr$_RX9j9e?!*5RsD zrK5#bg_7e^nfZ(hZ_+_wrTYi_REGO-Y?5WZ=hVvL6=_`htO+xJF8a{vx1f|c=~lF; z`bLrK2gr{_Ucep#{Tf#=J^D}MM5&#&jUN3Bwze{6NS^F})_4$X>u4&!zyP{reriZg zZTLpgkjm7MCcu!eneefwv^nAzYS^6?EnD<@;7-}T{WIE7A`7rISqVc7g>=(B>3 zLFS$o*q9cw$98~39O=k2Uf(lgs;zg`#k#v=Ap6W`>Rf}TG14G@DC+J@;t_OA4qoQ% zAcC!uSbuRG6UtNFF9SZuAohNb_HyGW3F=8Fs07Wd)qA+EUc2P4vcz4l1r*RlyHw** z;=bW)JVOeudE}h$H%deB_F-TVJtV{;^7~$!quV|}L_R5OvX0JD!XR2VDCGh@2$u(^ zNvkb%k8*xrUL#`&-f5ZnWp6)S=A$xQ;UcO7@nEJfcbJ)?XjS(8@y!*pt%jYZyZ--g zYc9{9n_>JJq7D3|gUv&3fn%M6`6b+M`RAo9Dx=tRmbtptZ`XIr8R8VYPA@V{c7hC0 zcLi!(vx62|rfEz_F~vODO~^xO=jy$3XRFl@CT(vW+kpda@nPToGY&mwXZ`It;g-|W zzE)9RvaskxWmO`VxVheUSG112|H8}F4<)eMTH&MTW`h34NG5pE`WWCmSB71BDIeb_2XhV^f}zWHAKEGH=@^grkk&$I|XK zHWUKu7(rWwp4RGvC7a8ZM1I0Igwm@!C3j(I<24IPd1r^*yLU+6x-FHSsA>|5=>qks z02stgUXfu&!M0n;9mB1>@N;GNMG4XstJ?63Vhd?K!kTIsPmu!bXP2Fg8>Fp|8Y64a zx<^bP&IG}4^~md#j4PMZV5h_x2&FnPId1%bI2wdFD62VVvagn$30m^xkN>sY{OF@A zEk~-S!et)$N4U@uqgS9@tE@af>VUX*5hZH= z4mGl+fN4Ad+QI`rOq(~ln=`+{Nn^Xba_-GkjLmZ;tvAUt4HsUc$Pt@i*@(CnH#z3H zF&MVi7=^jm^Lh)Ms{XRon7*9TGzofoGaoXq)nKIl@xpylIU=TGR_>8XoLgZrx$LW* z?}iLZ^%+;W8WHPqr>qSLyB(LvA6-v}&7J&;yUvJjW#~g(5Pe3d;?U4Q?r2vsxueTo zEPjznh>E6qL%%ly{dh%XF!f&thBhZ%1-w7huy*fc>xsXE%NZE zsNicr>(2;BA;P%joh%D-SlKMA2zF_ms$U>25vR|*!n%;ZU&v*ZFGj}#Wgp87yZBK7 zIsJFaSSny>w85=jLl^4tG0sbQL2zQH>(V*yozWoLpJ`gpER#+9_>`IFO$2`C8;(Lj zY>3&7;>Q}?jBhyP@wE2)3`F+oRF?9_4|>|1>0hNNZstP2t53$J1Ltg>Y=jHAUXvf! zd@G91<}XEdK&c^4A2qaoNe=Edo+aI++9sh)K1t(Na2}dz5}Ie zn?EYwNhJ}&eCQ^K#l5yT^n>$A*Y|e7kM_v6f5|6AK9?MQjanpB9!*YbKNTPy}O zBOBuiNpys*kN9NCvr}p+Bh+%rv)Qi@l*r)L_PUc*xerPuv7edCHlldhLg%cu=nz>d}ru_A#q=wbx(E zs2)m3gv(8h9VT@E4UrXsdrM^itNQ}}C&zOmzkK>w7{O*9`bT;hrtp`&ZM=3`6)MEFP3#;2`dA;k7pvf|o5nk*4fFLJ?0Oumc16LKdFmQnaICJLZ zrB#xlgFe8$2#}t~@wv$_hp@F6VKs`%X25s3fk8>r&+W!tx(Y&>;UgrgX!^%V%6Riv zSRZ*Cmr}FJVhfQ$fdM($r#|0efp?-o^r6;|NO}&dXp!Dc`-rk+2N-jmmICA3YPPrD zOie05zt0>vvcwn-)_sgtPLq2xAa~F&3E(%L{&W^Lx8h4tl^HW${LZhe(J#a@dzc~C z;00lFa_#CdLZ=9R^b+dWyv^^Px(ufJ+CB+EtBN9`lZa90GxKFJe8h%^^4e_gU7_96 zL(kH@PSuF@aITI?l{vQ-qsX&hN4=@CsK!85J3=Yp{WROy`Zewjj1tYMXu}Syt*wGY zC09lpJi(g1A>9a24qCj-^g7i#!($DM+!$nQwMNeH%E0#F$;h#?ve+apg;Cj!ggO1; zW1^)#eZ+La(RfOI2$Njc(A5pNgEPPMORj4WFRou0J%Ze&4_`Q>i2@)0Ay7a&(=hKq zYaqJ3^>gnp0yTTv)b4eUONgQLM>+{xG_JcP_dl~l4n{3beynQ)sb$L=UvJA{ zDDiD}b!mh}t`4Uo?m-yWU2zJI7F|_pIx=XRB}Vxc%|{#7iq&mEabGQB&mx5HSr8F` zNe$#Ej4z22{Q2Zp4GZFNMD^iE%$}erk{g`)>Cy^P{8!3#V&Kgg%Wfr9m1J%aqeXsQ zRV~9QUWnMdnigQ*>Vu`(3Wl+sM^cd8$vC7}sqVLAGm(+EJj7ot)0xr=`1EVHGM z#b{lJqVahfY;0i=Vw9su7EY4}%`MR(IscfaLFYlQEsOcyX5aDNffV~TvO)vVz1Q@2i5m{kf(}vC7xmz z4eG^4-!UmO%m}+P{xcLoDnP18W+|<&>`QK}e1MiI_CwFH{j7K$a{AKmiBwy-;XL`@ zIH}p1wLDS0)y?E9B16R6UdMEb==lRWt#1Hj*H!LRct8dsBvzF|ipOrJWHd z?KBt17@8>a^vOL=H~+=ygW7Be{wZ7>)^5>>d__T$shB)Tk!h%Zj&C&qP_LFsr5;qC z>W8=0P%>g#JT`miHBb~K0g#jAH?vsfK~(k6{GO4i7Q_xDM-vW>Fw8uW4Cf;D zw5%Z5<<8_%buMG=GJ?m##Z*ZMVKrRz;0%LP%-tOtb9oz^+Jq*QJ-AKZ&!kHE zTJ*TlyPl~O(w(RyQ|T4TxcA`f>5V^+r86Q!Za^Cn;-@RVH^3VH+XF^n$aZ>RSFh4|<{ zaCcww+C6$9KKu{vK_4~h?}JFcA22!`1m~Vhu*DEBE(t(DSA0_cjRIz8@F2 zga#$+2%nJsUG1p|6!hF#Fw|*}b2TR*w~siz^njE(kAZbJW!`i@j{&^T7TLLpark65 z8uXDpG0ueO)Y(yH9{WTku+AWCu$o$_e2cUeuJWW<5?DLhq57C=b$!LY?({hGB%A_f ze{y&eZkGZ|chM3Kmz3H_B3Vbhqr?@Chx(pIcib@zv=uOM<1ITPS_bb@2mzc+OBdBX zIHgvN|26vr4mX0ajJtC7J2-}%X>QFF69qqnqWzAv#mTlt#E4LRn654;3QOM$oz%WW ziV(`W%&TK`b5#S*UB`~KTQKr{>`Y})*TE9L_UBs>93PcQN_;^yKKp``@zee-d-o06 ze;Dv`(rNAzk?SYZisv( z>r7;}X9N$YuzE|piBO#LQe!SwJ6v#*mGTduO0Lw3)aDrD2avSJ>HG$-bvpuwb4N!m^rszRBnss5kq`N4{s5J$K#Od5+ zH`;TKu2j?S!x{z<+Q#>b*t{v`c)15 zAwy=-#NN#vl9A#nFBJoT{{wtQe9d_1Z<;7&=EHJ~Gz#!hYW6AR$jtiU*4vJsXMHzE zf@iE}LbmYI@A1IC>@Mov53jj<^4VM;5N!hWVwCMK?ssemFn4XT>>P`h05B!~ZhMa} zN1L$x#(qg#?X>&Q{TS_(U6wXsps4iH1J5ZmYdi*ONm39^;F)kQ`3PSnm0$!}*Zm9+ zESI3Gh0ctq8H=jAW%jkt5iv=^P%XmS2bdc!VM}vd^8|e~zS-g*+c1RP8zqpTQ)eh1 ztelh)B^Pcj=nCDa!min-=GLsO83Y%vL!{`Yq?wedZ`fu{)YG=EWpSNj+@ve08Ip?>Q+RF2-K1OSJC>9 zYV|#a{i|PnkDve7eTYY)IEWAJVsp;FDdA!xKDh_TSYe2}AlBUm)xq5_ul#aemV$L9 zyW1GV_piYi($cvXjo#w0++EyN2gr~^AqYY@{}{Ql9if79-@k}*#t?;GKbs!MCpLcx z<!`IY zk(Lk?19!!*vMkUkK2k6H%;yQ6ro5x`Hq*GsoDvh|O4a|gM z;KPxgj|ioye|SgNzOZTmvrI#9>St*Y!`O`tSCFJm{+BW-=cs{kW zpZsiKxh$NzjmPdYSGGCV)HjP27|K+ILt%3r;urk(v-n$TF@D+R)Gm?jLKXg%N=yiz zHSvYNlt6;XXfh*8C!~U3}r!g%z^j=XnEi{0itCIAHs^Lpj;w6)IoPu6D{XiG6 zZ_b-jyiGRbX@Nt&5NB{Apg`Aw84hhBjHY>Ibj8+rEpZDZavS-~X?1GzAwqK-_GQtE znYMJECf?u&F;l{gp;nRoS3_?Ah@z4W&&00QK>#KJ5t3IrsYwdkfq{pvS)== zYeNHma>=aHlTDFa^X^H)ehqG!w0T!W*!$L|xV!yN3In@jGe$h1U`Y)(^fqeZKzJ6KTl17&F zzffP##yeaeHaWhNV}iD;N(UXo5y#pX&zz}&aw%~cA_|BEK++|Bm&gExI6t`%y?KfF7 z_p0oPFiOhUp2Nx9WbL_b`=d_1m51XG-0L}4-S9LR>H}ij>t1D_sZCdztJ(u8E1KY5 z$(-DJ@45E4U+q=(9sMjT-rta$#7lmj;dKt#Rg=3x zD`=^S2v^SEF&r`I|DaTT`{B`WE4)zswksvXSi#Nh1{58B3X~g90Gam2_1thDD&C6- z!@s#lCG)5M3uqxqyN9Zzdl*5K^xq8cKRU!%9zsXzGy1UMbGP`j9f+z^3a1c+aACk! zpW@;;!+Xi|x^M5VfFU_JaCjuYC)1Tm=iKD*jRy?(G;jBOhXvwCAIm6;s1dPCMnTuq z5_4SiUhQXBZJWwMN_^@BuYta$K~yKJWcLcl@D^`Sg&zLLX^k)VU-(_LU|~jU1^d}~ zh-u6G;SGgk1(%L{nXuTaw!U41r!<>=frl#v%l2w@a=-XJHUg#@y#tMDr%eZM?ve9RUY?~OLTD! z2fh^^U9nh!1qkP#LDrWKi<$Suc{rRb4V-ep7pSd1334dutVJH&%sgzxhBMR9Wql^+ z0NXO+p%GFak>C~IVLuNkugr#%4~(`T<+|9S^fN!x4A-{PCH}n(LB+v|#}b)u412l1 z7~!OPz(c464be;7!kopRIy32nX(eHE%%mViD0~xFz?2#u$XiKptWI7k6Y3$A1K}-i z*LR`PwO}(kv3K2Nh*seU&6rWkCY>4T)ep-uBai|Mh-fSR+*@u{*KnMoZ1PWx5 z464n7j4DJFObX(8QYv zJUyzJzWwl`-LS&3!ZyCRmUS;%JIPO1&&a(nM#8ByY|=`;tetfrP9k+xsnv9mBQkYh zvVZiIrF?2Pn|qe=RCo-PI*DcWDjIr`BVwzDZ~f5FAp4&i*d}L*KQKavFH~cv+@D=- z@|PxP(3R9i;W;9A8!MWvFL2r$Y|;^FJj-h#zrt*WCCiZL=puFR6b<&Dm6(ciol#K6 z`YT7eYP6OoD8p)!x`ttIQib91K2Iu%H)v@IeLPHwD*8vzf0jOcQuM7}_D3AH$N%G# zA13!KFba6}vbBG6LK{-SjAb_=hrkVe>6gsMFUWfk-uTZMH|z|G z(8Ol=8)DAJ%vq;wr7bypo^Fz05Ic_zA$PB?b(U0XuL;c^uqWter1n1SQ+l!)(41iz z&ro`3ZfcSiubGxO#ruZVuprpL+}b4Gp^emrWeqRiXn}Vo;#NHIgv5G9f2`}OY(~bt zVF2!J%yZMPHEkOaCl;72Lr?jz=%+@cD#}6WtTl%l`qXCZit)mGJ{-`ZyeVJ)&)m^YJ#qH)fyrgf9|mFm z<;`b~tMa+WU;QMvq;m^O*4&*sw6AvCyUOOWy)22O(8F_*qo4fQQ=!yR5zo7@*tGHL z=BoM$8CYD0Y(un9v#Qm1Gdstx``jhIqRq9AcIxha0EdOHs|ftOduh>Nf&!0t25HUc ztjq8IKtTFEA6-2SzI)Fjm*=GBx`ITd>9gjVJ*U^@Gb9AaR**F7DEqv9gt!+z1pa=e zM&8++k#|l<9K_ez*{y@}JqBGdN`{DRVn zV7UGRF2#tN;5U6-)H@TZ7Y(XfDSFO^2*=)a>p z3Gn^SMZv=p)K<%r>FJ+eFv{FFKI?xA0G3z+i~N?V4p5hv1^TL`?z zYeIni{s6;L+TOtJk1W&l9R)&cID64m^!b;)E#X!7AY1D_L^240H>ht>O!7 zX3Ly6FG_z}upaf;d&Ce+hP4>v>IvUc6eju|%nK;pTwwP)hXQQqruH86(PXZ;5Oxd$ z&VYa?u7<23vb^i|lmBTT2|QW$O3$kJsaflxp_5-grPw_%|YoabEwNcw4~Ww$zrTP^GPm`ebDnyEZ#=u~z2wajB0X@~ZtAbmnt?N$-&R z6)VaI^m7yLaY@P(&nh`z7dT-ifPqOoDfQogy&3P^U({4b1{flS3$g3}h`0}q_9!8O zqls1U;Hb9D;C*nE(X>)k`~S{;#UyI5fa-S1vfndG^6T1|G2WCi7DvLfPKo_D$y6f_ zWO7`g(|K4PR`JSpHE9xnRK_Ah!q0&)&LZU18l2Yo&-o)78tE8rg>9W!?l-NG_X2^&^$i zSumxh>)J$IhitzY>{VkHlp;=iLUdds?!|c-#--{Qi#<8Ql%yVZd>=K4X0O7L2D1Hv zi0p6yxLp{i*5>yUfo z866~b_?`~0k-=K|J6AoF9pUtvsZ|cTmMox#$H7t_ZyJDI&^jIq++qAF9uLx5jbpD= zZ0ln)yB!+p$&94rXwHzf)$)Jf@G{aPkg8&_CVsu^QWWsUnQ~-QiygE_Ajc@T0Vj0V zVeY88-eIALSOtRZCciM-OffRNF#_&*%=4KQ*3Za5x-97i&`Xe4hum98tcQrbU#-F! zbQ?GGV~E>N$u?CmA!4FF4HzIetFk`GjrHKa;s_3tBAff{T6ofPFGo`lJ^a1Sf`|~9 zbB&p~E3HY`>AH3DZx>X!cU&-t`Nq^7O^qf~gig*7;NM3`;n4e^;OT$(HoTk%>BtC! zL|pw7Ciw67WL=>AOW*G!4d!Vf)0X&WjViY!)V{CEZ+o_q`Pz>JoWBcQPCmY1&mF%z z+$TaHIJ`yx06nKoE25rZ5+w#2RaXv=5TC6fQ}|-vVVxr|_8{7ftlQj$xKlgH z`%E|5ZNlGU1MW*i(0oK8X{oN0u#vTmoQt`BI#8#V$S#_HwqpsMFsHcn+=Foc{R0~I zIPK+!&M77B4r+Bh)-(GXU}78YO1`HXKQ7CYS9!dbw|*S1aD;o*nHUHDUu@3W&pi=i$>QQZfe11ACI{)y`9tt z7{pG(n+E{bg2X|8`>YVulyQOQ< zS|-3rxByS;kV+Grgs137nDN#vrCOOQtkvCxh1k#PbrE%Jx7&}$Vo0tDMvBVj_9zW@ zk|wT>D8|)oTBj9YOxIF*MG!7oZJ-b?i`tAk>3x<+U}im!K(pEGM0cR4unwR;DKP1 z-R|t_Uq8^azBrn8;Y?Q7O8jcLGw92kVzn$gZTpV?#A9K7mj4Lhs#u4;UyMwZu%8Zz{P+iA;h7#b$! zhH?$(c1}V7%$~^B3370`fSawm5W)4cq@7KbH1?XX-TBtqy;$}RMlk9pdIwYkX7$#Qcp$=4)b7d)P#8BWq#dOc{(qg-X4a{?cB%)=aJX;QMCVXnB0$-dEi*-=zM8m4b$>2{yNf} z9OOp&Lz`b`Mh@x+Nvaj$Mm(usNz*8vj zW1I8`Z+XjC(aUaej)Sg+#O4W>jXhTTUE&pE-fkVQR{n(8pQ(`N(6wtE>-N|mRaYy- zjtJ^+*GbJ62clY-dIT9nwi?KX=x_n`4h5GS+0+V#JC-IE=KWyr!7D=A=DS7{2%ASB zY~N>tg*^^gC?fJB_e<}ARC2uDIi6d`EpELC_{eD-?6N1_P;L~$luzNz{L8>0%r zr>|!BM+oMpe%Y6{pXyIrPXBT(2{*RhPdf)~CMo`SR$>+&v!bM7l&=_QkYCrnaZbS1 zvs&q8$@0995MQ<^gUw=YNBd)XyCu;#`L=48pRd2ZgFR8X{%L+ZDl}i$#nxWBwTXj= zWj;1sVSvL_NMCwfxCOjY-S|nlHK`yNW8in|`MLtM>;=iPD{nuO$=rNkP-T!4B2E!4 zO^J0z%&+KPj0f8*6s0{9=x+!)BgHwwyfVnRIaGh6-Y#;+lKEEH_n5!aD4B+!dMi$T znnQaGT`4;(>$V#GCf8UtV*9I)@Vj3{Gt2;m1^b7?-)J92S*GiI?*s8|^-)NlK*@CeuqP!}us8*#AN_jixsc%<-7_KIcNigG_BvW4Un_Y4X!kBj^MaQH zq~%h!X+H_n7`^_qZv4pXh9Wn?Xp}uj^HIQ6cIHGlu2bP_wDA)k+jnjx&%^aTie?7X zHI~OGuH(@>dHb|a0l-k^!N1ql&oT;;;?$yRkbGA8w0*aSB0Onq&9Y?Kb2u#DVVixj zNbP>$g7M%(7a`bDY&7ot{$SGNdgbBL4Lh390*HV|at5OD{W4yG*oM`&coyre$eAjnl<*xhMK|c6%M`j$UJ+ z*4D)S7THUbuPcI_V}hB=d#^UMFYP#OBY>HW%{>flUr8Z}mgS1xity2aPv#D{OGDCP#dA7XIR*CW5;gBLhe*l*+6$l07DugPf(alD7@N3Y-YfbaTpb>=2j#)aJ$G>) zVr;%X(BH&>&+|CFI%JSitsJ48KQ@)@p!+B*;Ar+-(U1PFq50K`pR&t}J^9Ii@Lhd7 zDPYi-;D*M@kAiIB!ck+UgkaeqOK9k&h3kt2>XPNq^1O(vJ$Cxm0XI@T&84T{jpeCk zy;m5uzO1KaE2$3vV)1Kk*L7K!Tb1WC3TdUT%Hf&m8?V4J!%XL%QnUv>1IAT~ObC04 zmY%Tfwm*&bopjw1SaEa#MJT(F!=R*q$rvGWoc+TRr$FV3^UdcH`t3Ki_N=rk>?e?A zifG>`kLo!b>**L&?#b^QfgZe2JXv?N+`gv#E)C_W>=jYex0RCJ{aQT|D zNt3vh6&sgHQ9C^V^2+;o&a+5)DLwABZHo|n2)OS!|_W$B!k)=RPdk{$lwXeEM`b7tU* zlr}wXi)WnDCZxuLpl(b&w=`6UreBw};?`f)SNM^~RaFf2frp2bIV`GihTcjN%GQ&= zzNTUEF|h8%R$_{QxxVmsqmlmL)1O#V74r{n&SM)jbw)|Xl)xijpoPH3k-UGFgWpXy ziI!H)9{y^*(xmr}u*+-DD7_7FTF>ZNa|v455Cec$)6Xy?*~!mvDA0{xJ8+ zn{&H)nhoEtw@RG`{lbOoyEhM|(T!53RjRkm7Hp?w^9!dIR&O6pX;y}CS$CgWzjDm+ zjeeAn+^spdr}pk&gFBAgire-vgyX}+-?}@6Mlid5Dkx%8H}SyLmxc@g)(PjkN>}`R zM;ahW8_?Bw_Ro)A+J|}fZ4vLqz0|E+eBtlLwXnyE0*UfQWHvyItDAk_JKkmCgNL_o zme=)p?=o!{$)ZuVJ4(EZ=Wvs-j~*U<#Joxey+sF<%ik^B@%Q!iGSVz>lY)6z@JE-0 zNrIl)lMA&5yRklRizWrT9V_~w%?i;yUoFmg?R0%Xb=@IZLfqhf-RL@62XIutrpcS= z?e3ySau0vr$9CmQHiEr2xu3)@N_Op=lbD#eShZPAw&s!TAw_%J@_G1c^#PSAt<6io z;mQZU2*?iIwaIeFfPO<#YmCBaQnm+=#on8X)KB#!eo2UjxiPfRsYbUG59YTjDeHrX z50~)(9ZEtO6tURy41Nwv!FSRS1`e7P-d_gjG=0zZeVJZM zqRh_DUYBw}9Fd#`qi<|+=Z%exIlPI0ZnottN|6xyE%jLYjpguvg{sl4NbPqoX6>M? zntC&Ho5V8w+XQRAz2r&sBr%!l{gE!W`U%jEfS9NR!s19QD^ z0~!hsRS{jDT#YSA+*0*A`>J@}VB$=R%(5hT9rUIOQ}&o!uvloeOFC}(EC-Dyd`_RG z2>ui=70ci1cyU4SN2yMSy?exD+%UPvX!z}^8e0=}p0834zW`6-cTe zj1uI!(z7iyj}=8R(%ak1OmiKt_3cRCD}Kp-KJO+SUga5lrc8>>lAe1{V1aM^yyl|z zVSsYV0nD5FHv@Tj@?ug3#w?#d;eg>ayYMb@lO)j5Hp1(#W|2Ge7B-&?+oxGR$DC5^ z9Oqtvd4t0YkNws+Qk{wTN-VF+{>NilIq5tsO3q6VA7OKEb|IQ&`1llHAJtU&R8kJ) z<$8$ZHBt#*oot}|9aO)0J6&&v##hqKzVN>4szUe(BiYG5K?hqH0I)`=hk<$gilc0! zlGwWb=ko|5jw>D`T_idHd}-`BG}{i_lO+{Kgs|zthrUUxSY`e@bUA95s@#1oMG1!K>Q%h$-?%fsovIoYW1HwSz8)FU|%s zArbS(Rfo%v$o)GbFWA80E~I_n#?$Lg7(R)n-KQl! zFRg?{M~X%z(PLRvgOGWExxu&nXR*pQf^-H6t2IA4U}6m7gsc6TH~XOd9$yYM6`Lh8 zZ@QSlLGF$LUr!(9d640qeRkGoy8Lx9>ezum8-z9QE~Xi#8$3-hnYuK(_RBoLDQ?zE z^ip*778+K9A11dW(r>ZGO`m)ga=gXnC5Rb`4*Q%ns(OtD9>Gkax+GD~XQ5r4gElfk zzR-FiI|R~pT&tYbUz`{vEidgKkoO-Ro)2DM+xfOWBh5mfq@HJ+ zkZ`kD^>Z=>8gG1VlOUj+rx4Oi9T1V{xto~I!)_N6R_-_x!~agBAcF9P+Soh zo7X%_ZPJ%BF&R?uwuSjLaqYYK5||jjc#T}husX^1x=)_G1cPMGZ^B2ul&@0(cTUPg z-?2Oi9*(?@i)y@|TFu_$RS|6bHfdIDpJo*|`h!-W8;Yxjm+2L6>Y2w(z&xMGwh(lb z&1)K^Hsy;AdBq%|$9S37>vuF6JB06#>2q><`UiOMHMGJg_R0)S1d%MLvqb5VO&+P? zC?Rdd-k8}^A-Y8H9KB5viN@vyAN%1;fkVwR@1>t1N+|*3cGD-*p@+JlPfLw;@r!g1 zepvf4=YT^^E*&vUhPMRZifel@-Rc|!spkp}_7UFmAh1nw0j6cMV;1!Ax`%SC$ur14P$Mpxb+5rZN zqDtf^{TAj1@xsprNi>rZ5`?N0CA0`khPSUD%;7{)9H+;!+P@E(qZ2_@rPGE30ejt; zVcKhUXjt?^#cLd=rTpL6wu{WymCa_UK155vxtI< zvomlr!#pQX-idbeR4d8^tJvESrT?F?t!!Kc3jMVr%tu`eh2jqR8ip*J`(xE1;rfRg zy3SwyV$5)~{5iv{)E{RxGu&VNGx-8CEo%C6V^LnW*C|uZt`lA2VWW{6oYR_lR8#Ti zk@g%=UeOL^Kk9Zcja7XfmsMd{t2*E6$>$#!Up~Lv+B8abzaErn0DA}&ZJer z7e(DR`}A-7DZgGg$m~#1dnV9bK?^{2S6TK^r^d7mHxEiiU+q-?cPKHk40v;fpZ&*J z+m^-sTdFbTdG^Ki={jmLMnf}ZSk%A7l`Y{9aGtH28EP1q{FR~Oa#+XA*-f#1Z|I79_TB}&Z+n&5R15*VV~tTQ#sxGc1- z!ap2Ib^ll?u)y(!)FR%6-W`{?7HRjS&D__#W$~6nTOOG>Eakc?vL!AX=-M>x|3};! z)4G;2o$EWt=Rc>6YyR?O^R*k6={B$}+$|g! zu{D>Z=5IOn+|DVu{e_yFf8!J@A8SABAnPz-U3eyM@6^e=mL{#?JGZ6jxtQJeUI91N$DC;c@%j(HNW?)snzulcaA?VY*?qbtN&u|gt94Y%T_9Hz5Wmq zb#Em;1jV{eucX21Lf7J~=2YH>I zONY$^cD1~eTKSuG)Af0vWErSkT3lL?w`j85w+D|uOSoBkZ7=ltodMP!8*)j_40!X@ t`+K#OHx9~y%IGxynR38Mk@$b?djDI4w;d0W1dbXpc)I$ztaD0e0sv&l5sm-= literal 0 HcmV?d00001 diff --git a/docs/images/wordpress-lang.png b/docs/images/wordpress-lang.png new file mode 100644 index 0000000000000000000000000000000000000000..f0bd864ef0a72930a28dd8e58685437ecbd947b3 GIT binary patch literal 30149 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 literal 0 HcmV?d00001 diff --git a/docs/images/wordpress-welcome.png b/docs/images/wordpress-welcome.png new file mode 100644 index 0000000000000000000000000000000000000000..c9ba20368c55c34b18cc6a37ed1e5d3d37aa1a60 GIT binary patch literal 62063 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

O2I>c(i=A{R+)A=E;y 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 literal 0 HcmV?d00001 diff --git a/docs/rails.md b/docs/rails.md index ccb0ab73..a8fc383e 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -9,9 +9,9 @@ weight=5 +++ -## Quickstart: Compose and Rails +## Quickstart: Docker Compose and Rails -This Quickstart guide will show you how to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). +This Quickstart guide will show you how to use Docker Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). ### Define the project diff --git a/docs/wordpress.md b/docs/wordpress.md index 62aec251..62f50c24 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -10,88 +10,133 @@ weight=6 -# Quickstart: Compose and WordPress +# Quickstart: Docker Compose and WordPress -You can use Compose to easily run WordPress in an isolated environment built -with Docker containers. +You can use Docker Compose to easily run WordPress in an isolated environment built +with Docker containers. This quick-start guide demonstrates how to use Compose to set up and run WordPress. Before starting, you'll need to have +[Compose installed](install.md). ## Define the project -First, [Install Compose](install.md) and then download WordPress into the -current directory: +1. Create an empty project directory. - $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - + You can name the directory something easy for you to remember. This directory is the context for your application image. The directory should only contain resources to build that image. -This will create a directory called `wordpress`. If you wish, you can rename it -to the name of your project. + This project directory will contain a `Dockerfile`, a `docker-compose.yaml` file, along with a downloaded `wordpress` directory and a custom `wp-config.php`, all of which you will create in the following steps. -Next, inside that directory, create a `Dockerfile`, a file that defines what -environment your app is going to run in. For more information on how to write -Dockerfiles, see the -[Docker user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the -[Dockerfile reference](https://docs.docker.com/engine/reference/builder/). In -this case, your Dockerfile should be: +2. Change directories into your project directory. - FROM orchardup/php5 - ADD . /code + For example, if you named your directory `my_wordpress`: -This tells Docker how to build an image defining a container that contains PHP -and WordPress. + $ cd my-wordpress/ -Next you'll create a `docker-compose.yml` file that will start your web service -and a separate MySQL instance: +3. Create a `Dockerfile`, a file that defines the environment in which your application will run. - version: '2' - services: - web: - build: . - command: php -S 0.0.0.0:8000 -t /code - ports: - - "8000:8000" - depends_on: - - db - volumes: - - .:/code - db: - image: orchardup/mysql - environment: - MYSQL_DATABASE: wordpress + For more information on how to write Dockerfiles, see the [Docker Engine user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](https://docs.docker.com/engine/reference/builder/). -A supporting file is needed to get this working. `wp-config.php` is -the standard WordPress config file with a single change to point the database -configuration at the `db` container: + In this case, your Dockerfile should include these two lines: - + +7. Verify the contents and structure of your project directory. + + + ![WordPress files](images/wordpress-files.png) ### Build the project -With those four files in place, run `docker-compose up` inside your WordPress -directory and it'll pull and build the needed images, and then start the web and -database containers. If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` gives you the machine address and you can open `http://MACHINE_VM_IP:8000` in a browser. +With those four new files in place, run `docker-compose up` from your project directory. This will pull and build the needed images, and then start the web and database containers. + +If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` gives you the machine address and you can open `http://MACHINE_VM_IP:8000` in a browser. + +At this point, WordPress should be running on port `8000` of your Docker Host, and you can complete the "famous five-minute installation" as a WordPress administrator. + +![Choose language for WordPress install](images/wordpress-lang.png) + +![WordPress Welcome](images/wordpress-welcome.png) + ## More Compose documentation From 97bbee19b7f16055c42d819bf005853159740b19 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 Feb 2016 14:55:06 -0800 Subject: [PATCH 159/580] Update docker-py version in requirements to 1.7.2 Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5f55ba8a..e25386d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.1 +docker-py==1.7.2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 From 49ef8a271d89212fe1e778188b301c6da67b9566 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 23 Feb 2016 11:31:22 -0800 Subject: [PATCH 160/580] Add release notes for 1.6.1 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/install.md | 6 +++--- script/run.sh | 2 +- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df63c5f..7d553cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,61 @@ Change log ========== +1.6.1 (2016-02-23) +------------------ + +Bug Fixes + +- Fixed a bug where recreating a container multiple times would cause the + new container to be started without the previous volumes. + +- Fixed a bug where Compose would set the value of unset environment variables + to an empty string, instead of a key without a value. + +- Provide a better error message when Compose requires a more recent version + of the Docker API. + +- Add a missing config field `network.aliases` which allows setting a network + scoped alias for a service. + +- Fixed a bug where `run` would not start services listed in `depends_on`. + +- Fixed a bug where `networks` and `network_mode` where not merged when using + extends or multiple Compose files. + +- Fixed a bug with service aliases where the short container id alias was + only contained 10 characters, instead of the 12 characters used in previous + versions. + +- Added a missing log message when creating a new named volume. + +- Fixed a bug where `build.args` was not merged when using `extends` or + multiple Compose files. + +- Fixed some bugs with config validation when null values or incorrect types + were used instead of a mapping. + +- Fixed a bug where a `build` section without a `context` would show a stack + trace instead of a helpful validation message. + +- Improved compatibility with swarm by only setting a container affinity to + the previous instance of a services' container when the service uses an + anonymous container volume. Previously the affinity was always set on all + containers. + +- Fixed the validation of some `driver_opts` would cause an error if a number + was used instead of a string. + +- Some improvements to the `run.sh` script used by the Compose container install + option. + +- Fixed a bug with `up --abort-on-container-exit` where Compose would exit, + but would not stop other containers. + +- Corrected the warning message that is printed when a boolean value is used + as a value in a mapping. + + 1.6.0 (2016-01-15) ------------------ diff --git a/docs/install.md b/docs/install.md index c50d7649..b7607a67 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.6.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.6.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.6.0 + docker-compose version: 1.6.1 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.5.2/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.6.1/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run.sh b/script/run.sh index 07132a0c..3e30dd15 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.2" +VERSION="1.6.1" IMAGE="docker/compose:$VERSION" From adb64ef8d5406c26834b2bd0a4dfab1de47ff9ca Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 5 Feb 2016 20:07:04 -0500 Subject: [PATCH 161/580] Merge v2 config jsonschemas into a single file. Signed-off-by: Daniel Nephin --- compose/config/config.py | 8 +- ...hema_v2.0.json => config_schema_v2.0.json} | 116 ++++++++++++++++-- compose/config/fields_schema_v2.0.json | 96 --------------- compose/config/validation.py | 14 +-- docker-compose.spec | 9 +- 5 files changed, 115 insertions(+), 128 deletions(-) rename compose/config/{service_schema_v2.0.json => config_schema_v2.0.json} (74%) delete mode 100644 compose/config/fields_schema_v2.0.json diff --git a/compose/config/config.py b/compose/config/config.py index 4e91a3af..3994a332 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,12 +31,12 @@ from .types import ServiceLink from .types import VolumeFromSpec from .types import VolumeSpec from .validation import match_named_volumes -from .validation import validate_against_fields_schema -from .validation import validate_against_service_schema from .validation import validate_config_section +from .validation import validate_against_config_schema from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_network_mode +from .validation import validate_service_constraints from .validation import validate_top_level_object from .validation import validate_ulimits @@ -415,7 +415,7 @@ def process_config_file(config_file, service_name=None): processed_config = services config_file = config_file._replace(config=processed_config) - validate_against_fields_schema(config_file) + validate_against_config_schema(config_file) if service_name and service_name not in services: raise ConfigurationError( @@ -548,7 +548,7 @@ def validate_extended_service_dict(service_dict, filename, service): def validate_service(service_config, service_names, version): service_dict, service_name = service_config.config, service_config.name - validate_against_service_schema(service_dict, service_name, version) + validate_service_constraints(service_dict, service_name, version) validate_paths(service_dict) validate_ulimits(service_config) diff --git a/compose/config/service_schema_v2.0.json b/compose/config/config_schema_v2.0.json similarity index 74% rename from compose/config/service_schema_v2.0.json rename to compose/config/config_schema_v2.0.json index edccedc6..e8ceb4c2 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -1,15 +1,50 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "service_schema_v2.0.json", - + "id": "config_schema_v2.0.json", "type": "object", - "allOf": [ - {"$ref": "#/definitions/service"}, - {"$ref": "#/definitions/constraints"} - ], + "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 + } + }, + + "additionalProperties": false, "definitions": { + "service": { "id": "#/definitions/service", "type": "object", @@ -193,6 +228,60 @@ "additionalProperties": false }, + "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" + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + } + }, + "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 + }, + "additionalProperties": false + }, + "string_or_list": { "oneOf": [ {"type": "string"}, @@ -221,15 +310,18 @@ {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] }, + "constraints": { - "id": "#/definitions/constraints", - "anyOf": [ + "services": { + "id": "#/definitions/services/constraints", + "anyOf": [ {"required": ["build"]}, {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] + ], + "properties": { + "build": { + "required": ["context"] + } } } } diff --git a/compose/config/fields_schema_v2.0.json b/compose/config/fields_schema_v2.0.json deleted file mode 100644 index 7703adcd..00000000 --- a/compose/config/fields_schema_v2.0.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "id": "fields_schema_v2.0.json", - - "properties": { - "version": { - "type": "string" - }, - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "service_schema_v2.0.json#/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 - } - }, - - "definitions": { - "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" - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - } - }, - "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 - }, - "additionalProperties": false - } - }, - "additionalProperties": false -} diff --git a/compose/config/validation.py b/compose/config/validation.py index 60ee5c93..d7ca270c 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -385,21 +385,17 @@ def process_errors(errors, path_prefix=None): return '\n'.join(format_error_message(error) for error in errors) -def validate_against_fields_schema(config_file): - schema_filename = "fields_schema_v{0}.json".format(config_file.version) +def validate_against_config_schema(config_file): _validate_against_schema( config_file.config, - schema_filename, + "service_schema_v{0}.json".format(config_file.version), format_checker=["ports", "expose", "bool-value-in-mapping"], filename=config_file.filename) -def validate_against_service_schema(config, service_name, version): - _validate_against_schema( - config, - "service_schema_v{0}.json".format(version), - format_checker=["ports"], - path_prefix=[service_name]) +def validate_service_constraints(config, service_name, version): + # TODO: + pass def _validate_against_schema( diff --git a/docker-compose.spec b/docker-compose.spec index b3d8db39..4282400e 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -22,19 +22,14 @@ exe = EXE(pyz, 'compose/config/fields_schema_v1.json', 'DATA' ), - ( - 'compose/config/fields_schema_v2.0.json', - 'compose/config/fields_schema_v2.0.json', - 'DATA' - ), ( 'compose/config/service_schema_v1.json', 'compose/config/service_schema_v1.json', 'DATA' ), ( - 'compose/config/service_schema_v2.0.json', - 'compose/config/service_schema_v2.0.json', + 'compose/config/config_schema_v2.0.json', + 'compose/config/config_schema_v2.0.json', 'DATA' ), ( From be554c3a74c137e835209b7a1f27b76bd64aa54f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 5 Feb 2016 20:21:58 -0500 Subject: [PATCH 162/580] Merge v1 config jsonschemas into a single file. Signed-off-by: Daniel Nephin --- ...e_schema_v1.json => config_schema_v1.json} | 44 +++++++++++-------- compose/config/fields_schema_v1.json | 13 ------ compose/config/validation.py | 2 +- docker-compose.spec | 9 +--- 4 files changed, 28 insertions(+), 40 deletions(-) rename compose/config/{service_schema_v1.json => config_schema_v1.json} (89%) delete mode 100644 compose/config/fields_schema_v1.json diff --git a/compose/config/service_schema_v1.json b/compose/config/config_schema_v1.json similarity index 89% rename from compose/config/service_schema_v1.json rename to compose/config/config_schema_v1.json index 4d974d71..affc19f0 100644 --- a/compose/config/service_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -1,13 +1,16 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "service_schema_v1.json", + "id": "config_schema_v1.json", "type": "object", - "allOf": [ - {"$ref": "#/definitions/service"}, - {"$ref": "#/definitions/constraints"} - ], + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + + "additionalProperties": false, "definitions": { "service": { @@ -162,21 +165,24 @@ {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] }, + "constraints": { - "id": "#/definitions/constraints", - "anyOf": [ - { - "required": ["build"], - "not": {"required": ["image"]} - }, - { - "required": ["image"], - "not": {"anyOf": [ - {"required": ["build"]}, - {"required": ["dockerfile"]} - ]} - } - ] + "services": { + "id": "#/definitions/services/constraints", + "anyOf": [ + { + "required": ["build"], + "not": {"required": ["image"]} + }, + { + "required": ["image"], + "not": {"anyOf": [ + {"required": ["build"]}, + {"required": ["dockerfile"]} + ]} + } + ] + } } } } diff --git a/compose/config/fields_schema_v1.json b/compose/config/fields_schema_v1.json deleted file mode 100644 index 8f6a8c0a..00000000 --- a/compose/config/fields_schema_v1.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "type": "object", - "id": "fields_schema_v1.json", - - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "service_schema_v1.json#/definitions/service" - } - }, - "additionalProperties": false -} diff --git a/compose/config/validation.py b/compose/config/validation.py index d7ca270c..07ec04ef 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -388,7 +388,7 @@ def process_errors(errors, path_prefix=None): def validate_against_config_schema(config_file): _validate_against_schema( config_file.config, - "service_schema_v{0}.json".format(config_file.version), + "config_schema_v{0}.json".format(config_file.version), format_checker=["ports", "expose", "bool-value-in-mapping"], filename=config_file.filename) diff --git a/docker-compose.spec b/docker-compose.spec index 4282400e..3a165dd6 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -18,13 +18,8 @@ exe = EXE(pyz, a.datas, [ ( - 'compose/config/fields_schema_v1.json', - 'compose/config/fields_schema_v1.json', - 'DATA' - ), - ( - 'compose/config/service_schema_v1.json', - 'compose/config/service_schema_v1.json', + 'compose/config/config_schema_v1.json', + 'compose/config/config_schema_v1.json', 'DATA' ), ( From 84a1822e407959bc4c75d4ed3b65c0444e8db885 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Nov 2015 13:19:27 -0500 Subject: [PATCH 163/580] Reduce complexity of _get_container_create_options Signed-off-by: Daniel Nephin --- compose/service.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index 01f17a12..dd3e8828 100644 --- a/compose/service.py +++ b/compose/service.py @@ -566,8 +566,7 @@ class Service(object): elif not container_options.get('name'): container_options['name'] = self.get_container_name(number, one_off) - if 'detach' not in container_options: - container_options['detach'] = True + container_options.setdefault('detach', True) # If a qualified hostname was given, split it into an # unqualified hostname and a domainname unless domainname @@ -581,16 +580,9 @@ class Service(object): container_options['domainname'] = parts[2] if 'ports' in container_options or 'expose' in self.options: - ports = [] - all_ports = container_options.get('ports', []) + self.options.get('expose', []) - for port_range in all_ports: - internal_range, _ = split_port(port_range) - for port in internal_range: - port = str(port) - if '/' in port: - port = tuple(port.split('/')) - ports.append(port) - container_options['ports'] = ports + container_options['ports'] = build_container_ports( + container_options, + self.options) container_options['environment'] = merge_environment( self.options.get('environment'), @@ -1031,3 +1023,18 @@ def format_environment(environment): return key return '{key}={value}'.format(key=key, value=value) return [format_env(*item) for item in environment.items()] + +# Ports + + +def build_container_ports(container_options, options): + ports = [] + all_ports = container_options.get('ports', []) + options.get('expose', []) + for port_range in all_ports: + internal_range, _ = split_port(port_range) + for port in internal_range: + port = str(port) + if '/' in port: + port = tuple(port.split('/')) + ports.append(port) + return ports From cdda616d6b21cd99bb2283009bfb066ee0b68767 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Nov 2015 13:26:15 -0500 Subject: [PATCH 164/580] Reduce complexity of sort_service_dicts. Signed-off-by: Daniel Nephin --- compose/config/sort_services.py | 35 ++++++++++++++++++--------------- tox.ini | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 9d29f329..20ac4461 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -23,28 +23,31 @@ def get_source_name_from_network_mode(network_mode, source_type): return net_name +def get_service_names(links): + return [link.split(':')[0] for link in links] + + +def get_service_names_from_volumes_from(volumes_from): + return [volume_from.source for volume_from in volumes_from] + + +def get_service_dependents(service_dict, services): + name = service_dict['name'] + return [ + service for service in services + if (name in get_service_names(service.get('links', [])) or + name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or + name == get_service_name_from_network_mode(service.get('network_mode')) or + name in service.get('depends_on', [])) + ] + + def sort_service_dicts(services): # Topological sort (Cormen/Tarjan algorithm). unmarked = services[:] temporary_marked = set() sorted_services = [] - def get_service_names(links): - return [link.split(':')[0] for link in links] - - def get_service_names_from_volumes_from(volumes_from): - return [volume_from.source for volume_from in volumes_from] - - def get_service_dependents(service_dict, services): - name = service_dict['name'] - return [ - service for service in services - if (name in get_service_names(service.get('links', [])) or - name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or - name == get_service_name_from_network_mode(service.get('network_mode')) or - name in service.get('depends_on', [])) - ] - def visit(n): if n['name'] in temporary_marked: if n['name'] in get_service_names(n.get('links', [])): diff --git a/tox.ini b/tox.ini index a18bfda7..7984775d 100644 --- a/tox.ini +++ b/tox.ini @@ -45,7 +45,7 @@ directory = coverage-html # Allow really long lines for now max-line-length = 140 # Set this high for now -max-complexity = 12 +max-complexity = 11 exclude = compose/packages [pytest] From 43ecf8793af1b1979c400634b31207684aa0ce8d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 16:06:32 -0500 Subject: [PATCH 165/580] Address old TODO, and small refactor of container name logic in service. Signed-off-by: Daniel Nephin --- compose/service.py | 19 ++++++++++--------- tests/integration/service_test.py | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/compose/service.py b/compose/service.py index dd3e8828..2fbea8d1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -162,11 +162,11 @@ class Service(object): - starts containers until there are at least `desired_num` running - removes all stopped containers """ - if self.custom_container_name() and desired_num > 1: + if self.custom_container_name and desired_num > 1: log.warn('The "%s" service is using the custom container name "%s". ' 'Docker requires each container to have a unique name. ' 'Remove the custom name to scale the service.' - % (self.name, self.custom_container_name())) + % (self.name, self.custom_container_name)) if self.specifies_host_port(): log.warn('The "%s" service specifies a port on the host. If multiple containers ' @@ -496,10 +496,6 @@ class Service(object): def get_volumes_from_names(self): return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)] - def get_container_name(self, number, one_off=False): - # TODO: Implement issue #652 here - return build_container_name(self.project, self.name, number, one_off) - # 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): @@ -561,9 +557,7 @@ class Service(object): for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) - if self.custom_container_name() and not one_off: - container_options['name'] = self.custom_container_name() - elif not container_options.get('name'): + if not container_options.get('name'): container_options['name'] = self.get_container_name(number, one_off) container_options.setdefault('detach', True) @@ -706,9 +700,16 @@ class Service(object): '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") ] + @property def custom_container_name(self): return self.options.get('container_name') + def get_container_name(self, number, one_off=False): + if self.custom_container_name and not one_off: + return self.custom_container_name + + return build_container_name(self.project, self.name, number, one_off) + def remove_image(self, image_type): if not image_type or image_type == ImageType.none: return False diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 968c0947..35696ea3 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -782,7 +782,7 @@ class ServiceTest(DockerClientTestCase): results in warning output. """ service = self.create_service('app', container_name='custom-container') - self.assertEqual(service.custom_container_name(), 'custom-container') + self.assertEqual(service.custom_container_name, 'custom-container') service.scale(3) @@ -963,7 +963,7 @@ class ServiceTest(DockerClientTestCase): def test_custom_container_name(self): service = self.create_service('web', container_name='my-web-container') - self.assertEqual(service.custom_container_name(), 'my-web-container') + self.assertEqual(service.custom_container_name, 'my-web-container') container = create_and_start_container(service) self.assertEqual(container.name, 'my-web-container') From dc3a5ce624fb0a0bf2d0c701aaa34df63b6fc5bd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 10 Feb 2016 16:05:15 -0500 Subject: [PATCH 166/580] Refactor config validation to support constraints in the same jsonschema Reworked the two schema validation functions to read from the same schema but use different parts of it. Error handling is now split as well by the schema that is being used to validate. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 +- compose/config/config_schema_v1.json | 4 +- compose/config/config_schema_v2.0.json | 4 +- compose/config/validation.py | 153 ++++++++++++------------- tests/unit/config/config_test.py | 19 ++- 5 files changed, 87 insertions(+), 95 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3994a332..850af31c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,8 +31,8 @@ from .types import ServiceLink from .types import VolumeFromSpec from .types import VolumeSpec from .validation import match_named_volumes -from .validation import validate_config_section from .validation import validate_against_config_schema +from .validation import validate_config_section from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_network_mode diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json index affc19f0..cde8c8e5 100644 --- a/compose/config/config_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -167,8 +167,8 @@ }, "constraints": { - "services": { - "id": "#/definitions/services/constraints", + "service": { + "id": "#/definitions/constraints/service", "anyOf": [ { "required": ["build"], diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index e8ceb4c2..54bfc978 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -312,8 +312,8 @@ }, "constraints": { - "services": { - "id": "#/definitions/services/constraints", + "service": { + "id": "#/definitions/constraints/service", "anyOf": [ {"required": ["build"]}, {"required": ["image"]} diff --git a/compose/config/validation.py b/compose/config/validation.py index 07ec04ef..4eafe7b5 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -14,6 +14,7 @@ from jsonschema import FormatChecker from jsonschema import RefResolver from jsonschema import ValidationError +from ..const import COMPOSEFILE_V1 as V1 from .errors import ConfigurationError from .errors import VERSION_EXPLANATION from .sort_services import get_service_name_from_network_mode @@ -209,7 +210,7 @@ def anglicize_json_type(json_type): def is_service_dict_schema(schema_id): - return schema_id == 'fields_schema_v1.json' or schema_id == '#/properties/services' + return schema_id in ('config_schema_v1.json', '#/properties/services') def handle_error_for_schema_with_id(error, path): @@ -221,35 +222,6 @@ def handle_error_for_schema_with_id(error, path): list(error.instance)[0], VALID_NAME_CHARS) - if schema_id == '#/definitions/constraints': - # Build context could in 'build' or 'build.context' and dockerfile could be - # in 'dockerfile' or 'build.dockerfile' - context = False - dockerfile = 'dockerfile' in error.instance - if 'build' in error.instance: - if isinstance(error.instance['build'], six.string_types): - context = True - else: - context = 'context' in error.instance['build'] - dockerfile = dockerfile or 'dockerfile' in error.instance['build'] - - # TODO: only applies to v1 - if 'image' in error.instance and context: - return ( - "{} has both an image and build path specified. " - "A service can either be built to image or use an existing " - "image, not both.".format(path_string(path))) - if 'image' not in error.instance and not context: - return ( - "{} has neither an image nor a build path specified. " - "At least one must be provided.".format(path_string(path))) - # TODO: only applies to v1 - if 'image' in error.instance and dockerfile: - return ( - "{} has both an image and alternate Dockerfile. " - "A service can either be built to image or use an existing " - "image, not both.".format(path_string(path))) - if error.validator == 'additionalProperties': if schema_id == '#/definitions/service': invalid_config_key = parse_key_from_error_msg(error) @@ -259,7 +231,7 @@ def handle_error_for_schema_with_id(error, path): return '{}\n{}'.format(error.message, VERSION_EXPLANATION) -def handle_generic_service_error(error, path): +def handle_generic_error(error, path): msg_format = None error_msg = error.message @@ -365,71 +337,94 @@ def _parse_oneof_validator(error): return (None, "contains an invalid type, it should be {}".format(valid_types)) -def process_errors(errors, path_prefix=None): - """jsonschema gives us an error tree full of information to explain what has - gone wrong. Process each error and pull out relevant information and re-write - helpful error messages that are relevant. - """ - path_prefix = path_prefix or [] +def process_service_constraint_errors(error, service_name, version): + if version == V1: + if 'image' in error.instance and 'build' in error.instance: + return ( + "Service {} has both an image and build path specified. " + "A service can either be built to image or use an existing " + "image, not both.".format(service_name)) - def format_error_message(error): - path = path_prefix + list(error.path) + if 'image' in error.instance and 'dockerfile' in error.instance: + return ( + "Service {} has both an image and alternate Dockerfile. " + "A service can either be built to image or use an existing " + "image, not both.".format(service_name)) - if 'id' in error.schema: - error_msg = handle_error_for_schema_with_id(error, path) - if error_msg: - return error_msg + if 'image' not in error.instance and 'build' not in error.instance: + return ( + "Service {} has neither an image nor a build context specified. " + "At least one must be provided.".format(service_name)) - return handle_generic_service_error(error, path) - return '\n'.join(format_error_message(error) for error in errors) +def process_config_schema_errors(error): + path = list(error.path) + + if 'id' in error.schema: + error_msg = handle_error_for_schema_with_id(error, path) + if error_msg: + return error_msg + + return handle_generic_error(error, path) def validate_against_config_schema(config_file): - _validate_against_schema( - config_file.config, - "config_schema_v{0}.json".format(config_file.version), - format_checker=["ports", "expose", "bool-value-in-mapping"], - filename=config_file.filename) + schema = load_jsonschema(config_file.version) + format_checker = FormatChecker(["ports", "expose", "bool-value-in-mapping"]) + validator = Draft4Validator( + schema, + resolver=RefResolver(get_resolver_path(), schema), + format_checker=format_checker) + handle_errors( + validator.iter_errors(config_file.config), + process_config_schema_errors, + config_file.filename) def validate_service_constraints(config, service_name, version): - # TODO: - pass + def handler(errors): + return process_service_constraint_errors(errors, service_name, version) + + schema = load_jsonschema(version) + validator = Draft4Validator(schema['definitions']['constraints']['service']) + handle_errors(validator.iter_errors(config), handler, None) -def _validate_against_schema( - config, - schema_filename, - format_checker=(), - path_prefix=None, - filename=None): - config_source_dir = os.path.dirname(os.path.abspath(__file__)) +def get_schema_path(): + return os.path.dirname(os.path.abspath(__file__)) + +def load_jsonschema(version): + filename = os.path.join( + get_schema_path(), + "config_schema_v{0}.json".format(version)) + + with open(filename, "r") as fh: + return json.load(fh) + + +def get_resolver_path(): + schema_path = get_schema_path() if sys.platform == "win32": - file_pre_fix = "///" - config_source_dir = config_source_dir.replace('\\', '/') + scheme = "///" + # TODO: why is this necessary? + schema_path = schema_path.replace('\\', '/') else: - file_pre_fix = "//" + scheme = "//" + return "file:{}{}/".format(scheme, schema_path) - resolver_full_path = "file:{}{}/".format(file_pre_fix, config_source_dir) - schema_file = os.path.join(config_source_dir, schema_filename) - with open(schema_file, "r") as schema_fh: - schema = json.load(schema_fh) - - resolver = RefResolver(resolver_full_path, schema) - validation_output = Draft4Validator( - schema, - resolver=resolver, - format_checker=FormatChecker(format_checker)) - - errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] +def handle_errors(errors, format_error_func, filename): + """jsonschema returns an error tree full of information to explain what has + gone wrong. Process each error and pull out relevant information and re-write + helpful error messages that are relevant. + """ + errors = list(sorted(errors, key=str)) if not errors: return - error_msg = process_errors(errors, path_prefix=path_prefix) - file_msg = " in file '{}'".format(filename) if filename else '' - raise ConfigurationError("Validation failed{}, reason(s):\n{}".format( - file_msg, - error_msg)) + error_msg = '\n'.join(format_error_func(error) for error in errors) + raise ConfigurationError( + "Validation failed{file_msg}, reason(s):\n{error_msg}".format( + file_msg=" in file '{}'".format(filename) if filename else "", + error_msg=error_msg)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 11bc7f0b..f8f224a0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -342,20 +342,17 @@ class ConfigTest(unittest.TestCase): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( - {invalid_name: {'image': 'busybox'}}, - 'working_dir', - 'filename.yml')) + {invalid_name: {'image': 'busybox'}})) assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() - def test_config_invalid_service_names_v2(self): + def test_load_config_invalid_service_names_v2(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: - config.load( - build_config_details({ + config.load(build_config_details( + { 'version': '2', - 'services': {invalid_name: {'image': 'busybox'}} - }, 'working_dir', 'filename.yml') - ) + 'services': {invalid_name: {'image': 'busybox'}}, + })) assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() def test_load_with_invalid_field_name(self): @@ -1317,7 +1314,7 @@ class ConfigTest(unittest.TestCase): }) with pytest.raises(ConfigurationError) as exc: config.load(config_details) - assert 'one.build is invalid, context is required.' in exc.exconly() + assert 'has neither an image nor a build context' in exc.exconly() class NetworkModeTest(unittest.TestCase): @@ -2269,7 +2266,7 @@ class ExtendsTest(unittest.TestCase): with pytest.raises(ConfigurationError) as exc: load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') assert ( - "myweb has neither an image nor a build path specified" in + "myweb has neither an image nor a build context specified" in exc.exconly() ) From a87d482a3bad04e211103c484b54580e243a9b4c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 16:27:43 -0500 Subject: [PATCH 167/580] Move all build scripts to script/build Signed-off-by: Daniel Nephin --- appveyor.yml | 2 +- project/RELEASE-PROCESS.md | 2 +- script/{build-image => build/image} | 0 script/{build-linux => build/linux} | 2 +- script/{build-linux-inner => build/linux-entrypoint} | 0 script/{build-osx => build/osx} | 0 script/{build-windows.ps1 => build/windows.ps1} | 2 +- script/ci | 2 +- script/release/build-binaries | 6 +++--- script/{test => test-default} | 0 script/travis/build-binary | 6 +++--- 11 files changed, 11 insertions(+), 11 deletions(-) rename script/{build-image => build/image} (100%) rename script/{build-linux => build/linux} (76%) rename script/{build-linux-inner => build/linux-entrypoint} (100%) rename script/{build-osx => build/osx} (100%) rename script/{build-windows.ps1 => build/windows.ps1} (97%) rename script/{test => test-default} (100%) diff --git a/appveyor.yml b/appveyor.yml index 489be021..e4f39544 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -11,7 +11,7 @@ build: false test_script: - "tox -e py27,py34 -- tests/unit" - - ps: ".\\script\\build-windows.ps1" + - ps: ".\\script\\build\\windows.ps1" artifacts: - path: .\dist\docker-compose-Windows-x86_64.exe diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 040a2602..14d93088 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -58,7 +58,7 @@ When prompted build the non-linux binaries and test them. 1. Build the Mac binary in a Mountain Lion VM: script/prepare-osx - script/build-osx + script/build/osx 2. Download the windows binary from AppVeyor diff --git a/script/build-image b/script/build/image similarity index 100% rename from script/build-image rename to script/build/image diff --git a/script/build-linux b/script/build/linux similarity index 76% rename from script/build-linux rename to script/build/linux index 47fb45e1..1a4cd4d9 100755 --- a/script/build-linux +++ b/script/build/linux @@ -7,7 +7,7 @@ set -ex TAG="docker-compose" docker build -t "$TAG" . | tail -n 200 docker run \ - --rm --entrypoint="script/build-linux-inner" \ + --rm --entrypoint="script/build/linux-entrypoint" \ -v $(pwd)/dist:/code/dist \ -v $(pwd)/.git:/code/.git \ "$TAG" diff --git a/script/build-linux-inner b/script/build/linux-entrypoint similarity index 100% rename from script/build-linux-inner rename to script/build/linux-entrypoint diff --git a/script/build-osx b/script/build/osx similarity index 100% rename from script/build-osx rename to script/build/osx diff --git a/script/build-windows.ps1 b/script/build/windows.ps1 similarity index 97% rename from script/build-windows.ps1 rename to script/build/windows.ps1 index 4a2bc1f7..db643274 100644 --- a/script/build-windows.ps1 +++ b/script/build/windows.ps1 @@ -26,7 +26,7 @@ # # 6. Build the binary: # -# .\script\build-windows.ps1 +# .\script\build\windows.ps1 $ErrorActionPreference = "Stop" diff --git a/script/ci b/script/ci index f30265c0..f73be842 100755 --- a/script/ci +++ b/script/ci @@ -18,4 +18,4 @@ GIT_VOLUME="--volumes-from=$(hostname)" . script/test-versions >&2 echo "Building Linux binary" -. script/build-linux-inner +. script/build/linux-entrypoint diff --git a/script/release/build-binaries b/script/release/build-binaries index 083f8eb5..3a57b89a 100755 --- a/script/release/build-binaries +++ b/script/release/build-binaries @@ -22,15 +22,15 @@ REPO=docker/compose # Build the binaries script/clean -script/build-linux +script/build/linux # TODO: build osx binary # script/prepare-osx -# script/build-osx +# script/build/osx # TODO: build or fetch the windows binary echo "You need to build the osx/windows binaries, that step is not automated yet." echo "Building the container distribution" -script/build-image $VERSION +script/build/image $VERSION echo "Create a github release" # TODO: script more of this https://developer.github.com/v3/repos/releases/ diff --git a/script/test b/script/test-default similarity index 100% rename from script/test rename to script/test-default diff --git a/script/travis/build-binary b/script/travis/build-binary index 7cc1092d..065244bf 100755 --- a/script/travis/build-binary +++ b/script/travis/build-binary @@ -3,11 +3,11 @@ set -ex if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - script/build-linux + script/build/linux # TODO: requires auth to push, so disable for now - # script/build-image master + # script/build/image master # docker push docker/compose:master else script/prepare-osx - script/build-osx + script/build/osx fi From ec6bb1660dde7e6700e894edb2235bdcf08a3a35 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 16:31:30 -0500 Subject: [PATCH 168/580] Move run scripts to script/run Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 2 +- script/dev | 21 --------------------- script/release/make-branch | 4 ++-- script/{ => run}/run.ps1 | 0 script/{ => run}/run.sh | 0 script/shell | 4 ---- 6 files changed, 3 insertions(+), 28 deletions(-) delete mode 100755 script/dev rename script/{ => run}/run.ps1 (100%) rename script/{ => run}/run.sh (100%) delete mode 100755 script/shell diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 14d93088..4b78a9ba 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -88,7 +88,7 @@ When prompted build the non-linux binaries and test them. ...release notes go here... -5. Attach the binaries and `script/run.sh` +5. Attach the binaries and `script/run/run.sh` 6. Add "Thanks" with a list of contributors. The contributor list can be generated by running `./script/release/contributors`. diff --git a/script/dev b/script/dev deleted file mode 100755 index 80b3d013..00000000 --- a/script/dev +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# This is a script for running Compose inside a Docker container. It's handy for -# development. -# -# $ ln -s `pwd`/script/dev /usr/local/bin/docker-compose -# $ cd /a/compose/project -# $ docker-compose up -# - -set -e - -# Follow symbolic links -if [ -h "$0" ]; then - DIR=$(readlink "$0") -else - DIR=$0 -fi -DIR="$(dirname "$DIR")"/.. - -docker build -t docker-compose $DIR -exec docker run -i -t -v /var/run/docker.sock:/var/run/docker.sock -v `pwd`:`pwd` -w `pwd` docker-compose $@ diff --git a/script/release/make-branch b/script/release/make-branch index 46ba6bbc..86b4c9f6 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -65,10 +65,10 @@ git config "branch.${BRANCH}.release" $VERSION editor=${EDITOR:-vim} -echo "Update versions in docs/install.md, compose/__init__.py, script/run.sh" +echo "Update versions in docs/install.md, compose/__init__.py, script/run/run.sh" $editor docs/install.md $editor compose/__init__.py -$editor script/run.sh +$editor script/run/run.sh echo "Write release notes in CHANGELOG.md" diff --git a/script/run.ps1 b/script/run/run.ps1 similarity index 100% rename from script/run.ps1 rename to script/run/run.ps1 diff --git a/script/run.sh b/script/run/run.sh similarity index 100% rename from script/run.sh rename to script/run/run.sh diff --git a/script/shell b/script/shell deleted file mode 100755 index 903be76f..00000000 --- a/script/shell +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -set -ex -docker build -t docker-compose . -exec docker run -v /var/run/docker.sock:/var/run/docker.sock -v `pwd`:/code -ti --rm --entrypoint bash docker-compose From 11dc7207520be98f70ac38e000c8a90975908e39 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 16:35:26 -0500 Subject: [PATCH 169/580] Move test scripts to script/test. Signed-off-by: Daniel Nephin --- CONTRIBUTING.md | 14 +++++++------- project/RELEASE-PROCESS.md | 2 +- script/build/image | 2 +- script/build/linux-entrypoint | 2 +- script/build/osx | 2 +- script/{ => build}/write-git-sha | 0 script/ci | 25 ++++++------------------- script/release/build-binaries | 2 +- script/release/push-release | 2 +- script/{prepare-osx => setup/osx} | 0 script/{test-versions => test/all} | 2 +- script/test/ci | 25 +++++++++++++++++++++++++ script/{test-default => test/default} | 2 +- script/{ => test}/versions.py | 0 script/travis/build-binary | 2 +- 15 files changed, 47 insertions(+), 35 deletions(-) rename script/{ => build}/write-git-sha (100%) rename script/{prepare-osx => setup/osx} (100%) rename script/{test-versions => test/all} (96%) create mode 100755 script/test/ci rename script/{test-default => test/default} (92%) rename script/{ => test}/versions.py (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66224752..50e58ddc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,22 +50,22 @@ See Docker's [basic contribution workflow](https://docs.docker.com/opensource/wo Use the test script to run linting checks and then the full test suite against different Python interpreters: - $ script/test + $ script/test/default Tests are run against a Docker daemon inside a container, so that we can test against multiple Docker versions. By default they'll run against only the latest Docker version - set the `DOCKER_VERSIONS` environment variable to "all" to run against all supported versions: - $ DOCKER_VERSIONS=all script/test + $ DOCKER_VERSIONS=all script/test/default -Arguments to `script/test` are passed through to the `nosetests` executable, so +Arguments to `script/test/default` are passed through to the `tox` executable, so you can specify a test directory, file, module, class or method: - $ script/test tests/unit - $ script/test tests/unit/cli_test.py - $ script/test tests/unit/config_test.py::ConfigTest - $ script/test tests/unit/config_test.py::ConfigTest::test_load + $ script/test/default tests/unit + $ script/test/default tests/unit/cli_test.py + $ script/test/default tests/unit/config_test.py::ConfigTest + $ script/test/default tests/unit/config_test.py::ConfigTest::test_load ## Finding things to work on diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 4b78a9ba..de94aae8 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -57,7 +57,7 @@ When prompted build the non-linux binaries and test them. 1. Build the Mac binary in a Mountain Lion VM: - script/prepare-osx + script/setup/osx script/build/osx 2. Download the windows binary from AppVeyor diff --git a/script/build/image b/script/build/image index 89733505..bdd98f03 100755 --- a/script/build/image +++ b/script/build/image @@ -10,7 +10,7 @@ fi TAG=$1 VERSION="$(python setup.py --version)" -./script/write-git-sha +./script/build/write-git-sha python setup.py sdist cp dist/docker-compose-$VERSION.tar.gz dist/docker-compose-release.tar.gz docker build -t docker/compose:$TAG -f Dockerfile.run . diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index 9bf7c95d..bf515060 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -9,7 +9,7 @@ mkdir -p `pwd`/dist chmod 777 `pwd`/dist $VENV/bin/pip install -q -r requirements-build.txt -./script/write-git-sha +./script/build/write-git-sha su -c "$VENV/bin/pyinstaller docker-compose.spec" user mv dist/docker-compose $TARGET $TARGET version diff --git a/script/build/osx b/script/build/osx index 168fd430..3de34576 100755 --- a/script/build/osx +++ b/script/build/osx @@ -9,7 +9,7 @@ virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-build.txt venv/bin/pip install --no-deps . -./script/write-git-sha +./script/build/write-git-sha venv/bin/pyinstaller docker-compose.spec mv dist/docker-compose dist/docker-compose-Darwin-x86_64 dist/docker-compose-Darwin-x86_64 version diff --git a/script/write-git-sha b/script/build/write-git-sha similarity index 100% rename from script/write-git-sha rename to script/build/write-git-sha diff --git a/script/ci b/script/ci index f73be842..7b3489a1 100755 --- a/script/ci +++ b/script/ci @@ -1,21 +1,8 @@ #!/bin/bash -# This should be run inside a container built from the Dockerfile -# at the root of the repo: # -# $ TAG="docker-compose:$(git rev-parse --short HEAD)" -# $ docker build -t "$TAG" . -# $ docker run --rm --volume="/var/run/docker.sock:/var/run/docker.sock" --volume="$(pwd)/.git:/code/.git" -e "TAG=$TAG" --entrypoint="script/ci" "$TAG" - -set -ex - -docker version - -export DOCKER_VERSIONS=all -STORAGE_DRIVER=${STORAGE_DRIVER:-overlay} -export DOCKER_DAEMON_ARGS="--storage-driver=$STORAGE_DRIVER" - -GIT_VOLUME="--volumes-from=$(hostname)" -. script/test-versions - ->&2 echo "Building Linux binary" -. script/build/linux-entrypoint +# Backwards compatiblity for jenkins +# +# TODO: remove this script after all current PRs and jenkins are updated with +# the new script/test/ci change +set -e +exec script/test/ci diff --git a/script/release/build-binaries b/script/release/build-binaries index 3a57b89a..d076197c 100755 --- a/script/release/build-binaries +++ b/script/release/build-binaries @@ -24,7 +24,7 @@ REPO=docker/compose script/clean script/build/linux # TODO: build osx binary -# script/prepare-osx +# script/setup/osx # script/build/osx # TODO: build or fetch the windows binary echo "You need to build the osx/windows binaries, that step is not automated yet." diff --git a/script/release/push-release b/script/release/push-release index 7d9ec0a2..33d0d777 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -57,7 +57,7 @@ docker push docker/compose:$VERSION echo "Uploading sdist to pypi" pandoc -f markdown -t rst README.md -o README.rst sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst -./script/write-git-sha +./script/build/write-git-sha python setup.py sdist if [ "$(command -v twine 2> /dev/null)" ]; then twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz diff --git a/script/prepare-osx b/script/setup/osx similarity index 100% rename from script/prepare-osx rename to script/setup/osx diff --git a/script/test-versions b/script/test/all similarity index 96% rename from script/test-versions rename to script/test/all index 14a3e6e4..08bf1618 100755 --- a/script/test-versions +++ b/script/test/all @@ -14,7 +14,7 @@ docker run --rm \ get_versions="docker run --rm --entrypoint=/code/.tox/py27/bin/python $TAG - /code/script/versions.py docker/docker" + /code/script/test/versions.py docker/docker" if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$($get_versions default)" diff --git a/script/test/ci b/script/test/ci new file mode 100755 index 00000000..c5927b2c --- /dev/null +++ b/script/test/ci @@ -0,0 +1,25 @@ +#!/bin/bash +# This should be run inside a container built from the Dockerfile +# at the root of the repo: +# +# $ TAG="docker-compose:$(git rev-parse --short HEAD)" +# $ docker build -t "$TAG" . +# $ docker run --rm \ +# --volume="/var/run/docker.sock:/var/run/docker.sock" \ +# --volume="$(pwd)/.git:/code/.git" \ +# -e "TAG=$TAG" \ +# --entrypoint="script/test/ci" "$TAG" + +set -ex + +docker version + +export DOCKER_VERSIONS=all +STORAGE_DRIVER=${STORAGE_DRIVER:-overlay} +export DOCKER_DAEMON_ARGS="--storage-driver=$STORAGE_DRIVER" + +GIT_VOLUME="--volumes-from=$(hostname)" +. script/test/all + +>&2 echo "Building Linux binary" +. script/build/linux-entrypoint diff --git a/script/test-default b/script/test/default similarity index 92% rename from script/test-default rename to script/test/default index bdb3579b..fa741a19 100755 --- a/script/test-default +++ b/script/test/default @@ -12,4 +12,4 @@ mkdir -p coverage-html docker build -t "$TAG" . GIT_VOLUME="--volume=$(pwd)/.git:/code/.git" -. script/test-versions +. script/test/all diff --git a/script/versions.py b/script/test/versions.py similarity index 100% rename from script/versions.py rename to script/test/versions.py diff --git a/script/travis/build-binary b/script/travis/build-binary index 065244bf..7707a1ee 100755 --- a/script/travis/build-binary +++ b/script/travis/build-binary @@ -8,6 +8,6 @@ if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then # script/build/image master # docker push docker/compose:master else - script/prepare-osx + script/setup/osx script/build/osx fi From 2cd1b94dd3a16688e8be2442c35ac1f03d62cacb Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 25 Feb 2016 15:15:18 -0800 Subject: [PATCH 170/580] Update note about build + image with extends Signed-off-by: Aanand Prasad --- docs/extends.md | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index bceb0257..9ecccd8a 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -290,31 +290,17 @@ replaces the old value. # result command: python otherapp.py -In the case of `build` and `image`, using one in the local service causes -Compose to discard the other, if it was defined in the original service. - -Example of image replacing build: - - # original service - build: . - - # local service - image: redis - - # result - image: redis - - -Example of build replacing image: - - # original service - image: redis - - # local service - build: . - - # result - build: . +> **Note:** In the case of `build` and `image`, when using +> [version 1 of the Compose file format](compose-file.md#version-1), using one +> option in the local service causes Compose to discard the other option if it +> was defined in the original service. +> +> For example, if the original service defines `image: webapp` and the +> local service defines `build: .` then the resulting service will have +> `build: .` and no `image` option. +> +> This is because `build` and `image` cannot be used together in a version 1 +> file. For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and `dns_search`, Compose concatenates both sets of values: From 5cc420e72739a576f18703966ebef4c160e2064c Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Thu, 25 Feb 2016 16:47:46 -0800 Subject: [PATCH 171/580] WIP: updated note format for dockerfile per Aanand's comments on PR #3019 Signed-off-by: Victoria Bialas --- docs/compose-file.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 514e6a03..ad48b29f 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -95,13 +95,13 @@ specified. > **Note**: In the [version 1 file format](#version-1), `dockerfile` is > different in two ways: -> -> - It appears alongside `build`, not as a sub-option: -> -> build: . -> dockerfile: Dockerfile-alternate -> - Using `dockerfile` together with `image` is not allowed. Attempting to do -> so results in an error. + + * It appears alongside `build`, not as a sub-option: + + build: . + dockerfile: Dockerfile-alternate + + * Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. #### args From 5be48ba1eddd6c61a54b72ae5e8b88009c02770f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 25 Feb 2016 17:19:58 -0800 Subject: [PATCH 172/580] Update docs about using build and image together Signed-off-by: Aanand Prasad --- docs/compose-file.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 514e6a03..1323131e 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -59,6 +59,14 @@ optionally [dockerfile](#dockerfile) and [args](#args). args: buildno: 1 +If you specify `image` as well as `build`, then Compose tags the built image +with the tag specified in `image`: + + build: ./dir + image: webapp + +This will result in an image tagged `webapp`, built from `./dir`. + > **Note**: In the [version 1 file format](#version-1), `build` is different in > two ways: > @@ -340,13 +348,22 @@ An entry with the ip address and hostname will be created in `/etc/hosts` inside ### image -Tag or partial image ID. Can be local or remote - Compose will attempt to -pull if it doesn't exist locally. +Specify the image to start the container from. Can either be a repository/tag or +a partial image ID. - image: ubuntu - image: orchardup/postgresql + image: redis + image: ubuntu:14.04 + image: tutum/influxdb + image: example-registry.com:4000/postgresql image: a4bc65fd +If the image does not exist, Compose attempts to pull it, unless you have also +specified [build](#build), in which case it builds it using the specified +options and tags it with the specified tag. + +> **Note**: In the [version 1 file format](#version-1), using `build` together +> with `image` is not allowed. Attempting to do so results in an error. + ### labels Add metadata to containers using [Docker labels](https://docs.docker.com/engine/userguide/labels-custom-metadata/). You can use either an array or a dictionary. From c72e9b3843c2a286e6478dde445fe3de99d88239 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 23 Feb 2016 15:50:59 -0800 Subject: [PATCH 173/580] Add release notes for 1.6.2 Signed-off-by: Aanand Prasad --- CHANGELOG.md | 6 ++++++ docs/install.md | 6 +++--- script/run/run.sh | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d553cc2..8b93087f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ Change log ========== +1.6.2 (2016-02-23) +------------------ + +- Fixed a bug where connecting to a TLS-enabled Docker Engine would fail with + a certificate verification error. + 1.6.1 (2016-02-23) ------------------ diff --git a/docs/install.md b/docs/install.md index b7607a67..eee0c203 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.6.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.6.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.6.1 + docker-compose version: 1.6.2 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.6.1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.6.2/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run/run.sh b/script/run/run.sh index 3e30dd15..212f9b97 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.6.1" +VERSION="1.6.2" IMAGE="docker/compose:$VERSION" From 62fb6b99ebd508497766dcc763ae3b0c5d26fdf8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 26 Feb 2016 11:26:56 -0800 Subject: [PATCH 174/580] Update FAQ regarding long stop times - It happens on recreate, not just stop - We now support `stop_signal`, which can help in some cases Signed-off-by: Aanand Prasad --- docs/faq.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 73596c18..a42243fc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -15,7 +15,7 @@ weight=90 If you don’t see your question here, feel free to drop by `#docker-compose` on freenode IRC and ask the community. -## Why do my services take 10 seconds to stop? +## Why do my services take 10 seconds to recreate or stop? Compose stop attempts to stop a container by sending a `SIGTERM`. It then waits for a [default timeout of 10 seconds](./reference/stop.md). After the timeout, @@ -40,6 +40,12 @@ in your Dockerfile. * If you are able, modify the application that you're running to add an explicit signal handler for `SIGTERM`. +* Set the `stop_signal` to a signal which the application knows how to handle: + + web: + build: . + stop_signal: SIGINT + * If you can't modify the application, wrap the application in a lightweight init system (like [s6](http://skarnet.org/software/s6/)) or a signal proxy (like [dumb-init](https://github.com/Yelp/dumb-init) or From d28c5dda9294d1f6824207aba7ac210e1e3fc3f8 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Thu, 10 Sep 2015 13:17:55 +0200 Subject: [PATCH 175/580] implement exec Resolves #593 Signed-off-by: Tomas Tomecek --- compose/cli/docopt_command.py | 4 +++ compose/cli/main.py | 54 ++++++++++++++++++++++++++++++++++- compose/container.py | 6 ++++ docs/reference/exec.md | 29 +++++++++++++++++++ tests/acceptance/cli_test.py | 18 ++++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 docs/reference/exec.md diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index e3f4aa9e..d2900b39 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -43,6 +43,10 @@ class DocoptCommand(object): def get_handler(self, command): command = command.replace('-', '_') + # we certainly want to have "exec" command, since that's what docker client has + # but in python exec is a keyword + if command == "exec": + command = "exec_command" if not hasattr(self, command): raise NoSuchCommand(command, self) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6a04f9f0..2c0fda1c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -43,7 +43,7 @@ from .utils import yesno if not IS_WINDOWS_PLATFORM: - from dockerpty.pty import PseudoTerminal, RunOperation + from dockerpty.pty import PseudoTerminal, RunOperation, ExecOperation log = logging.getLogger(__name__) console_handler = logging.StreamHandler(sys.stderr) @@ -152,6 +152,7 @@ class TopLevelCommand(DocoptCommand): create Create services down Stop and remove containers, networks, images, and volumes events Receive real time events from containers + exec Execute a command in a running container help Get help on a command kill Kill containers logs View output from containers @@ -298,6 +299,57 @@ class TopLevelCommand(DocoptCommand): print(formatter(event)) sys.stdout.flush() + def exec_command(self, project, options): + """ + Execute a command in a running container + + Usage: exec [options] SERVICE COMMAND [ARGS...] + + Options: + -d Detached mode: Run command in the background. + --privileged Give extended privileges to the process. + --user USER Run the command as this user. + -T Disable pseudo-tty allocation. By default `docker-compose exec` + allocates a TTY. + --index=index index of the container if there are multiple + instances of a service [default: 1] + """ + index = int(options.get('--index')) + service = project.get_service(options['SERVICE']) + try: + container = service.get_container(number=index) + except ValueError as e: + raise UserError(str(e)) + command = [options['COMMAND']] + options['ARGS'] + tty = not options["-T"] + + create_exec_options = { + "privileged": options["--privileged"], + "user": options["--user"], + "tty": tty, + "stdin": tty, + } + + exec_id = container.create_exec(command, **create_exec_options) + + if options['-d']: + container.start_exec(exec_id, tty=tty) + return + + signals.set_signal_handler_to_shutdown() + try: + operation = ExecOperation( + project.client, + exec_id, + interactive=tty, + ) + pty = PseudoTerminal(project.client, operation) + pty.start() + except signals.ShutdownException: + log.info("received shutdown exception: closing") + exit_code = project.client.exec_inspect(exec_id).get("ExitCode") + sys.exit(exit_code) + def help(self, project, options): """ Get help on a command. diff --git a/compose/container.py b/compose/container.py index c96b63ef..6dac9499 100644 --- a/compose/container.py +++ b/compose/container.py @@ -216,6 +216,12 @@ class Container(object): def remove(self, **options): return self.client.remove_container(self.id, **options) + def create_exec(self, command, **options): + return self.client.exec_create(self.id, command, **options) + + def start_exec(self, exec_id, **options): + return self.client.exec_start(exec_id, **options) + def rename_to_tmp_name(self): """Rename the container to a hopefully unique temporary container name by prepending the short id. diff --git a/docs/reference/exec.md b/docs/reference/exec.md new file mode 100644 index 00000000..6c0eeb04 --- /dev/null +++ b/docs/reference/exec.md @@ -0,0 +1,29 @@ + + +# exec + +``` +Usage: exec [options] SERVICE COMMAND [ARGS...] + +Options: +-d Detached mode: Run command in the background. +--privileged Give extended privileges to the process. +--user USER Run the command as this user. +-T Disable pseudo-tty allocation. By default `docker-compose exec` + allocates a TTY. +--index=index index of the container if there are multiple + instances of a service [default: 1] +``` + +This is equivalent of `docker exec`. With this subcommand you can run arbitrary +commands in your services. Commands are by default allocating a TTY, so you can +do e.g. `docker-compose exec web sh` to get an interactive prompt. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 02f82872..2b61898e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -752,6 +752,24 @@ class CLITestCase(DockerClientTestCase): self.project.stop(['simple']) wait_on_condition(ContainerCountCondition(self.project, 0)) + def test_exec_without_tty(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', 'console']) + self.assertEqual(len(self.project.containers()), 1) + + stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) + self.assertEquals(stdout, "/\n") + self.assertEquals(stderr, "") + + def test_exec_custom_user(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', 'console']) + self.assertEqual(len(self.project.containers()), 1) + + stdout, stderr = self.dispatch(['exec', '-T', '--user=operator', 'console', 'whoami']) + self.assertEquals(stdout, "operator\n") + self.assertEquals(stderr, "") + def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', 'console', '/bin/true']) From 6bfb23baaa45b1ab4bd8c4b1cbce2b49753643d1 Mon Sep 17 00:00:00 2001 From: Jesus Date: Sat, 27 Feb 2016 18:25:54 +0100 Subject: [PATCH 176/580] Display containers name when scale a container Display in the log output the name of those containers created using the scale command and change the test_scale_with_api_error test to support the containers name when scale Signed-off-by: Jesus Rodriguez Tinoco --- compose/service.py | 2 +- tests/integration/service_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index e1b0c916..a24b9733 100644 --- a/compose/service.py +++ b/compose/service.py @@ -223,7 +223,7 @@ class Service(object): parallel_execute( container_numbers, lambda n: create_and_start(service=self, number=n), - lambda n: n, + lambda n: self.get_container_name(n), "Creating and starting" ) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4bb625a1..6d0c97db 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -735,7 +735,7 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.assertIn("ERROR: for 2 Boom", mock_stderr.getvalue()) + self.assertIn("ERROR: for composetest_web_2 Boom", mock_stderr.getvalue()) def test_scale_with_unexpected_exception(self): """Test that when scaling if the API returns an error, that is not of type From 6b947ee478458927e119b6b2d9129f3ef62ab883 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 26 Feb 2016 11:22:41 -0800 Subject: [PATCH 177/580] Document ways to make services wait for dependencies Signed-off-by: Aanand Prasad --- docs/faq.md | 94 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index a42243fc..201549f1 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -15,6 +15,76 @@ weight=90 If you don’t see your question here, feel free to drop by `#docker-compose` on freenode IRC and ask the community. + +## How do I control the order of service startup? I need my database to be ready before my application starts. + +You can control the order of service startup with the +[depends_on](compose-file.md#depends-on) option. Compose always starts +containers in dependency order, where dependencies are determined by +`depends_on`, `links`, `volumes_from` and `network_mode: "service:..."`. + +However, Compose will not wait until a container is "ready" (whatever that means +for your particular application) - only until it's running. There's a good +reason for this. + +The problem of waiting for a database to be ready is really just a subset of a +much larger problem of distributed systems. In production, your database could +become unavailable or move hosts at any time. Your application needs to be +resilient to these types of failures. + +To handle this, your application should attempt to re-establish a connection to +the database after a failure. If the application retries the connection, +it should eventually be able to connect to the database. + +The best solution is to perform this check in your application code, both at +startup and whenever a connection is lost for any reason. However, if you don't +need this level of resilience, you can work around the problem with a wrapper +script: + +- Use a tool such as [wait-for-it](https://github.com/vishnubob/wait-for-it) + or [dockerize](https://github.com/jwilder/dockerize). These are small + wrapper scripts which you can include in your application's image and will + poll a given host and port until it's accepting TCP connections. + + Supposing your application's image has a `CMD` set in its Dockerfile, you + can wrap it by setting the entrypoint in `docker-compose.yml`: + + version: "2" + services: + web: + build: . + ports: + - "80:8000" + depends_on: + - "db" + entrypoint: ./wait-for-it.sh db:5432 + db: + image: postgres + +- Write your own wrapper script to perform a more application-specific health + check. For example, you might want to wait until Postgres is definitely + ready to accept commands: + + #!/bin/bash + + set -e + + host="$1" + shift + cmd="$@" + + until psql -h "$host" -U "postgres" -c '\l'; do + >&2 echo "Postgres is unavailable - sleeping" + sleep 1 + done + + >&2 echo "Postgres is up - executing command" + exec $cmd + + You can use this as a wrapper script as in the previous example, by setting + `entrypoint: ./wait-for-postgres.sh db`. + + ## Why do my services take 10 seconds to recreate or stop? Compose stop attempts to stop a container by sending a `SIGTERM`. It then waits @@ -90,30 +160,6 @@ specify the filename to use, for example: docker-compose -f docker-compose.json up ``` -## How do I get Compose to wait for my database to be ready before starting my application? - -Unfortunately, Compose won't do that for you but for a good reason. - -The problem of waiting for a database to be ready is really just a subset of a -much larger problem of distributed systems. In production, your database could -become unavailable or move hosts at any time. The application needs to be -resilient to these types of failures. - -To handle this, the application would attempt to re-establish a connection to -the database after a failure. If the application retries the connection, -it should eventually be able to connect to the database. - -To wait for the application to be in a good state, you can implement a -healthcheck. A healthcheck makes a request to the application and checks -the response for a success status code. If it is not successful it waits -for a short period of time, and tries again. After some timeout value, the check -stops trying and report a failure. - -If you need to run tests against your application, you can start by running a -healthcheck. Once the healthcheck gets a successful response, you can start -running your tests. - - ## Should I include my code with `COPY`/`ADD` or a volume? You can add your code to the image using `COPY` or `ADD` directive in a From 04877d47aa35e08544a52d374d7b654954e8e2cc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 29 Feb 2016 14:36:21 -0800 Subject: [PATCH 178/580] Build osx binary on travis and upload to bintray. This requires a change to the make-branch script, to have it push the bump branch to the docker remote instead of the user remote. Pushing to the docker remote triggers the travis build, which builds the binary. Signed-off-by: Daniel Nephin --- .travis.yml | 2 ++ project/RELEASE-PROCESS.md | 6 +++--- script/release/build-binaries | 10 +++++----- script/release/make-branch | 20 +++----------------- script/travis/bintray.json.tmpl | 6 +++--- 5 files changed, 16 insertions(+), 28 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3bb365a1..fbf26964 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,3 +25,5 @@ deploy: key: '$BINTRAY_API_KEY' file: ./bintray.json skip_cleanup: true + on: + all_branches: true diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index de94aae8..930af15a 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -55,10 +55,10 @@ Check out the bump branch and run the `build-binaries` script When prompted build the non-linux binaries and test them. -1. Build the Mac binary in a Mountain Lion VM: +1. Download the osx binary from Bintray. Make sure that the latest build has + finished, otherwise you'll be downloading an old binary. - script/setup/osx - script/build/osx + https://dl.bintray.com/docker-compose/$BRANCH_NAME/ 2. Download the windows binary from AppVeyor diff --git a/script/release/build-binaries b/script/release/build-binaries index d076197c..9d4a606e 100755 --- a/script/release/build-binaries +++ b/script/release/build-binaries @@ -23,11 +23,6 @@ REPO=docker/compose # Build the binaries script/clean script/build/linux -# TODO: build osx binary -# script/setup/osx -# script/build/osx -# TODO: build or fetch the windows binary -echo "You need to build the osx/windows binaries, that step is not automated yet." echo "Building the container distribution" script/build/image $VERSION @@ -35,3 +30,8 @@ script/build/image $VERSION echo "Create a github release" # TODO: script more of this https://developer.github.com/v3/repos/releases/ browser https://github.com/$REPO/releases/new + +echo "Don't forget to download the osx and windows binaries from appveyor/bintray\!" +echo "https://dl.bintray.com/docker-compose/$BRANCH/" +echo "https://ci.appveyor.com/project/docker/compose" +echo diff --git a/script/release/make-branch b/script/release/make-branch index 86b4c9f6..7ccf3f05 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -82,20 +82,6 @@ $SHELL || true git commit -a -m "Bump $VERSION" --signoff --no-verify -echo "Push branch to user remote" -GITHUB_USER=$USER -USER_REMOTE="$(find_remote $GITHUB_USER/compose)" -if [ -z "$USER_REMOTE" ]; then - echo "$GITHUB_USER/compose not found" - read -r -p "Enter the name of your GitHub fork (username/repo): " GITHUB_REPO - # assumes there is already a user remote somewhere - USER_REMOTE=$(find_remote $GITHUB_REPO) -fi -if [ -z "$USER_REMOTE" ]; then - >&2 echo "No user remote found. You need to 'git push' your branch." - exit 2 -fi - - -git push $USER_REMOTE -browser https://github.com/$REPO/compare/docker:release...$GITHUB_USER:$BRANCH?expand=1 +echo "Push branch to docker remote" +git push $REMOTE +browser https://github.com/$REPO/compare/docker:release...$BRANCH?expand=1 diff --git a/script/travis/bintray.json.tmpl b/script/travis/bintray.json.tmpl index 7d0adbeb..f9728558 100644 --- a/script/travis/bintray.json.tmpl +++ b/script/travis/bintray.json.tmpl @@ -1,7 +1,7 @@ { "package": { "name": "${TRAVIS_OS_NAME}", - "repo": "master", + "repo": "${TRAVIS_BRANCH}", "subject": "docker-compose", "desc": "Automated build of master branch from travis ci.", "website_url": "https://github.com/docker/compose", @@ -11,8 +11,8 @@ }, "version": { - "name": "master", - "desc": "Automated build of the master branch.", + "name": "${TRAVIS_BRANCH}", + "desc": "Automated build of the ${TRAVIS_BRANCH} branch.", "released": "${DATE}", "vcs_tag": "master" }, From b726f508a62b58c718b3568a51200224a613eed4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Mar 2016 11:42:19 -0500 Subject: [PATCH 179/580] Fix merging of logging options in v1 config. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 ++ tests/unit/config/config_test.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 850af31c..f34809a9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -88,6 +88,8 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', 'dockerfile', + 'log_driver', + 'log_opt', 'logging', 'network_mode', ] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c2ca8e6e..420db60b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1248,6 +1248,24 @@ class ConfigTest(unittest.TestCase): } } + def test_merge_logging_v1(self): + base = { + 'image': 'alpine:edge', + 'log_driver': 'something', + 'log_opt': {'foo': 'three'}, + } + override = { + 'image': 'alpine:edge', + 'command': 'true', + } + actual = config.merge_service_dicts(base, override, V1) + assert actual == { + 'image': 'alpine:edge', + 'log_driver': 'something', + 'log_opt': {'foo': 'three'}, + 'command': 'true', + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 18510b4024518f41be7afdd114c898a75ac97480 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Mar 2016 11:57:35 -0500 Subject: [PATCH 180/580] Don't allow booleans for mapping types. Signed-off-by: Daniel Nephin --- compose/config/config_schema_v1.json | 3 +-- compose/config/config_schema_v2.0.json | 3 +-- compose/config/validation.py | 19 +------------------ tests/unit/config/config_test.py | 26 +++++++++++--------------- 4 files changed, 14 insertions(+), 37 deletions(-) diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json index cde8c8e5..36a93793 100644 --- a/compose/config/config_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -156,8 +156,7 @@ "type": "object", "patternProperties": { ".+": { - "type": ["string", "number", "boolean", "null"], - "format": "bool-value-in-mapping" + "type": ["string", "number", "null"] } }, "additionalProperties": false diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 54bfc978..28209ced 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -301,8 +301,7 @@ "type": "object", "patternProperties": { ".+": { - "type": ["string", "number", "boolean", "null"], - "format": "bool-value-in-mapping" + "type": ["string", "number", "null"] } }, "additionalProperties": false diff --git a/compose/config/validation.py b/compose/config/validation.py index 4eafe7b5..088bec3f 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -63,23 +63,6 @@ def format_expose(instance): return True -@FormatChecker.cls_checks(format="bool-value-in-mapping") -def format_boolean_in_environment(instance): - """Check if there is a boolean in the mapping sections and display a warning. - Always return True here so the validation won't raise an error. - """ - if isinstance(instance, bool): - log.warn( - "There is a boolean value in the 'environment', 'labels', or " - "'extra_hosts' field of a service.\n" - "These sections only support string values.\n" - "Please add quotes to any boolean values to make them strings " - "(eg, 'True', 'false', 'yes', 'N', 'on', 'Off').\n" - "This warning will become an error in a future release. \r\n" - ) - return True - - def match_named_volumes(service_dict, project_volumes): service_volumes = service_dict.get('volumes', []) for volume_spec in service_volumes: @@ -370,7 +353,7 @@ def process_config_schema_errors(error): def validate_against_config_schema(config_file): schema = load_jsonschema(config_file.version) - format_checker = FormatChecker(["ports", "expose", "bool-value-in-mapping"]) + format_checker = FormatChecker(["ports", "expose"]) validator = Draft4Validator( schema, resolver=RefResolver(get_resolver_path(), schema), diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c2ca8e6e..8a523fea 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1095,22 +1095,18 @@ class ConfigTest(unittest.TestCase): ).services self.assertEqual(service[0]['entrypoint'], entrypoint) - @mock.patch('compose.config.validation.log') - def test_logs_warning_for_boolean_in_environment(self, mock_logging): - expected_warning_msg = "There is a boolean value in the 'environment'" - config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'environment': {'SHOW_STUFF': True} - }}, - 'working_dir', - 'filename.yml' - ) - ) + def test_logs_warning_for_boolean_in_environment(self): + config_details = build_config_details({ + 'web': { + 'image': 'busybox', + 'environment': {'SHOW_STUFF': True} + } + }) - assert mock_logging.warn.called - assert expected_warning_msg in mock_logging.warn.call_args[0][0] + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + + assert "contains true, which is an invalid type" in exc.exconly() def test_config_valid_environment_dict_key_contains_dashes(self): services = config.load( From 82632098a33cb57b7dc8412b13c5f17aacb162a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Thu, 21 Jan 2016 13:16:04 +0100 Subject: [PATCH 181/580] =?UTF-8?q?Add=20-f,=20--follow=20flag=20as=20opti?= =?UTF-8?q?on=20on=20logs.=20Closes=20#2187=20Signed-off-by:=20St=C3=A9pha?= =?UTF-8?q?ne=20Seguin=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose/cli/log_printer.py | 19 ++++++++++------ compose/cli/main.py | 13 ++++++----- docs/reference/logs.md | 1 + requirements.txt | 2 +- tests/acceptance/cli_test.py | 22 +++++++++++++++++++ .../logs-composefile/docker-compose.yml | 6 +++++ tests/unit/cli/log_printer_test.py | 14 ++++++++++-- tests/unit/cli/main_test.py | 4 ++-- 8 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/logs-composefile/docker-compose.yml diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 85fef794..6fd5ca5d 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,11 +13,13 @@ from compose.utils import split_buffer class LogPrinter(object): """Print logs from many containers to a single output stream.""" - def __init__(self, containers, output=sys.stdout, monochrome=False, cascade_stop=False): + def __init__(self, containers, output=sys.stdout, monochrome=False, + cascade_stop=False, follow=False): self.containers = containers self.output = utils.get_output_stream(output) self.monochrome = monochrome self.cascade_stop = cascade_stop + self.follow = follow def run(self): if not self.containers: @@ -41,7 +43,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func) + yield generator_func(container, prefix, color_func, self.follow) def build_log_prefix(container, prefix_width): @@ -64,28 +66,31 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func): +def build_no_log_generator(container, prefix, color_func, follow): """Return a generator that prints a warning about logs and waits for container to exit. """ yield "{} WARNING: no logs are available with the '{}' log driver\n".format( prefix, container.log_driver) - yield color_func(wait_on_exit(container)) + if follow: + yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_func): +def build_log_generator(container, prefix, color_func, follow): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: - stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) + stream = container.logs(stdout=True, stderr=True, stream=True, + follow=follow) line_generator = split_buffer(stream) else: line_generator = split_buffer(container.log_stream) for line in line_generator: yield prefix + line - yield color_func(wait_on_exit(container)) + if follow: + yield color_func(wait_on_exit(container)) def wait_on_exit(container): diff --git a/compose/cli/main.py b/compose/cli/main.py index 6a04f9f0..1e6d0a55 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,13 +328,15 @@ class TopLevelCommand(DocoptCommand): Usage: logs [options] [SERVICE...] Options: - --no-color Produce monochrome output. + --no-color Produce monochrome output. + -f, --follow Follow log output """ containers = project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] + follow = options['--follow'] print("Attaching to", list_containers(containers)) - LogPrinter(containers, monochrome=monochrome).run() + LogPrinter(containers, monochrome=monochrome, follow=follow).run() def pause(self, project, options): """ @@ -660,7 +662,8 @@ class TopLevelCommand(DocoptCommand): if detached: return - log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop) + log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop, + follow=True) print("Attaching to", list_containers(log_printer.containers)) log_printer.run() @@ -758,13 +761,13 @@ def run_one_off_container(container_options, project, service, options): sys.exit(exit_code) -def build_log_printer(containers, service_names, monochrome, cascade_stop): +def build_log_printer(containers, service_names, monochrome, cascade_stop, follow): if service_names: containers = [ container for container in containers if container.service in service_names ] - return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop) + return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop, follow=follow) @contextlib.contextmanager diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 5b241ea7..4f0d5730 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -16,6 +16,7 @@ Usage: logs [options] [SERVICE...] Options: --no-color Produce monochrome output. +-f, --follow Follow log output ``` Displays log output from services. diff --git a/requirements.txt b/requirements.txt index e25386d2..b31840c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 +git+https://github.com/docker/docker-py.git@81d8caaf36159bf1accd86eab2e157bf8dd071a9#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 02f82872..60a5693b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -398,6 +398,8 @@ class CLITestCase(DockerClientTestCase): assert 'simple_1 | simple' in result.stdout assert 'another_1 | another' in result.stdout + assert 'simple_1 exited with code 0' in result.stdout + assert 'another_1 exited with code 0' in result.stdout @v2_only() def test_up(self): @@ -1141,6 +1143,26 @@ class CLITestCase(DockerClientTestCase): def test_logs_invalid_service_name(self): self.dispatch(['logs', 'madeupname'], returncode=1) + def test_logs_follow(self): + self.base_dir = 'tests/fixtures/echo-services' + self.dispatch(['up', '-d'], None) + + result = self.dispatch(['logs', '-f']) + + assert result.stdout.count('\n') == 5 + assert 'simple' in result.stdout + assert 'another' in result.stdout + assert 'exited with code 0' in result.stdout + + def test_logs_unfollow(self): + self.base_dir = 'tests/fixtures/logs-composefile' + self.dispatch(['up', '-d'], None) + + result = self.dispatch(['logs']) + + assert result.stdout.count('\n') >= 1 + assert 'exited with code 0' not in result.stdout + def test_kill(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/fixtures/logs-composefile/docker-compose.yml b/tests/fixtures/logs-composefile/docker-compose.yml new file mode 100644 index 00000000..0af9d805 --- /dev/null +++ b/tests/fixtures/logs-composefile/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: sh -c "echo hello && sleep 200" +another: + image: busybox:latest + command: sh -c "echo test" diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 5b04226c..bed0eae8 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -17,7 +17,7 @@ def build_mock_container(reader): name_without_project='web_1', has_api_logs=True, log_stream=None, - attach=reader, + logs=reader, wait=mock.Mock(return_value=0), ) @@ -39,7 +39,7 @@ def mock_container(): class TestLogPrinter(object): def test_single_container(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream).run() + LogPrinter([mock_container], output=output_stream, follow=True).run() output = output_stream.getvalue() assert 'hello' in output @@ -47,6 +47,15 @@ class TestLogPrinter(object): # Call count is 2 lines + "container exited line" assert output_stream.flush.call_count == 3 + def test_single_container_without_follow(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream, follow=False).run() + + output = output_stream.getvalue() + assert 'hello' in output + assert 'world' in output + # Call count is 2 lines + assert output_stream.flush.call_count == 2 + def test_monochrome(self, output_stream, mock_container): LogPrinter([mock_container], output=output_stream, monochrome=True).run() assert '\033[' not in output_stream.getvalue() @@ -86,3 +95,4 @@ class TestLogPrinter(object): output = output_stream.getvalue() assert "WARNING: no logs are available with the 'none' log driver\n" in output + assert "exited with code" not in output diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index fd6c5002..bddb9f17 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -33,7 +33,7 @@ class CLIMainTestCase(unittest.TestCase): mock_container('another', 1), ] service_names = ['web', 'db'] - log_printer = build_log_printer(containers, service_names, True, False) + log_printer = build_log_printer(containers, service_names, True, False, True) self.assertEqual(log_printer.containers, containers[:3]) def test_build_log_printer_all_services(self): @@ -43,7 +43,7 @@ class CLIMainTestCase(unittest.TestCase): mock_container('other', 1), ] service_names = [] - log_printer = build_log_printer(containers, service_names, True, False) + log_printer = build_log_printer(containers, service_names, True, False, True) self.assertEqual(log_printer.containers, containers) From d9b4286f919ee77ffe40d81405f517c1982a7f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Sun, 28 Feb 2016 22:00:12 +0100 Subject: [PATCH 182/580] Add -t, --timestamps flag as option on logs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Seguin --- compose/cli/log_printer.py | 18 ++++++++++++------ compose/cli/main.py | 8 +++++--- docs/reference/logs.md | 5 +++-- tests/acceptance/cli_test.py | 8 ++++++++ 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 6fd5ca5d..adef19ed 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,13 +13,19 @@ from compose.utils import split_buffer class LogPrinter(object): """Print logs from many containers to a single output stream.""" - def __init__(self, containers, output=sys.stdout, monochrome=False, - cascade_stop=False, follow=False): + def __init__(self, + containers, + output=sys.stdout, + monochrome=False, + cascade_stop=False, + follow=False, + timestamps=False): self.containers = containers self.output = utils.get_output_stream(output) self.monochrome = monochrome self.cascade_stop = cascade_stop self.follow = follow + self.timestamps = timestamps def run(self): if not self.containers: @@ -43,7 +49,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func, self.follow) + yield generator_func(container, prefix, color_func, self.follow, self.timestamps) def build_log_prefix(container, prefix_width): @@ -66,7 +72,7 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func, follow): +def build_no_log_generator(container, prefix, color_func, follow, timestamps): """Return a generator that prints a warning about logs and waits for container to exit. """ @@ -77,12 +83,12 @@ def build_no_log_generator(container, prefix, color_func, follow): yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_func, follow): +def build_log_generator(container, prefix, color_func, follow, timestamps): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: stream = container.logs(stdout=True, stderr=True, stream=True, - follow=follow) + follow=follow, timestamps=timestamps) line_generator = split_buffer(stream) else: line_generator = split_buffer(container.log_stream) diff --git a/compose/cli/main.py b/compose/cli/main.py index 1e6d0a55..fb309ac5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,15 +328,17 @@ class TopLevelCommand(DocoptCommand): Usage: logs [options] [SERVICE...] Options: - --no-color Produce monochrome output. - -f, --follow Follow log output + --no-color Produce monochrome output. + -f, --follow Follow log output + -t, --timestamps Show timestamps """ containers = project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] follow = options['--follow'] + timestamps = options['--timestamps'] print("Attaching to", list_containers(containers)) - LogPrinter(containers, monochrome=monochrome, follow=follow).run() + LogPrinter(containers, monochrome=monochrome, follow=follow, timestamps=timestamps).run() def pause(self, project, options): """ diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 4f0d5730..8135f4c9 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -15,8 +15,9 @@ parent = "smn_compose_cli" Usage: logs [options] [SERVICE...] Options: ---no-color Produce monochrome output. --f, --follow Follow log output +--no-color Produce monochrome output. +-f, --follow Follow log output +-t, --timestamps Show timestamps ``` Displays log output from services. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 60a5693b..78e17e44 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1163,6 +1163,14 @@ class CLITestCase(DockerClientTestCase): assert result.stdout.count('\n') >= 1 assert 'exited with code 0' not in result.stdout + def test_logs_timestamps(self): + self.base_dir = 'tests/fixtures/echo-services' + self.dispatch(['up', '-d'], None) + + result = self.dispatch(['logs', '-f', '-t'], None) + + self.assertRegexpMatches(result.stdout, '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})') + def test_kill(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') From 9b36dc5c540f9c88bdf6cb5e5b8e7e7b745d3c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Sun, 28 Feb 2016 22:00:53 +0100 Subject: [PATCH 183/580] =?UTF-8?q?Add=20--tail=20flag=20as=20option=20on?= =?UTF-8?q?=20logs.=20Closes=20#265=20Signed-off-by:=20St=C3=A9phane=20Seg?= =?UTF-8?q?uin=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose/cli/log_printer.py | 13 ++++++++----- compose/cli/main.py | 14 +++++++++++--- docs/reference/logs.md | 2 ++ tests/acceptance/cli_test.py | 8 ++++++++ .../logs-tail-composefile/docker-compose.yml | 3 +++ tests/unit/cli/log_printer_test.py | 2 +- 6 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/logs-tail-composefile/docker-compose.yml diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index adef19ed..6a8553c5 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -19,13 +19,16 @@ class LogPrinter(object): monochrome=False, cascade_stop=False, follow=False, - timestamps=False): + timestamps=False, + tail="all"): + self.containers = containers self.output = utils.get_output_stream(output) self.monochrome = monochrome self.cascade_stop = cascade_stop self.follow = follow self.timestamps = timestamps + self.tail = tail def run(self): if not self.containers: @@ -49,7 +52,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func, self.follow, self.timestamps) + yield generator_func(container, prefix, color_func, self.follow, self.timestamps, self.tail) def build_log_prefix(container, prefix_width): @@ -72,7 +75,7 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func, follow, timestamps): +def build_no_log_generator(container, prefix, color_func, follow, timestamps, tail): """Return a generator that prints a warning about logs and waits for container to exit. """ @@ -83,12 +86,12 @@ def build_no_log_generator(container, prefix, color_func, follow, timestamps): yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_func, follow, timestamps): +def build_log_generator(container, prefix, color_func, follow, timestamps, tail): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: stream = container.logs(stdout=True, stderr=True, stream=True, - follow=follow, timestamps=timestamps) + follow=follow, timestamps=timestamps, tail=tail) line_generator = split_buffer(stream) else: line_generator = split_buffer(container.log_stream) diff --git a/compose/cli/main.py b/compose/cli/main.py index fb309ac5..8cbdce01 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -329,16 +329,24 @@ class TopLevelCommand(DocoptCommand): Options: --no-color Produce monochrome output. - -f, --follow Follow log output - -t, --timestamps Show timestamps + -f, --follow Follow log output. + -t, --timestamps Show timestamps. + --tail="all" Number of lines to show from the end of the logs + for each container. """ containers = project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] follow = options['--follow'] timestamps = options['--timestamps'] + tail = options['--tail'] + if tail is not None: + if tail.isdigit(): + tail = int(tail) + elif tail != 'all': + raise UserError("tail flag must be all or a number") print("Attaching to", list_containers(containers)) - LogPrinter(containers, monochrome=monochrome, follow=follow, timestamps=timestamps).run() + LogPrinter(containers, monochrome=monochrome, follow=follow, timestamps=timestamps, tail=tail).run() def pause(self, project, options): """ diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 8135f4c9..745d24f7 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -18,6 +18,8 @@ Options: --no-color Produce monochrome output. -f, --follow Follow log output -t, --timestamps Show timestamps +--tail Number of lines to show from the end of the logs + for each container. ``` Displays log output from services. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 78e17e44..8e743075 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1171,6 +1171,14 @@ class CLITestCase(DockerClientTestCase): self.assertRegexpMatches(result.stdout, '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})') + def test_logs_tail(self): + self.base_dir = 'tests/fixtures/logs-tail-composefile' + self.dispatch(['up'], None) + + result = self.dispatch(['logs', '--tail', '2'], None) + + assert result.stdout.count('\n') == 3 + def test_kill(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/fixtures/logs-tail-composefile/docker-compose.yml b/tests/fixtures/logs-tail-composefile/docker-compose.yml new file mode 100644 index 00000000..80d8feae --- /dev/null +++ b/tests/fixtures/logs-tail-composefile/docker-compose.yml @@ -0,0 +1,3 @@ +simple: + image: busybox:latest + command: sh -c "echo a && echo b && echo c && echo d" diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index bed0eae8..d5593639 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -47,7 +47,7 @@ class TestLogPrinter(object): # Call count is 2 lines + "container exited line" assert output_stream.flush.call_count == 3 - def test_single_container_without_follow(self, output_stream, mock_container): + def test_single_container_without_stream(self, output_stream, mock_container): LogPrinter([mock_container], output=output_stream, follow=False).run() output = output_stream.getvalue() From 038da4eea3add68bb80b78da43d0c5d90715fbe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Sun, 28 Feb 2016 22:04:16 +0100 Subject: [PATCH 184/580] Logs args of LogPrinter as a dictionary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Seguin --- compose/cli/log_printer.py | 23 +++++++++-------------- compose/cli/main.py | 17 ++++++++++------- tests/unit/cli/log_printer_test.py | 4 ++-- tests/unit/cli/main_test.py | 4 ++-- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 6a8553c5..326676ba 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -18,17 +18,13 @@ class LogPrinter(object): output=sys.stdout, monochrome=False, cascade_stop=False, - follow=False, - timestamps=False, - tail="all"): - + log_args=None): + log_args = log_args or {} self.containers = containers self.output = utils.get_output_stream(output) self.monochrome = monochrome self.cascade_stop = cascade_stop - self.follow = follow - self.timestamps = timestamps - self.tail = tail + self.log_args = log_args def run(self): if not self.containers: @@ -52,7 +48,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func, self.follow, self.timestamps, self.tail) + yield generator_func(container, prefix, color_func, self.log_args) def build_log_prefix(container, prefix_width): @@ -75,30 +71,29 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func, follow, timestamps, tail): +def build_no_log_generator(container, prefix, color_func, log_args): """Return a generator that prints a warning about logs and waits for container to exit. """ yield "{} WARNING: no logs are available with the '{}' log driver\n".format( prefix, container.log_driver) - if follow: + if log_args.get('follow'): yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_func, follow, timestamps, tail): +def build_log_generator(container, prefix, color_func, log_args): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: - stream = container.logs(stdout=True, stderr=True, stream=True, - follow=follow, timestamps=timestamps, tail=tail) + stream = container.logs(stdout=True, stderr=True, stream=True, **log_args) line_generator = split_buffer(stream) else: line_generator = split_buffer(container.log_stream) for line in line_generator: yield prefix + line - if follow: + if log_args.get('follow'): yield color_func(wait_on_exit(container)) diff --git a/compose/cli/main.py b/compose/cli/main.py index 8cbdce01..a3aabd7a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -337,16 +337,19 @@ class TopLevelCommand(DocoptCommand): containers = project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] - follow = options['--follow'] - timestamps = options['--timestamps'] tail = options['--tail'] if tail is not None: if tail.isdigit(): tail = int(tail) elif tail != 'all': raise UserError("tail flag must be all or a number") + log_args = { + 'follow': options['--follow'], + 'tail': tail, + 'timestamps': options['--timestamps'] + } print("Attaching to", list_containers(containers)) - LogPrinter(containers, monochrome=monochrome, follow=follow, timestamps=timestamps, tail=tail).run() + LogPrinter(containers, monochrome=monochrome, log_args=log_args).run() def pause(self, project, options): """ @@ -672,8 +675,8 @@ class TopLevelCommand(DocoptCommand): if detached: return - log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop, - follow=True) + log_args = {'follow': True} + log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop, log_args) print("Attaching to", list_containers(log_printer.containers)) log_printer.run() @@ -771,13 +774,13 @@ def run_one_off_container(container_options, project, service, options): sys.exit(exit_code) -def build_log_printer(containers, service_names, monochrome, cascade_stop, follow): +def build_log_printer(containers, service_names, monochrome, cascade_stop, log_args): if service_names: containers = [ container for container in containers if container.service in service_names ] - return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop, follow=follow) + return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop, log_args=log_args) @contextlib.contextmanager diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index d5593639..54fef0b2 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -39,7 +39,7 @@ def mock_container(): class TestLogPrinter(object): def test_single_container(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream, follow=True).run() + LogPrinter([mock_container], output=output_stream, log_args={'follow': True}).run() output = output_stream.getvalue() assert 'hello' in output @@ -48,7 +48,7 @@ class TestLogPrinter(object): assert output_stream.flush.call_count == 3 def test_single_container_without_stream(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream, follow=False).run() + LogPrinter([mock_container], output=output_stream).run() output = output_stream.getvalue() assert 'hello' in output diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index bddb9f17..e7c52003 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -33,7 +33,7 @@ class CLIMainTestCase(unittest.TestCase): mock_container('another', 1), ] service_names = ['web', 'db'] - log_printer = build_log_printer(containers, service_names, True, False, True) + log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) self.assertEqual(log_printer.containers, containers[:3]) def test_build_log_printer_all_services(self): @@ -43,7 +43,7 @@ class CLIMainTestCase(unittest.TestCase): mock_container('other', 1), ] service_names = [] - log_printer = build_log_printer(containers, service_names, True, False, True) + log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) self.assertEqual(log_printer.containers, containers) From ed4473c849cc3e9029dc7894ade716d791c918c6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Mar 2016 16:39:43 -0500 Subject: [PATCH 185/580] Fix signal handling with pyinstaller. Raise a ShutdownException instead of a KeyboardInterupt when a thread.error is caught. This thread.error is only raised when run from a pyinstaller binary (for reasons unknown). Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/cli/multiplexer.py | 3 ++- compose/parallel.py | 36 +++++++++++++++++++++++------------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2c0fda1c..0a917720 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -54,7 +54,7 @@ def main(): try: command = TopLevelCommand() command.sys_dispatch() - except KeyboardInterrupt: + except (KeyboardInterrupt, signals.ShutdownException): log.error("Aborting.") sys.exit(1) except (UserError, NoSuchService, ConfigurationError) as e: diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index e6e63f24..ae8aa591 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -10,6 +10,7 @@ try: except ImportError: from queue import Queue, Empty # Python 3.x +from compose.cli.signals import ShutdownException STOP = object() @@ -47,7 +48,7 @@ class Multiplexer(object): pass # See https://github.com/docker/compose/issues/189 except thread.error: - raise KeyboardInterrupt() + raise ShutdownException() def _init_readers(self): for iterator in self.iterators: diff --git a/compose/parallel.py b/compose/parallel.py index b8415e5e..4810a106 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -6,9 +6,11 @@ import sys from threading import Thread from docker.errors import APIError +from six.moves import _thread as thread from six.moves.queue import Empty from six.moves.queue import Queue +from compose.cli.signals import ShutdownException from compose.utils import get_output_stream @@ -26,19 +28,7 @@ def parallel_execute(objects, func, index_func, msg): objects = list(objects) stream = get_output_stream(sys.stderr) writer = ParallelStreamWriter(stream, msg) - - for obj in objects: - writer.initialize(index_func(obj)) - - q = Queue() - - # TODO: limit the number of threads #1828 - for obj in objects: - t = Thread( - target=perform_operation, - args=(func, obj, q.put, index_func(obj))) - t.daemon = True - t.start() + q = setup_queue(writer, objects, func, index_func) done = 0 errors = {} @@ -48,6 +38,9 @@ def parallel_execute(objects, func, index_func, msg): msg_index, result = q.get(timeout=1) except Empty: continue + # See https://github.com/docker/compose/issues/189 + except thread.error: + raise ShutdownException() if isinstance(result, APIError): errors[msg_index] = "error", result.explanation @@ -68,6 +61,23 @@ def parallel_execute(objects, func, index_func, msg): raise error +def setup_queue(writer, objects, func, index_func): + for obj in objects: + writer.initialize(index_func(obj)) + + q = Queue() + + # TODO: limit the number of threads #1828 + for obj in objects: + t = Thread( + target=perform_operation, + args=(func, obj, q.put, index_func(obj))) + t.daemon = True + t.start() + + return q + + class ParallelStreamWriter(object): """Write out messages for operations happening in parallel. From aa7b862f4c7f10337fc0b586d70aae5392b51f6c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 29 Feb 2016 15:53:04 -0800 Subject: [PATCH 186/580] Clarify depends_on logic Signed-off-by: Aanand Prasad --- docs/compose-file.md | 5 +++ docs/faq.md | 68 +-------------------------------- docs/startup-order.md | 88 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 66 deletions(-) create mode 100644 docs/startup-order.md diff --git a/docs/compose-file.md b/docs/compose-file.md index 3e7acf70..24e45160 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -203,6 +203,11 @@ Simple example: db: image: postgres +> **Note:** `depends_on` will not wait for `db` and `redis` to be "ready" before +> starting `web` - only until they have been started. If you need to wait +> for a service to be ready, see [Controlling startup order](startup-order.md) +> for more on this problem and strategies for solving it. + ### dns Custom DNS servers. Can be a single value or a list. diff --git a/docs/faq.md b/docs/faq.md index 201549f1..45885255 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -16,73 +16,9 @@ If you don’t see your question here, feel free to drop by `#docker-compose` on freenode IRC and ask the community. -## How do I control the order of service startup? I need my database to be ready before my application starts. +## Can I control service startup order? -You can control the order of service startup with the -[depends_on](compose-file.md#depends-on) option. Compose always starts -containers in dependency order, where dependencies are determined by -`depends_on`, `links`, `volumes_from` and `network_mode: "service:..."`. - -However, Compose will not wait until a container is "ready" (whatever that means -for your particular application) - only until it's running. There's a good -reason for this. - -The problem of waiting for a database to be ready is really just a subset of a -much larger problem of distributed systems. In production, your database could -become unavailable or move hosts at any time. Your application needs to be -resilient to these types of failures. - -To handle this, your application should attempt to re-establish a connection to -the database after a failure. If the application retries the connection, -it should eventually be able to connect to the database. - -The best solution is to perform this check in your application code, both at -startup and whenever a connection is lost for any reason. However, if you don't -need this level of resilience, you can work around the problem with a wrapper -script: - -- Use a tool such as [wait-for-it](https://github.com/vishnubob/wait-for-it) - or [dockerize](https://github.com/jwilder/dockerize). These are small - wrapper scripts which you can include in your application's image and will - poll a given host and port until it's accepting TCP connections. - - Supposing your application's image has a `CMD` set in its Dockerfile, you - can wrap it by setting the entrypoint in `docker-compose.yml`: - - version: "2" - services: - web: - build: . - ports: - - "80:8000" - depends_on: - - "db" - entrypoint: ./wait-for-it.sh db:5432 - db: - image: postgres - -- Write your own wrapper script to perform a more application-specific health - check. For example, you might want to wait until Postgres is definitely - ready to accept commands: - - #!/bin/bash - - set -e - - host="$1" - shift - cmd="$@" - - until psql -h "$host" -U "postgres" -c '\l'; do - >&2 echo "Postgres is unavailable - sleeping" - sleep 1 - done - - >&2 echo "Postgres is up - executing command" - exec $cmd - - You can use this as a wrapper script as in the previous example, by setting - `entrypoint: ./wait-for-postgres.sh db`. +Yes - see [Controlling startup order](startup-order.md). ## Why do my services take 10 seconds to recreate or stop? diff --git a/docs/startup-order.md b/docs/startup-order.md new file mode 100644 index 00000000..c67e1829 --- /dev/null +++ b/docs/startup-order.md @@ -0,0 +1,88 @@ + + +# Controlling startup order in Compose + +You can control the order of service startup with the +[depends_on](compose-file.md#depends-on) option. Compose always starts +containers in dependency order, where dependencies are determined by +`depends_on`, `links`, `volumes_from` and `network_mode: "service:..."`. + +However, Compose will not wait until a container is "ready" (whatever that means +for your particular application) - only until it's running. There's a good +reason for this. + +The problem of waiting for a database (for example) to be ready is really just +a subset of a much larger problem of distributed systems. In production, your +database could become unavailable or move hosts at any time. Your application +needs to be resilient to these types of failures. + +To handle this, your application should attempt to re-establish a connection to +the database after a failure. If the application retries the connection, +it should eventually be able to connect to the database. + +The best solution is to perform this check in your application code, both at +startup and whenever a connection is lost for any reason. However, if you don't +need this level of resilience, you can work around the problem with a wrapper +script: + +- Use a tool such as [wait-for-it](https://github.com/vishnubob/wait-for-it) + or [dockerize](https://github.com/jwilder/dockerize). These are small + wrapper scripts which you can include in your application's image and will + poll a given host and port until it's accepting TCP connections. + + Supposing your application's image has a `CMD` set in its Dockerfile, you + can wrap it by setting the entrypoint in `docker-compose.yml`: + + version: "2" + services: + web: + build: . + ports: + - "80:8000" + depends_on: + - "db" + entrypoint: ./wait-for-it.sh db:5432 + db: + image: postgres + +- Write your own wrapper script to perform a more application-specific health + check. For example, you might want to wait until Postgres is definitely + ready to accept commands: + + #!/bin/bash + + set -e + + host="$1" + shift + cmd="$@" + + until psql -h "$host" -U "postgres" -c '\l'; do + >&2 echo "Postgres is unavailable - sleeping" + sleep 1 + done + + >&2 echo "Postgres is up - executing command" + exec $cmd + + You can use this as a wrapper script as in the previous example, by setting + `entrypoint: ./wait-for-postgres.sh db`. + + +## Compose documentation + +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with WordPress](wordpress.md) +- [Command line reference](./reference/index.md) +- [Compose file reference](compose-file.md) From 00497436153cd60c5b43f396659cc697fda770e2 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 2 Mar 2016 21:12:19 +0100 Subject: [PATCH 187/580] add support for multiple compose files to bash completion Since 1.6.0, Compose supports multiple compose files specified with `-f`. These need to be passed to the docker invocations done by the completion. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 3b135311..d926d648 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -18,7 +18,11 @@ __docker_compose_q() { - docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} "$@" + local file_args + if [ ${#compose_files[@]} -ne 0 ] ; then + file_args="${compose_files[@]/#/-f }" + fi + docker-compose 2>/dev/null $file_args ${compose_project:+-p $compose_project} "$@" } # suppress trailing whitespace @@ -456,14 +460,14 @@ _docker_compose() { # special treatment of some top-level options local command='docker_compose' local counter=1 - local compose_file compose_project + local compose_files=() compose_project while [ $counter -lt $cword ]; do case "${words[$counter]}" in --file|-f) (( counter++ )) - compose_file="${words[$counter]}" + compose_files+=(${words[$counter]}) ;; - --project-name|p) + --project-name|-p) (( counter++ )) compose_project="${words[$counter]}" ;; From b7fb3a6d9b2f96df47e4133b1bbf8d8d478b9373 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 25 Feb 2016 16:39:58 -0800 Subject: [PATCH 188/580] Add --build flag for up and create Also adds a warning when up builds an image without the --build flag so that users know it wont happen on the next up. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 24 ++++++++++++++++++++---- compose/project.py | 10 ++++++++-- compose/service.py | 37 +++++++++++++++++++++++++++---------- tests/unit/service_test.py | 38 ++++++++++++++++++++++++++++++++++---- 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index be33ce52..afb777be 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -25,6 +25,7 @@ from ..const import HTTP_TIMEOUT from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError from ..project import NoSuchService +from ..service import BuildAction from ..service import BuildError from ..service import ConvergenceStrategy from ..service import ImageType @@ -249,14 +250,15 @@ class TopLevelCommand(DocoptCommand): image haven't changed. Incompatible with --no-recreate. --no-recreate If containers already exist, don't recreate them. Incompatible with --force-recreate. - --no-build Don't build an image, even if it's missing + --no-build Don't build an image, even if it's missing. + --build Build images before creating containers. """ service_names = options['SERVICE'] project.create( service_names=service_names, strategy=convergence_strategy_from_opts(options), - do_build=not options['--no-build'] + do_build=build_action_from_opts(options), ) def down(self, project, options): @@ -699,7 +701,8 @@ class TopLevelCommand(DocoptCommand): Incompatible with --no-recreate. --no-recreate If containers already exist, don't recreate them. Incompatible with --force-recreate. - --no-build Don't build an image, even if it's missing + --no-build Don't build an image, even if it's missing. + --build Build images before starting containers. --abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d. -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown @@ -721,7 +724,7 @@ class TopLevelCommand(DocoptCommand): service_names=service_names, start_deps=start_deps, strategy=convergence_strategy_from_opts(options), - do_build=not options['--no-build'], + do_build=build_action_from_opts(options), timeout=timeout, detached=detached) @@ -775,6 +778,19 @@ def image_type_from_opt(flag, value): raise UserError("%s flag must be one of: all, local" % flag) +def build_action_from_opts(options): + if options['--build'] and options['--no-build']: + raise UserError("--build and --no-build can not be combined.") + + if options['--build']: + return BuildAction.force + + if options['--no-build']: + return BuildAction.skip + + return BuildAction.none + + def run_one_off_container(container_options, project, service, options): if not options['--no-deps']: deps = service.get_dependency_names() diff --git a/compose/project.py b/compose/project.py index cfb11aa0..c964417f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -21,6 +21,7 @@ from .container import Container from .network import build_networks from .network import get_networks from .network import ProjectNetworks +from .service import BuildAction from .service import ContainerNetworkMode from .service import ConvergenceStrategy from .service import NetworkMode @@ -249,7 +250,12 @@ class Project(object): else: log.info('%s uses an image, skipping' % service.name) - def create(self, service_names=None, strategy=ConvergenceStrategy.changed, do_build=True): + def create( + self, + service_names=None, + strategy=ConvergenceStrategy.changed, + do_build=BuildAction.none, + ): services = self.get_services_without_duplicate(service_names, include_deps=True) plans = self._get_convergence_plans(services, strategy) @@ -298,7 +304,7 @@ class Project(object): service_names=None, start_deps=True, strategy=ConvergenceStrategy.changed, - do_build=True, + do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, detached=False): diff --git a/compose/service.py b/compose/service.py index a24b9733..e2ddeb5a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -104,6 +104,14 @@ class ImageType(enum.Enum): all = 2 +@enum.unique +class BuildAction(enum.Enum): + """Enumeration for the possible build actions.""" + none = 0 + force = 1 + skip = 2 + + class Service(object): def __init__( self, @@ -243,7 +251,7 @@ class Service(object): def create_container(self, one_off=False, - do_build=True, + do_build=BuildAction.none, previous_container=None, number=None, quiet=False, @@ -266,20 +274,29 @@ class Service(object): return Container.create(self.client, **container_options) - def ensure_image_exists(self, do_build=True): + def ensure_image_exists(self, do_build=BuildAction.none): + if self.can_be_built() and do_build == BuildAction.force: + self.build() + return + try: self.image() return except NoSuchImageError: pass - if self.can_be_built(): - if do_build: - self.build() - else: - raise NeedsBuildError(self) - else: + if not self.can_be_built(): self.pull() + return + + if do_build == BuildAction.skip: + raise NeedsBuildError(self) + + self.build() + log.warn( + "Image for service {} was build because it was not found. To " + "rebuild this image you must use the `build` command or the " + "--build flag.".format(self.name)) def image(self): try: @@ -343,7 +360,7 @@ class Service(object): def execute_convergence_plan(self, plan, - do_build=True, + do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, detached=False, start=True): @@ -392,7 +409,7 @@ class Service(object): def recreate_container( self, container, - do_build=False, + do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, attach_logs=False, start_new_container=True): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4f1e065e..8631aa92 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import docker +import pytest from docker.errors import APIError from .. import mock @@ -15,6 +16,7 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.service import build_ulimits from compose.service import build_volume_binding +from compose.service import BuildAction from compose.service import ContainerNetworkMode from compose.service import get_container_data_volumes from compose.service import ImageType @@ -427,7 +429,12 @@ class ServiceTest(unittest.TestCase): '{"stream": "Successfully built abcd"}', ] - service.create_container(do_build=True) + with mock.patch('compose.service.log', autospec=True) as mock_log: + service.create_container(do_build=BuildAction.none) + assert mock_log.warn.called + _, args, _ = mock_log.warn.mock_calls[0] + assert 'was build because it was not found' in args[0] + self.mock_client.build.assert_called_once_with( tag='default_foo', dockerfile=None, @@ -444,14 +451,37 @@ class ServiceTest(unittest.TestCase): service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.return_value = {'Id': 'abc123'} - service.create_container(do_build=False) + service.create_container(do_build=BuildAction.skip) self.assertFalse(self.mock_client.build.called) def test_create_container_no_build_but_needs_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.side_effect = NoSuchImageError - with self.assertRaises(NeedsBuildError): - service.create_container(do_build=False) + with pytest.raises(NeedsBuildError): + service.create_container(do_build=BuildAction.skip) + + def test_create_container_force_build(self): + service = Service('foo', client=self.mock_client, build={'context': '.'}) + self.mock_client.inspect_image.return_value = {'Id': 'abc123'} + self.mock_client.build.return_value = [ + '{"stream": "Successfully built abcd"}', + ] + + with mock.patch('compose.service.log', autospec=True) as mock_log: + service.create_container(do_build=BuildAction.force) + + assert not mock_log.warn.called + self.mock_client.build.assert_called_once_with( + tag='default_foo', + dockerfile=None, + stream=True, + path='.', + pull=False, + forcerm=False, + nocache=False, + rm=True, + buildargs=None, + ) def test_build_does_not_pull(self): self.mock_client.build.return_value = [ From e1b87d7be0aa11f5f87762635a9e24d4e8849e77 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 26 Feb 2016 15:03:53 -0800 Subject: [PATCH 189/580] Update reference docs for the new flag. Signed-off-by: Daniel Nephin --- compose/service.py | 6 +++--- docs/reference/create.md | 15 ++++++++------- docs/reference/up.md | 34 ++++++++++++++++++---------------- tests/unit/service_test.py | 2 +- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/compose/service.py b/compose/service.py index e2ddeb5a..7ee441f2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -294,9 +294,9 @@ class Service(object): self.build() log.warn( - "Image for service {} was build because it was not found. To " - "rebuild this image you must use the `build` command or the " - "--build flag.".format(self.name)) + "Image for service {} was built because it did not already exist. To " + "rebuild this image you must use `docker-compose build` or " + "`docker-compose up --build`.".format(self.name)) def image(self): try: diff --git a/docs/reference/create.md b/docs/reference/create.md index a785e2c7..5065e8be 100644 --- a/docs/reference/create.md +++ b/docs/reference/create.md @@ -12,14 +12,15 @@ parent = "smn_compose_cli" # create ``` +Creates containers for a service. + Usage: create [options] [SERVICE...] Options: ---force-recreate Recreate containers even if their configuration and - image haven't changed. Incompatible with --no-recreate. ---no-recreate If containers already exist, don't recreate them. - Incompatible with --force-recreate. ---no-build Don't build an image, even if it's missing + --force-recreate Recreate containers even if their configuration and + image haven't changed. Incompatible with --no-recreate. + --no-recreate If containers already exist, don't recreate them. + Incompatible with --force-recreate. + --no-build Don't build an image, even if it's missing. + --build Build images before creating containers. ``` - -Creates containers for a service. diff --git a/docs/reference/up.md b/docs/reference/up.md index a02358ec..07ee82f9 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -15,22 +15,24 @@ parent = "smn_compose_cli" Usage: up [options] [SERVICE...] Options: --d Detached mode: Run containers in the background, - print new container names. - Incompatible with --abort-on-container-exit. ---no-color Produce monochrome output. ---no-deps Don't start linked services. ---force-recreate Recreate containers even if their configuration - and image haven't changed. - Incompatible with --no-recreate. ---no-recreate If containers already exist, don't recreate them. - Incompatible with --force-recreate. ---no-build Don't build an image, even if it's missing ---abort-on-container-exit Stops all containers if any container was stopped. - Incompatible with -d. --t, --timeout TIMEOUT Use this timeout in seconds for container shutdown - when attached or when containers are already - running. (default: 10) + -d Detached mode: Run containers in the background, + print new container names. + Incompatible with --abort-on-container-exit. + --no-color Produce monochrome output. + --no-deps Don't start linked services. + --force-recreate Recreate containers even if their configuration + and image haven't changed. + Incompatible with --no-recreate. + --no-recreate If containers already exist, don't recreate them. + Incompatible with --force-recreate. + --no-build Don't build an image, even if it's missing. + --build Build images before starting containers. + --abort-on-container-exit Stops all containers if any container was stopped. + Incompatible with -d. + -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown + when attached or when containers are already + running. (default: 10) + ``` Builds, (re)creates, starts, and attaches to containers for a service. diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8631aa92..199aeeb4 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -433,7 +433,7 @@ class ServiceTest(unittest.TestCase): service.create_container(do_build=BuildAction.none) assert mock_log.warn.called _, args, _ = mock_log.warn.mock_calls[0] - assert 'was build because it was not found' in args[0] + assert 'was built because it did not already exist' in args[0] self.mock_client.build.assert_called_once_with( tag='default_foo', From 2c75a8fdf556329acf1a8442cd9ac5f1e94be8cc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 5 Feb 2016 17:03:05 +0000 Subject: [PATCH 190/580] Extract helper methods for building config objects from dicts Signed-off-by: Aanand Prasad --- .pre-commit-config.yaml | 2 +- tests/helpers.py | 16 ++++++++++++ tests/integration/project_test.py | 43 +++++++++++++------------------ tests/unit/config/config_test.py | 7 +---- 4 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 tests/helpers.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db2b6506..e37677c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ - id: end-of-file-fixer - id: flake8 - id: name-tests-test - exclude: 'tests/integration/testcases.py' + exclude: 'tests/(helpers\.py|integration/testcases\.py)' - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..dd0b668e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from compose.config.config import ConfigDetails +from compose.config.config import ConfigFile +from compose.config.config import load + + +def build_config(contents, **kwargs): + return load(build_config_details(contents, **kwargs)) + + +def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): + return ConfigDetails( + working_dir, + [ConfigFile(filename, contents)]) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 8915733c..8400ba1f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ import py import pytest from docker.errors import NotFound +from ..helpers import build_config from .testcases import DockerClientTestCase from compose.config import config from compose.config import ConfigurationError @@ -20,13 +21,6 @@ from compose.service import ConvergenceStrategy from tests.integration.testcases import v2_only -def build_service_dicts(service_config): - return config.load( - config.ConfigDetails( - 'working_dir', - [config.ConfigFile(None, service_config)])) - - class ProjectTest(DockerClientTestCase): def test_containers(self): @@ -67,19 +61,18 @@ class ProjectTest(DockerClientTestCase): ) def test_volumes_from_service(self): - service_dicts = build_service_dicts({ - 'data': { - 'image': 'busybox:latest', - 'volumes': ['/var/data'], - }, - 'db': { - 'image': 'busybox:latest', - 'volumes_from': ['data'], - }, - }) project = Project.from_config( name='composetest', - config_data=service_dicts, + config_data=build_config({ + 'data': { + 'image': 'busybox:latest', + 'volumes': ['/var/data'], + }, + 'db': { + 'image': 'busybox:latest', + 'volumes_from': ['data'], + }, + }), client=self.client, ) db = project.get_service('db') @@ -96,7 +89,7 @@ class ProjectTest(DockerClientTestCase): ) project = Project.from_config( name='composetest', - config_data=build_service_dicts({ + config_data=build_config({ 'db': { 'image': 'busybox:latest', 'volumes_from': ['composetest_data_container'], @@ -112,7 +105,7 @@ class ProjectTest(DockerClientTestCase): project = Project.from_config( name='composetest', client=self.client, - config_data=build_service_dicts({ + config_data=build_config({ 'version': V2_0, 'services': { 'net': { @@ -139,7 +132,7 @@ class ProjectTest(DockerClientTestCase): def get_project(): return Project.from_config( name='composetest', - config_data=build_service_dicts({ + config_data=build_config({ 'version': V2_0, 'services': { 'web': { @@ -174,7 +167,7 @@ class ProjectTest(DockerClientTestCase): def test_net_from_service_v1(self): project = Project.from_config( name='composetest', - config_data=build_service_dicts({ + config_data=build_config({ 'net': { 'image': 'busybox:latest', 'command': ["top"] @@ -198,7 +191,7 @@ class ProjectTest(DockerClientTestCase): def get_project(): return Project.from_config( name='composetest', - config_data=build_service_dicts({ + config_data=build_config({ 'web': { 'image': 'busybox:latest', 'net': 'container:composetest_net_container' @@ -469,7 +462,7 @@ class ProjectTest(DockerClientTestCase): def test_project_up_starts_depends(self): project = Project.from_config( name='composetest', - config_data=build_service_dicts({ + config_data=build_config({ 'console': { 'image': 'busybox:latest', 'command': ["top"], @@ -504,7 +497,7 @@ class ProjectTest(DockerClientTestCase): def test_project_up_with_no_deps(self): project = Project.from_config( name='composetest', - config_data=build_service_dicts({ + config_data=build_config({ 'console': { 'image': 'busybox:latest', 'command': ["top"], diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c2ca8e6e..04f299c6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -11,6 +11,7 @@ from operator import itemgetter import py import pytest +from ...helpers import build_config_details from compose.config import config from compose.config.config import resolve_build_args from compose.config.config import resolve_environment @@ -43,12 +44,6 @@ def service_sort(services): return sorted(services, key=itemgetter('name')) -def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): - return config.ConfigDetails( - working_dir, - [config.ConfigFile(filename, contents)]) - - class ConfigTest(unittest.TestCase): def test_load(self): service_dicts = config.load( From 575b48749d1eb8f8a583a5b1d2336003a6f12383 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 5 Feb 2016 17:53:35 +0000 Subject: [PATCH 191/580] Remove unused global_options arg from dispatch() Signed-off-by: Aanand Prasad --- compose/cli/docopt_command.py | 8 ++++---- tests/unit/cli_test.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index d2900b39..5b50189c 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -20,12 +20,12 @@ class DocoptCommand(object): return {'options_first': True} def sys_dispatch(self): - self.dispatch(sys.argv[1:], None) + self.dispatch(sys.argv[1:]) - def dispatch(self, argv, global_options): - self.perform_command(*self.parse(argv, global_options)) + def dispatch(self, argv): + self.perform_command(*self.parse(argv)) - def parse(self, argv, global_options): + def parse(self, argv): options = docopt_full_help(getdoc(self), argv, **self.docopt_options()) command = options['COMMAND'] diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 26ae4e30..3fc0a985 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -66,17 +66,17 @@ class CLITestCase(unittest.TestCase): def test_help(self): command = TopLevelCommand() with self.assertRaises(SystemExit): - command.dispatch(['-h'], None) + command.dispatch(['-h']) def test_command_help(self): with self.assertRaises(SystemExit) as ctx: - TopLevelCommand().dispatch(['help', 'up'], None) + TopLevelCommand().dispatch(['help', 'up']) self.assertIn('Usage: up', str(ctx.exception)) def test_command_help_nonexistent(self): with self.assertRaises(NoSuchCommand): - TopLevelCommand().dispatch(['help', 'nonexistent'], None) + TopLevelCommand().dispatch(['help', 'nonexistent']) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") @mock.patch('compose.cli.main.RunOperation', autospec=True) From 4644f2c0f998d7f3a27464cb965265c041a17e2a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 5 Feb 2016 13:12:43 +0000 Subject: [PATCH 192/580] Remove environment-overriding unit test for 'run' There's already an acceptance test for it Signed-off-by: Aanand Prasad --- tests/unit/cli_test.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 3fc0a985..cf3c8e8f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -110,39 +110,6 @@ class CLITestCase(unittest.TestCase): _, _, call_kwargs = mock_run_operation.mock_calls[0] assert call_kwargs['logs'] is False - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") - @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) - def test_run_with_environment_merged_with_options_list(self, mock_pseudo_terminal): - command = TopLevelCommand() - mock_client = mock.create_autospec(docker.Client) - mock_project = mock.Mock(client=mock_client) - mock_project.get_service.return_value = Service( - 'service', - client=mock_client, - environment=['FOO=ONE', 'BAR=TWO'], - image='someimage') - - command.run(mock_project, { - 'SERVICE': 'service', - 'COMMAND': None, - '-e': ['BAR=NEW', 'OTHER=bär'.encode('utf-8')], - '--user': None, - '--no-deps': None, - '-d': True, - '-T': None, - '--entrypoint': None, - '--service-ports': None, - '--publish': [], - '--rm': None, - '--name': None, - }) - - _, _, call_kwargs = mock_client.create_container.mock_calls[0] - assert ( - sorted(call_kwargs['environment']) == - sorted(['FOO=ONE', 'BAR=NEW', 'OTHER=bär']) - ) - def test_run_service_with_restart_always(self): command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) From 20caf02bf6a99d316615769af558c7212e25927b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 5 Feb 2016 17:09:07 +0000 Subject: [PATCH 193/580] Create real Project objects in CLI unit tests Signed-off-by: Aanand Prasad --- tests/unit/cli_test.py | 62 +++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index cf3c8e8f..cbe9ea6f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -10,13 +10,14 @@ import pytest from .. import mock from .. import unittest +from ..helpers import build_config from compose.cli.command import get_project from compose.cli.command import get_project_name from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand from compose.const import IS_WINDOWS_PLATFORM -from compose.service import Service +from compose.project import Project class CLITestCase(unittest.TestCase): @@ -84,18 +85,19 @@ class CLITestCase(unittest.TestCase): def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) - mock_project = mock.Mock(client=mock_client) - mock_project.get_service.return_value = Service( - 'service', + project = Project.from_config( + name='composetest', client=mock_client, - environment=['FOO=ONE', 'BAR=TWO'], - image='someimage') + config_data=build_config({ + 'service': {'image': 'busybox'} + }), + ) with pytest.raises(SystemExit): - command.run(mock_project, { + command.run(project, { 'SERVICE': 'service', 'COMMAND': None, - '-e': ['BAR=NEW', 'OTHER=bär'.encode('utf-8')], + '-e': [], '--user': None, '--no-deps': None, '-d': False, @@ -111,15 +113,21 @@ class CLITestCase(unittest.TestCase): assert call_kwargs['logs'] is False def test_run_service_with_restart_always(self): - command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) - mock_project = mock.Mock(client=mock_client) - mock_project.get_service.return_value = Service( - 'service', + + project = Project.from_config( + name='composetest', client=mock_client, - restart={'Name': 'always', 'MaximumRetryCount': 0}, - image='someimage') - command.run(mock_project, { + config_data=build_config({ + 'service': { + 'image': 'busybox', + 'restart': 'always', + } + }), + ) + + command = TopLevelCommand() + command.run(project, { 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -140,14 +148,7 @@ class CLITestCase(unittest.TestCase): ) command = TopLevelCommand() - mock_client = mock.create_autospec(docker.Client) - mock_project = mock.Mock(client=mock_client) - mock_project.get_service.return_value = Service( - 'service', - client=mock_client, - restart='always', - image='someimage') - command.run(mock_project, { + command.run(project, { 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -168,17 +169,16 @@ class CLITestCase(unittest.TestCase): def test_command_manula_and_service_ports_together(self): command = TopLevelCommand() - mock_client = mock.create_autospec(docker.Client) - mock_project = mock.Mock(client=mock_client) - mock_project.get_service.return_value = Service( - 'service', - client=mock_client, - restart='always', - image='someimage', + project = Project.from_config( + name='composetest', + client=None, + config_data=build_config({ + 'service': {'image': 'busybox'}, + }), ) with self.assertRaises(UserError): - command.run(mock_project, { + command.run(project, { 'SERVICE': 'service', 'COMMAND': None, '-e': [], From 53a3d14046e00b6489ae4aadeb0e3325cb5169b1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 26 Feb 2016 15:55:06 -0800 Subject: [PATCH 194/580] Support multiple files in COMPOSE_FILE env var. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 6 ++++-- docs/reference/envvars.md | 13 +++++++++---- tests/unit/cli/command_test.py | 35 ++++++++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 2a0d8698..98de2104 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -58,8 +58,10 @@ def get_config_path_from_options(options): if file_option: return file_option - config_file = os.environ.get('COMPOSE_FILE') - return [config_file] if config_file else None + config_files = os.environ.get('COMPOSE_FILE') + if config_files: + return config_files.split(os.pathsep) + return None def get_client(verbose=False, version=None): diff --git a/docs/reference/envvars.md b/docs/reference/envvars.md index 6360fe54..e1170be9 100644 --- a/docs/reference/envvars.md +++ b/docs/reference/envvars.md @@ -27,10 +27,15 @@ defaults to the `basename` of the project directory. See also the `-p` ## COMPOSE\_FILE -Specify the file containing the compose configuration. If not provided, -Compose looks for a file named `docker-compose.yml` in the current directory -and then each parent directory in succession until a file by that name is -found. See also the `-f` [command-line option](overview.md). +Specify the path to a Compose file. If not provided, Compose looks for a file named +`docker-compose.yml` in the current directory and then each parent directory in +succession until a file by that name is found. + +This variable supports multiple compose files separate by a path separator (on +Linux and OSX the path separator is `:`, on Windows it is `;`). For example: +`COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml` + +See also the `-f` [command-line option](overview.md). ## COMPOSE\_API\_VERSION diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 18044672..1ca671fe 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -1,16 +1,19 @@ from __future__ import absolute_import from __future__ import unicode_literals +import os + import pytest from requests.exceptions import ConnectionError from compose.cli import errors from compose.cli.command import friendly_error_message +from compose.cli.command import get_config_path_from_options +from compose.const import IS_WINDOWS_PLATFORM from tests import mock -from tests import unittest -class FriendlyErrorMessageTestCase(unittest.TestCase): +class TestFriendlyErrorMessage(object): def test_dispatch_generic_connection_error(self): with pytest.raises(errors.ConnectionErrorGeneric): @@ -21,3 +24,31 @@ class FriendlyErrorMessageTestCase(unittest.TestCase): ): with friendly_error_message(): raise ConnectionError() + + +class TestGetConfigPathFromOptions(object): + + def test_path_from_options(self): + paths = ['one.yml', 'two.yml'] + opts = {'--file': paths} + assert get_config_path_from_options(opts) == paths + + def test_single_path_from_env(self): + with mock.patch.dict(os.environ): + os.environ['COMPOSE_FILE'] = 'one.yml' + assert get_config_path_from_options({}) == ['one.yml'] + + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator') + def test_multiple_path_from_env(self): + with mock.patch.dict(os.environ): + os.environ['COMPOSE_FILE'] = 'one.yml:two.yml' + assert get_config_path_from_options({}) == ['one.yml', 'two.yml'] + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator') + def test_multiple_path_from_env_windows(self): + with mock.patch.dict(os.environ): + os.environ['COMPOSE_FILE'] = 'one.yml;two.yml' + assert get_config_path_from_options({}) == ['one.yml', 'two.yml'] + + def test_no_path(self): + assert not get_config_path_from_options({}) From 81b7fba33e55005041699da5a782cf55272f1d66 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Sun, 28 Feb 2016 16:54:01 +0200 Subject: [PATCH 195/580] Allowing null for build args Signed-off-by: Dimitar Bonev --- compose/config/config_schema_v2.0.json | 15 +------------- tests/unit/config/config_test.py | 27 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 28209ced..a4a30a5f 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -58,20 +58,7 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^.+$": { - "type": ["string", "number"] - } - }, - "additionalProperties": false - } - ] - } + "args": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false } diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 08d52553..d0e82420 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -404,7 +404,7 @@ class ConfigTest(unittest.TestCase): config.load(config_details) assert ( "services.web.build.args contains an invalid type, it should be an " - "array, or an object" in exc.exconly() + "object, or an array" in exc.exconly() ) def test_config_integer_service_name_raise_validation_error(self): @@ -689,6 +689,31 @@ class ConfigTest(unittest.TestCase): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' + def test_build_args_allow_empty_properties(self): + service = config.load( + build_config_details( + { + 'version': '2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'dockerfile': 'Dockerfile-alt', + 'args': { + 'foo': None + } + } + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services[0] + assert 'args' in service['build'] + assert 'foo' in service['build']['args'] + assert service['build']['args']['foo'] == 'None' + def test_load_with_multiple_files_mismatched_networks_format(self): base_file = config.ConfigFile( 'base.yaml', From 698998c410ba5d8895eafeb5050901e32fe6e4bb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Mar 2016 15:15:50 -0800 Subject: [PATCH 196/580] Don't call create on existing volumes Signed-off-by: Joffrey F --- compose/volume.py | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/compose/volume.py b/compose/volume.py index 26fbda96..254c2c28 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import logging -from docker.errors import APIError from docker.errors import NotFound from .config import ConfigurationError @@ -82,12 +81,13 @@ class ProjectVolumes(object): def initialize(self): try: for volume in self.volumes.values(): + volume_exists = volume.exists() if volume.external: log.debug( 'Volume {0} declared as external. No new ' 'volume will be created.'.format(volume.name) ) - if not volume.exists(): + if not volume_exists: raise ConfigurationError( 'Volume {name} declared as external, but could' ' not be found. Please create the volume manually' @@ -97,28 +97,32 @@ class ProjectVolumes(object): ) ) continue - log.info( - 'Creating volume "{0}" with {1} driver'.format( - volume.full_name, volume.driver or 'default' + + if not volume_exists: + log.info( + 'Creating volume "{0}" with {1} driver'.format( + volume.full_name, volume.driver or 'default' + ) ) - ) - volume.create() + volume.create() + else: + driver = volume.inspect()['Driver'] + if 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'] + ) + ) except NotFound: raise ConfigurationError( 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) ) - except APIError as e: - if 'Choose a different volume name' in str(e): - 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'] - ) - ) def namespace_spec(self, volume_spec): if not volume_spec.is_named_volume: From d2b065e6156e502f2d3be4e5d0ca620a20ecb3d3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 7 Mar 2016 18:07:41 -0800 Subject: [PATCH 197/580] Don't raise ConfigurationError for volume driver mismatch when driver is unspecified Add testcase Signed-off-by: Joffrey F --- compose/volume.py | 2 +- tests/integration/project_test.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/compose/volume.py b/compose/volume.py index 254c2c28..17e90087 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -107,7 +107,7 @@ class ProjectVolumes(object): volume.create() else: driver = volume.inspect()['Driver'] - if driver != volume.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 ' diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 8400ba1f..daeb9c81 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -839,6 +839,44 @@ class ProjectTest(DockerClientTestCase): vol_name ) in str(e.exception) + @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) + + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], + volumes={vol_name: {'driver': 'local'}}, + networks={}, + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.volumes.initialize() + + volume_data = self.client.inspect_volume(full_vol_name) + self.assertEqual(volume_data['Name'], full_vol_name) + self.assertEqual(volume_data['Driver'], 'local') + + config_data = config_data._replace( + volumes={vol_name: {}} + ) + project = Project.from_config( + name='composetest', + config_data=config_data, + client=self.client + ) + project.volumes.initialize() + volume_data = self.client.inspect_volume(full_vol_name) + self.assertEqual(volume_data['Name'], full_vol_name) + self.assertEqual(volume_data['Driver'], 'local') + @v2_only() def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() From 88a719b4b685be62a4bcc354a07f9ecd42e1282f Mon Sep 17 00:00:00 2001 From: Louis Tiao Date: Tue, 8 Mar 2016 15:48:42 +1100 Subject: [PATCH 198/580] Fixed indentation level in example. Signed-off-by: Louis Tiao --- docs/overview.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/overview.md b/docs/overview.md index 2efb715a..03ade356 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -46,10 +46,10 @@ A `docker-compose.yml` looks like this: - logvolume01:/var/log links: - redis - redis: - image: redis - volumes: - logvolume01: {} + redis: + image: redis + volumes: + logvolume01: {} For more information about the Compose file, see the [Compose file reference](compose-file.md) From 000eaee16ab19608ee4d96f2ceb48cf24a763d76 Mon Sep 17 00:00:00 2001 From: wenchma Date: Tue, 8 Mar 2016 17:09:28 +0800 Subject: [PATCH 199/580] Update image format for service conf reference Signed-off-by: Wen Cheng Ma --- docs/compose-file.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 24e45160..d8e98fbc 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -59,13 +59,13 @@ optionally [dockerfile](#dockerfile) and [args](#args). args: buildno: 1 -If you specify `image` as well as `build`, then Compose tags the built image -with the tag specified in `image`: +If you specify `image` as well as `build`, then Compose names the built image +with the `webapp` and optional `tag` specified in `image`: build: ./dir - image: webapp + image: webapp:tag -This will result in an image tagged `webapp`, built from `./dir`. +This will result in an image named `webapp` and tagged `tag`, built from `./dir`. > **Note**: In the [version 1 file format](#version-1), `build` is different in > two ways: From e700d7ca6ad4d38cff6ecb5b855c703a98d163e3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 8 Mar 2016 18:15:18 +0100 Subject: [PATCH 200/580] Fix version in docs example Signed-off-by: Aanand Prasad --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 24e45160..d6cb92cf 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -518,7 +518,7 @@ The general format is shown here. In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the `legacy` network. - version: 2 + version: '2' services: web: From 53bea8a72040ad4b96777e9d020029ce363f9ee7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 29 Feb 2016 14:35:23 -0800 Subject: [PATCH 201/580] Refactor command dispatch to improve unit testing and support better error messages. Signed-off-by: Daniel Nephin --- compose/cli/docopt_command.py | 39 ++++++++--------- compose/cli/main.py | 79 +++++++++++++++++++++-------------- tests/unit/cli_test.py | 23 ++++------ 3 files changed, 75 insertions(+), 66 deletions(-) diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 5b50189c..809a4b74 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from __future__ import unicode_literals -import sys from inspect import getdoc from docopt import docopt @@ -15,24 +14,21 @@ def docopt_full_help(docstring, *args, **kwargs): raise SystemExit(docstring) -class DocoptCommand(object): - def docopt_options(self): - return {'options_first': True} +class DocoptDispatcher(object): - def sys_dispatch(self): - self.dispatch(sys.argv[1:]) - - def dispatch(self, argv): - self.perform_command(*self.parse(argv)) + def __init__(self, command_class, options): + self.command_class = command_class + self.options = options def parse(self, argv): - options = docopt_full_help(getdoc(self), argv, **self.docopt_options()) + command_help = getdoc(self.command_class) + options = docopt_full_help(command_help, argv, **self.options) command = options['COMMAND'] if command is None: - raise SystemExit(getdoc(self)) + raise SystemExit(command_help) - handler = self.get_handler(command) + handler = get_handler(self.command_class, command) docstring = getdoc(handler) if docstring is None: @@ -41,17 +37,18 @@ class DocoptCommand(object): command_options = docopt_full_help(docstring, options['ARGS'], options_first=True) return options, handler, command_options - def get_handler(self, command): - command = command.replace('-', '_') - # we certainly want to have "exec" command, since that's what docker client has - # but in python exec is a keyword - if command == "exec": - command = "exec_command" - if not hasattr(self, command): - raise NoSuchCommand(command, self) +def get_handler(command_class, command): + command = command.replace('-', '_') + # we certainly want to have "exec" command, since that's what docker client has + # but in python exec is a keyword + if command == "exec": + command = "exec_command" - return getattr(self, command) + if not hasattr(command_class, command): + raise NoSuchCommand(command, command_class) + + return getattr(command_class, command) class NoSuchCommand(Exception): diff --git a/compose/cli/main.py b/compose/cli/main.py index afb777be..0584bf1a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -3,6 +3,7 @@ from __future__ import print_function from __future__ import unicode_literals import contextlib +import functools import json import logging import re @@ -33,7 +34,8 @@ from ..service import NeedsBuildError from .command import friendly_error_message from .command import get_config_path_from_options from .command import project_from_options -from .docopt_command import DocoptCommand +from .docopt_command import DocoptDispatcher +from .docopt_command import get_handler from .docopt_command import NoSuchCommand from .errors import UserError from .formatter import ConsoleWarningFormatter @@ -52,19 +54,16 @@ console_handler = logging.StreamHandler(sys.stderr) def main(): setup_logging() + command = dispatch() + try: - command = TopLevelCommand() - command.sys_dispatch() + command() except (KeyboardInterrupt, signals.ShutdownException): log.error("Aborting.") sys.exit(1) except (UserError, NoSuchService, ConfigurationError) as e: log.error(e.msg) 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 APIError as e: log_api_error(e) sys.exit(1) @@ -88,6 +87,40 @@ def main(): sys.exit(1) +def dispatch(): + dispatcher = DocoptDispatcher( + TopLevelCommand, + {'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) + + setup_console_handler(console_handler, options.get('--verbose')) + return functools.partial(perform_command, options, handler, command_options) + + +def perform_command(options, handler, command_options): + if options['COMMAND'] in ('help', 'version'): + # Skip looking up the compose file. + handler(command_options) + return + + if options['COMMAND'] == 'config': + command = TopLevelCommand(None) + handler(command, options, command_options) + return + + project = project_from_options('.', options) + command = TopLevelCommand(project) + with friendly_error_message(): + # TODO: use self.project + handler(command, project, command_options) + + def log_api_error(e): if 'client is newer than server' in e.explanation: # we need JSON formatted errors. In the meantime... @@ -134,7 +167,7 @@ def parse_doc_section(name, source): return [s.strip() for s in pattern.findall(source)] -class TopLevelCommand(DocoptCommand): +class TopLevelCommand(object): """Define and run multi-container applications with Docker. Usage: @@ -173,26 +206,8 @@ class TopLevelCommand(DocoptCommand): """ base_dir = '.' - def docopt_options(self): - options = super(TopLevelCommand, self).docopt_options() - options['version'] = get_version_info('compose') - return options - - def perform_command(self, options, handler, command_options): - setup_console_handler(console_handler, options.get('--verbose')) - - if options['COMMAND'] in ('help', 'version'): - # Skip looking up the compose file. - handler(None, command_options) - return - - if options['COMMAND'] == 'config': - handler(options, command_options) - return - - project = project_from_options(self.base_dir, options) - with friendly_error_message(): - handler(project, command_options) + def __init__(self, project): + self.project = project def build(self, project, options): """ @@ -352,13 +367,14 @@ class TopLevelCommand(DocoptCommand): exit_code = project.client.exec_inspect(exec_id).get("ExitCode") sys.exit(exit_code) - def help(self, project, options): + @classmethod + def help(cls, options): """ Get help on a command. Usage: help COMMAND """ - handler = self.get_handler(options['COMMAND']) + handler = get_handler(cls, options['COMMAND']) raise SystemExit(getdoc(handler)) def kill(self, project, options): @@ -739,7 +755,8 @@ class TopLevelCommand(DocoptCommand): print("Aborting on container exit...") project.stop(service_names=service_names, timeout=timeout) - def version(self, project, options): + @classmethod + def version(cls, options): """ Show version informations diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index cbe9ea6f..c609d832 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -64,26 +64,20 @@ class CLITestCase(unittest.TestCase): self.assertTrue(project.client) self.assertTrue(project.services) - def test_help(self): - command = TopLevelCommand() - with self.assertRaises(SystemExit): - command.dispatch(['-h']) - def test_command_help(self): - with self.assertRaises(SystemExit) as ctx: - TopLevelCommand().dispatch(['help', 'up']) + with pytest.raises(SystemExit) as exc: + TopLevelCommand.help({'COMMAND': 'up'}) - self.assertIn('Usage: up', str(ctx.exception)) + assert 'Usage: up' in exc.exconly() def test_command_help_nonexistent(self): - with self.assertRaises(NoSuchCommand): - TopLevelCommand().dispatch(['help', 'nonexistent']) + with pytest.raises(NoSuchCommand): + TopLevelCommand.help({'COMMAND': 'nonexistent'}) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") @mock.patch('compose.cli.main.RunOperation', autospec=True) @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): - command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) project = Project.from_config( name='composetest', @@ -92,6 +86,7 @@ class CLITestCase(unittest.TestCase): 'service': {'image': 'busybox'} }), ) + command = TopLevelCommand(project) with pytest.raises(SystemExit): command.run(project, { @@ -126,7 +121,7 @@ class CLITestCase(unittest.TestCase): }), ) - command = TopLevelCommand() + command = TopLevelCommand(project) command.run(project, { 'SERVICE': 'service', 'COMMAND': None, @@ -147,7 +142,7 @@ class CLITestCase(unittest.TestCase): 'always' ) - command = TopLevelCommand() + command = TopLevelCommand(project) command.run(project, { 'SERVICE': 'service', 'COMMAND': None, @@ -168,7 +163,6 @@ class CLITestCase(unittest.TestCase): ) def test_command_manula_and_service_ports_together(self): - command = TopLevelCommand() project = Project.from_config( name='composetest', client=None, @@ -176,6 +170,7 @@ class CLITestCase(unittest.TestCase): 'service': {'image': 'busybox'}, }), ) + command = TopLevelCommand(project) with self.assertRaises(UserError): command.run(project, { From 9f9dcc098a8f5df7168e8c9574ab2aebe8bf808a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 8 Mar 2016 14:35:54 -0500 Subject: [PATCH 202/580] Make TopLevelCommand use the project field. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 101 ++++++++++++++++++++--------------------- tests/unit/cli_test.py | 8 ++-- 2 files changed, 54 insertions(+), 55 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0584bf1a..b020a14b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -117,8 +117,7 @@ def perform_command(options, handler, command_options): project = project_from_options('.', options) command = TopLevelCommand(project) with friendly_error_message(): - # TODO: use self.project - handler(command, project, command_options) + handler(command, command_options) def log_api_error(e): @@ -204,12 +203,12 @@ class TopLevelCommand(object): up Create and start containers version Show the Docker-Compose version information """ - base_dir = '.' - def __init__(self, project): + def __init__(self, project, project_dir='.'): self.project = project + self.project_dir = '.' - def build(self, project, options): + def build(self, options): """ Build or rebuild services. @@ -224,7 +223,7 @@ class TopLevelCommand(object): --no-cache Do not use cache when building the image. --pull Always attempt to pull a newer version of the image. """ - project.build( + self.project.build( service_names=options['SERVICE'], no_cache=bool(options.get('--no-cache', False)), pull=bool(options.get('--pull', False)), @@ -243,7 +242,7 @@ class TopLevelCommand(object): """ config_path = get_config_path_from_options(config_options) - compose_config = config.load(config.find(self.base_dir, config_path)) + compose_config = config.load(config.find(self.project_dir, config_path)) if options['--quiet']: return @@ -254,7 +253,7 @@ class TopLevelCommand(object): print(serialize_config(compose_config)) - def create(self, project, options): + def create(self, options): """ Creates containers for a service. @@ -270,13 +269,13 @@ class TopLevelCommand(object): """ service_names = options['SERVICE'] - project.create( + self.project.create( service_names=service_names, strategy=convergence_strategy_from_opts(options), do_build=build_action_from_opts(options), ) - def down(self, project, options): + def down(self, options): """ Stop containers and remove containers, networks, volumes, and images created by `up`. Only containers and networks are removed by default. @@ -290,9 +289,9 @@ class TopLevelCommand(object): -v, --volumes Remove data volumes """ image_type = image_type_from_opt('--rmi', options['--rmi']) - project.down(image_type, options['--volumes']) + self.project.down(image_type, options['--volumes']) - def events(self, project, options): + def events(self, options): """ Receive real time events from containers. @@ -311,12 +310,12 @@ class TopLevelCommand(object): event['time'] = event['time'].isoformat() return json.dumps(event) - for event in project.events(): + for event in self.project.events(): formatter = json_format_event if options['--json'] else format_event print(formatter(event)) sys.stdout.flush() - def exec_command(self, project, options): + def exec_command(self, options): """ Execute a command in a running container @@ -332,7 +331,7 @@ class TopLevelCommand(object): instances of a service [default: 1] """ index = int(options.get('--index')) - service = project.get_service(options['SERVICE']) + service = self.project.get_service(options['SERVICE']) try: container = service.get_container(number=index) except ValueError as e: @@ -356,15 +355,15 @@ class TopLevelCommand(object): signals.set_signal_handler_to_shutdown() try: operation = ExecOperation( - project.client, + self.project.client, exec_id, interactive=tty, ) - pty = PseudoTerminal(project.client, operation) + pty = PseudoTerminal(self.project.client, operation) pty.start() except signals.ShutdownException: log.info("received shutdown exception: closing") - exit_code = project.client.exec_inspect(exec_id).get("ExitCode") + exit_code = self.project.client.exec_inspect(exec_id).get("ExitCode") sys.exit(exit_code) @classmethod @@ -377,7 +376,7 @@ class TopLevelCommand(object): handler = get_handler(cls, options['COMMAND']) raise SystemExit(getdoc(handler)) - def kill(self, project, options): + def kill(self, options): """ Force stop service containers. @@ -389,9 +388,9 @@ class TopLevelCommand(object): """ signal = options.get('-s', 'SIGKILL') - project.kill(service_names=options['SERVICE'], signal=signal) + self.project.kill(service_names=options['SERVICE'], signal=signal) - def logs(self, project, options): + def logs(self, options): """ View output from containers. @@ -404,7 +403,7 @@ class TopLevelCommand(object): --tail="all" Number of lines to show from the end of the logs for each container. """ - containers = project.containers(service_names=options['SERVICE'], stopped=True) + containers = self.project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] tail = options['--tail'] @@ -421,16 +420,16 @@ class TopLevelCommand(object): print("Attaching to", list_containers(containers)) LogPrinter(containers, monochrome=monochrome, log_args=log_args).run() - def pause(self, project, options): + def pause(self, options): """ Pause services. Usage: pause [SERVICE...] """ - containers = project.pause(service_names=options['SERVICE']) + containers = self.project.pause(service_names=options['SERVICE']) exit_if(not containers, 'No containers to pause', 1) - def port(self, project, options): + def port(self, options): """ Print the public port for a port binding. @@ -442,7 +441,7 @@ class TopLevelCommand(object): instances of a service [default: 1] """ index = int(options.get('--index')) - service = project.get_service(options['SERVICE']) + service = self.project.get_service(options['SERVICE']) try: container = service.get_container(number=index) except ValueError as e: @@ -451,7 +450,7 @@ class TopLevelCommand(object): options['PRIVATE_PORT'], protocol=options.get('--protocol') or 'tcp') or '') - def ps(self, project, options): + def ps(self, options): """ List containers. @@ -461,8 +460,8 @@ class TopLevelCommand(object): -q Only display IDs """ containers = sorted( - project.containers(service_names=options['SERVICE'], stopped=True) + - project.containers(service_names=options['SERVICE'], one_off=True), + self.project.containers(service_names=options['SERVICE'], stopped=True) + + self.project.containers(service_names=options['SERVICE'], one_off=True), key=attrgetter('name')) if options['-q']: @@ -488,7 +487,7 @@ class TopLevelCommand(object): ]) print(Formatter().table(headers, rows)) - def pull(self, project, options): + def pull(self, options): """ Pulls images for services. @@ -497,12 +496,12 @@ class TopLevelCommand(object): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. """ - project.pull( + self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures') ) - def rm(self, project, options): + def rm(self, options): """ Remove stopped service containers. @@ -517,21 +516,21 @@ class TopLevelCommand(object): -f, --force Don't ask to confirm removal -v Remove volumes associated with containers """ - all_containers = project.containers(service_names=options['SERVICE'], stopped=True) + all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) stopped_containers = [c for c in all_containers if not c.is_running] if len(stopped_containers) > 0: print("Going to remove", list_containers(stopped_containers)) if options.get('--force') \ or yesno("Are you sure? [yN] ", default=False): - project.remove_stopped( + self.project.remove_stopped( service_names=options['SERVICE'], v=options.get('-v', False) ) else: print("No stopped containers") - def run(self, project, options): + def run(self, options): """ Run a one-off command on a service. @@ -560,7 +559,7 @@ class TopLevelCommand(object): -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. """ - service = project.get_service(options['SERVICE']) + service = self.project.get_service(options['SERVICE']) detach = options['-d'] if IS_WINDOWS_PLATFORM and not detach: @@ -608,9 +607,9 @@ class TopLevelCommand(object): if options['--name']: container_options['name'] = options['--name'] - run_one_off_container(container_options, project, service, options) + run_one_off_container(container_options, self.project, service, options) - def scale(self, project, options): + def scale(self, options): """ Set number of containers to run for a service. @@ -636,18 +635,18 @@ class TopLevelCommand(object): except ValueError: raise UserError('Number of containers for service "%s" is not a ' 'number' % service_name) - project.get_service(service_name).scale(num, timeout=timeout) + self.project.get_service(service_name).scale(num, timeout=timeout) - def start(self, project, options): + def start(self, options): """ Start existing containers. Usage: start [SERVICE...] """ - containers = project.start(service_names=options['SERVICE']) + containers = self.project.start(service_names=options['SERVICE']) exit_if(not containers, 'No containers to start', 1) - def stop(self, project, options): + def stop(self, options): """ Stop running containers without removing them. @@ -660,9 +659,9 @@ class TopLevelCommand(object): (default: 10) """ timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) - project.stop(service_names=options['SERVICE'], timeout=timeout) + self.project.stop(service_names=options['SERVICE'], timeout=timeout) - def restart(self, project, options): + def restart(self, options): """ Restart running containers. @@ -673,19 +672,19 @@ class TopLevelCommand(object): (default: 10) """ timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) - containers = project.restart(service_names=options['SERVICE'], timeout=timeout) + containers = self.project.restart(service_names=options['SERVICE'], timeout=timeout) exit_if(not containers, 'No containers to restart', 1) - def unpause(self, project, options): + def unpause(self, options): """ Unpause services. Usage: unpause [SERVICE...] """ - containers = project.unpause(service_names=options['SERVICE']) + containers = self.project.unpause(service_names=options['SERVICE']) exit_if(not containers, 'No containers to unpause', 1) - def up(self, project, options): + def up(self, options): """ Builds, (re)creates, starts, and attaches to containers for a service. @@ -735,8 +734,8 @@ class TopLevelCommand(object): if detached and cascade_stop: raise UserError("--abort-on-container-exit and -d cannot be combined.") - with up_shutdown_context(project, service_names, timeout, detached): - to_attach = project.up( + with up_shutdown_context(self.project, service_names, timeout, detached): + to_attach = self.project.up( service_names=service_names, start_deps=start_deps, strategy=convergence_strategy_from_opts(options), @@ -753,7 +752,7 @@ class TopLevelCommand(object): if cascade_stop: print("Aborting on container exit...") - project.stop(service_names=service_names, timeout=timeout) + self.project.stop(service_names=service_names, timeout=timeout) @classmethod def version(cls, options): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index c609d832..1d7c13e7 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -89,7 +89,7 @@ class CLITestCase(unittest.TestCase): command = TopLevelCommand(project) with pytest.raises(SystemExit): - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -122,7 +122,7 @@ class CLITestCase(unittest.TestCase): ) command = TopLevelCommand(project) - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -143,7 +143,7 @@ class CLITestCase(unittest.TestCase): ) command = TopLevelCommand(project) - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -173,7 +173,7 @@ class CLITestCase(unittest.TestCase): command = TopLevelCommand(project) with self.assertRaises(UserError): - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], From 886328640f5665c337bb9dd1f065cc0e350364f0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 8 Mar 2016 14:42:51 -0500 Subject: [PATCH 203/580] Convert some cli tests to pytest. Signed-off-by: Daniel Nephin --- tests/unit/cli/main_test.py | 67 +++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index e7c52003..9b24776f 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import logging +import pytest + from compose import container from compose.cli.errors import UserError from compose.cli.formatter import ConsoleWarningFormatter @@ -11,7 +13,6 @@ from compose.cli.main import convergence_strategy_from_opts from compose.cli.main import setup_console_handler from compose.service import ConvergenceStrategy from tests import mock -from tests import unittest def mock_container(service, number): @@ -22,7 +23,14 @@ def mock_container(service, number): name_without_project='{0}_{1}'.format(service, number)) -class CLIMainTestCase(unittest.TestCase): +@pytest.fixture +def logging_handler(): + stream = mock.Mock() + stream.isatty.return_value = True + return logging.StreamHandler(stream=stream) + + +class TestCLIMainTestCase(object): def test_build_log_printer(self): containers = [ @@ -34,7 +42,7 @@ class CLIMainTestCase(unittest.TestCase): ] service_names = ['web', 'db'] log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) - self.assertEqual(log_printer.containers, containers[:3]) + assert log_printer.containers == containers[:3] def test_build_log_printer_all_services(self): containers = [ @@ -44,58 +52,53 @@ class CLIMainTestCase(unittest.TestCase): ] service_names = [] log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) - self.assertEqual(log_printer.containers, containers) + assert log_printer.containers == containers -class SetupConsoleHandlerTestCase(unittest.TestCase): +class TestSetupConsoleHandlerTestCase(object): - def setUp(self): - self.stream = mock.Mock() - self.stream.isatty.return_value = True - self.handler = logging.StreamHandler(stream=self.stream) + def test_with_tty_verbose(self, logging_handler): + setup_console_handler(logging_handler, True) + assert type(logging_handler.formatter) == ConsoleWarningFormatter + assert '%(name)s' in logging_handler.formatter._fmt + assert '%(funcName)s' in logging_handler.formatter._fmt - def test_with_tty_verbose(self): - setup_console_handler(self.handler, True) - assert type(self.handler.formatter) == ConsoleWarningFormatter - assert '%(name)s' in self.handler.formatter._fmt - assert '%(funcName)s' in self.handler.formatter._fmt + def test_with_tty_not_verbose(self, logging_handler): + setup_console_handler(logging_handler, False) + assert type(logging_handler.formatter) == ConsoleWarningFormatter + assert '%(name)s' not in logging_handler.formatter._fmt + assert '%(funcName)s' not in logging_handler.formatter._fmt - def test_with_tty_not_verbose(self): - setup_console_handler(self.handler, False) - assert type(self.handler.formatter) == ConsoleWarningFormatter - assert '%(name)s' not in self.handler.formatter._fmt - assert '%(funcName)s' not in self.handler.formatter._fmt - - def test_with_not_a_tty(self): - self.stream.isatty.return_value = False - setup_console_handler(self.handler, False) - assert type(self.handler.formatter) == logging.Formatter + def test_with_not_a_tty(self, logging_handler): + logging_handler.stream.isatty.return_value = False + setup_console_handler(logging_handler, False) + assert type(logging_handler.formatter) == logging.Formatter -class ConvergeStrategyFromOptsTestCase(unittest.TestCase): +class TestConvergeStrategyFromOptsTestCase(object): def test_invalid_opts(self): options = {'--force-recreate': True, '--no-recreate': True} - with self.assertRaises(UserError): + with pytest.raises(UserError): convergence_strategy_from_opts(options) def test_always(self): options = {'--force-recreate': True, '--no-recreate': False} - self.assertEqual( - convergence_strategy_from_opts(options), + assert ( + convergence_strategy_from_opts(options) == ConvergenceStrategy.always ) def test_never(self): options = {'--force-recreate': False, '--no-recreate': True} - self.assertEqual( - convergence_strategy_from_opts(options), + assert ( + convergence_strategy_from_opts(options) == ConvergenceStrategy.never ) def test_changed(self): options = {'--force-recreate': False, '--no-recreate': False} - self.assertEqual( - convergence_strategy_from_opts(options), + assert ( + convergence_strategy_from_opts(options) == ConvergenceStrategy.changed ) From 0a091055d24bec9501e918353fe1c5009f88dc0c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 8 Mar 2016 15:39:11 -0500 Subject: [PATCH 204/580] Improve handling of connection errors and error messages. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 35 ++------- compose/cli/errors.py | 125 +++++++++++++++++++++++++-------- compose/cli/main.py | 39 ++-------- tests/unit/cli/command_test.py | 16 ----- tests/unit/cli/errors_test.py | 51 ++++++++++++++ 5 files changed, 153 insertions(+), 113 deletions(-) create mode 100644 tests/unit/cli/errors_test.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 98de2104..55f6df01 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -1,52 +1,25 @@ from __future__ import absolute_import from __future__ import unicode_literals -import contextlib import logging import os import re import six -from requests.exceptions import ConnectionError -from requests.exceptions import SSLError -from . import errors from . import verbose_proxy from .. import config from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client -from .utils import call_silently from .utils import get_version_info -from .utils import is_mac -from .utils import is_ubuntu log = logging.getLogger(__name__) -@contextlib.contextmanager -def friendly_error_message(): - try: - yield - except SSLError as e: - raise errors.UserError('SSL error: %s' % e) - except ConnectionError: - if call_silently(['which', 'docker']) != 0: - if is_mac(): - raise errors.DockerNotFoundMac() - elif is_ubuntu(): - raise errors.DockerNotFoundUbuntu() - else: - raise errors.DockerNotFoundGeneric() - elif call_silently(['which', 'docker-machine']) == 0: - raise errors.ConnectionErrorDockerMachine() - else: - raise errors.ConnectionErrorGeneric(get_client().base_url) - - -def project_from_options(base_dir, options): +def project_from_options(project_dir, options): return get_project( - base_dir, + project_dir, get_config_path_from_options(options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), @@ -76,8 +49,8 @@ def get_client(verbose=False, version=None): return client -def get_project(base_dir, config_path=None, project_name=None, verbose=False): - config_details = config.find(base_dir, config_path) +def get_project(project_dir, config_path=None, project_name=None, verbose=False): + config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 03d6a50c..a16cad2f 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -1,10 +1,27 @@ from __future__ import absolute_import from __future__ import unicode_literals +import contextlib +import logging from textwrap import dedent +from docker.errors import APIError +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import ReadTimeout +from requests.exceptions import SSLError + +from ..const import API_VERSION_TO_ENGINE_VERSION +from ..const import HTTP_TIMEOUT +from .utils import call_silently +from .utils import is_mac +from .utils import is_ubuntu + + +log = logging.getLogger(__name__) + class UserError(Exception): + def __init__(self, msg): self.msg = dedent(msg).strip() @@ -14,44 +31,90 @@ class UserError(Exception): __str__ = __unicode__ -class DockerNotFoundMac(UserError): - def __init__(self): - super(DockerNotFoundMac, self).__init__(""" - Couldn't connect to Docker daemon. You might need to install docker-osx: - - https://github.com/noplay/docker-osx - """) +class ConnectionError(Exception): + pass -class DockerNotFoundUbuntu(UserError): - def __init__(self): - super(DockerNotFoundUbuntu, self).__init__(""" - Couldn't connect to Docker daemon. You might need to install Docker: - - https://docs.docker.com/engine/installation/ubuntulinux/ - """) +@contextlib.contextmanager +def handle_connection_errors(client): + try: + yield + except SSLError as e: + log.error('SSL error: %s' % e) + raise ConnectionError() + except RequestsConnectionError: + if call_silently(['which', 'docker']) != 0: + if is_mac(): + exit_with_error(docker_not_found_mac) + if is_ubuntu(): + exit_with_error(docker_not_found_ubuntu) + exit_with_error(docker_not_found_generic) + if call_silently(['which', 'docker-machine']) == 0: + exit_with_error(conn_error_docker_machine) + exit_with_error(conn_error_generic.format(url=client.base_url)) + except APIError as e: + log_api_error(e, client.api_version) + raise ConnectionError() + except ReadTimeout as e: + log.error( + "An HTTP request took too long to complete. Retry with --verbose to " + "obtain debug information.\n" + "If you encounter this issue regularly because of slow network " + "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher " + "value (current value: %s)." % HTTP_TIMEOUT) + raise ConnectionError() -class DockerNotFoundGeneric(UserError): - def __init__(self): - super(DockerNotFoundGeneric, self).__init__(""" - Couldn't connect to Docker daemon. You might need to install Docker: +def log_api_error(e, client_version): + if 'client is newer than server' not in e.explanation: + log.error(e.explanation) + return - https://docs.docker.com/engine/installation/ - """) + version = API_VERSION_TO_ENGINE_VERSION.get(client_version) + if not version: + # They've set a custom API version + log.error(e.explanation) + return + + log.error( + "The Docker Engine version is less than the minimum required by " + "Compose. Your current project requires a Docker Engine of " + "version {version} or greater.".format(version=version)) -class ConnectionErrorDockerMachine(UserError): - def __init__(self): - super(ConnectionErrorDockerMachine, self).__init__(""" - Couldn't connect to Docker daemon - you might need to run `docker-machine start default`. - """) +def exit_with_error(msg): + log.error(dedent(msg).strip()) + raise ConnectionError() -class ConnectionErrorGeneric(UserError): - def __init__(self, url): - super(ConnectionErrorGeneric, self).__init__(""" - Couldn't connect to Docker daemon at %s - is it running? +docker_not_found_mac = """ + Couldn't connect to Docker daemon. You might need to install docker-osx: - If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable. - """ % url) + https://github.com/noplay/docker-osx +""" + + +docker_not_found_ubuntu = """ + Couldn't connect to Docker daemon. You might need to install Docker: + + https://docs.docker.com/engine/installation/ubuntulinux/ +""" + + +docker_not_found_generic = """ + Couldn't connect to Docker daemon. You might need to install Docker: + + https://docs.docker.com/engine/installation/ +""" + + +conn_error_docker_machine = """ + Couldn't connect to Docker daemon - you might need to run `docker-machine start default`. +""" + + +conn_error_generic = """ + Couldn't connect to Docker daemon at {url} - is it running? + + If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable. +""" diff --git a/compose/cli/main.py b/compose/cli/main.py index b020a14b..66362168 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -11,18 +11,14 @@ import sys from inspect import getdoc from operator import attrgetter -from docker.errors import APIError -from requests.exceptions import ReadTimeout - +from . import errors from . import signals from .. import __version__ from ..config import config from ..config import ConfigurationError from ..config import parse_environment from ..config.serialize import serialize_config -from ..const import API_VERSION_TO_ENGINE_VERSION from ..const import DEFAULT_TIMEOUT -from ..const import HTTP_TIMEOUT from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError from ..project import NoSuchService @@ -31,7 +27,6 @@ from ..service import BuildError from ..service import ConvergenceStrategy from ..service import ImageType from ..service import NeedsBuildError -from .command import friendly_error_message from .command import get_config_path_from_options from .command import project_from_options from .docopt_command import DocoptDispatcher @@ -53,7 +48,6 @@ console_handler = logging.StreamHandler(sys.stderr) def main(): - setup_logging() command = dispatch() try: @@ -64,9 +58,6 @@ def main(): except (UserError, NoSuchService, ConfigurationError) as e: log.error(e.msg) sys.exit(1) - except APIError as e: - log_api_error(e) - sys.exit(1) except BuildError as e: log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason)) sys.exit(1) @@ -76,18 +67,12 @@ def main(): except NeedsBuildError as e: log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name) sys.exit(1) - except ReadTimeout as e: - log.error( - "An HTTP request took too long to complete. Retry with --verbose to " - "obtain debug information.\n" - "If you encounter this issue regularly because of slow network " - "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher " - "value (current value: %s)." % HTTP_TIMEOUT - ) + except errors.ConnectionError: sys.exit(1) def dispatch(): + setup_logging() dispatcher = DocoptDispatcher( TopLevelCommand, {'options_first': True, 'version': get_version_info('compose')}) @@ -116,26 +101,10 @@ def perform_command(options, handler, command_options): project = project_from_options('.', options) command = TopLevelCommand(project) - with friendly_error_message(): + with errors.handle_connection_errors(project.client): handler(command, command_options) -def log_api_error(e): - if 'client is newer than server' in e.explanation: - # we need JSON formatted errors. In the meantime... - # TODO: fix this by refactoring project dispatch - # http://github.com/docker/compose/pull/2832#commitcomment-15923800 - client_version = e.explanation.split('client API version: ')[1].split(',')[0] - log.error( - "The engine version is lesser than the minimum required by " - "compose. Your current project requires a Docker Engine of " - "version {version} or superior.".format( - version=API_VERSION_TO_ENGINE_VERSION[client_version] - )) - else: - log.error(e.explanation) - - def setup_logging(): root_logger = logging.getLogger() root_logger.addHandler(console_handler) diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 1ca671fe..b524a5f3 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -4,28 +4,12 @@ from __future__ import unicode_literals import os import pytest -from requests.exceptions import ConnectionError -from compose.cli import errors -from compose.cli.command import friendly_error_message from compose.cli.command import get_config_path_from_options from compose.const import IS_WINDOWS_PLATFORM from tests import mock -class TestFriendlyErrorMessage(object): - - def test_dispatch_generic_connection_error(self): - with pytest.raises(errors.ConnectionErrorGeneric): - with mock.patch( - 'compose.cli.command.call_silently', - autospec=True, - side_effect=[0, 1] - ): - with friendly_error_message(): - raise ConnectionError() - - class TestGetConfigPathFromOptions(object): def test_path_from_options(self): diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py new file mode 100644 index 00000000..a99356b4 --- /dev/null +++ b/tests/unit/cli/errors_test.py @@ -0,0 +1,51 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import pytest +from docker.errors import APIError +from requests.exceptions import ConnectionError + +from compose.cli import errors +from compose.cli.errors import handle_connection_errors +from tests import mock + + +@pytest.yield_fixture +def mock_logging(): + with mock.patch('compose.cli.errors.log', autospec=True) as mock_log: + yield mock_log + + +def patch_call_silently(side_effect): + return mock.patch( + 'compose.cli.errors.call_silently', + autospec=True, + side_effect=side_effect) + + +class TestHandleConnectionErrors(object): + + def test_generic_connection_error(self, mock_logging): + with pytest.raises(errors.ConnectionError): + with patch_call_silently([0, 1]): + with handle_connection_errors(mock.Mock()): + raise ConnectionError() + + _, args, _ = mock_logging.error.mock_calls[0] + assert "Couldn't connect to Docker daemon at" in args[0] + + def test_api_error_version_mismatch(self, mock_logging): + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise APIError(None, None, "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): + msg = "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) From ee136446a2e5d1a2b108f586e872f40d801485d6 Mon Sep 17 00:00:00 2001 From: Matt Daue Date: Tue, 23 Feb 2016 21:19:11 -0500 Subject: [PATCH 205/580] Fix #2804: Add ipv4 and ipv6 static addressing - Added ipv4_network and ipv6_network to the networks section in the service section for each configured network - Added feature documentation - Added unit tests Signed-off-by: Matt Daue --- compose/config/config_schema_v2.0.json | 4 +- compose/network.py | 10 +- compose/service.py | 9 +- docs/networking.md | 24 +++++ requirements.txt | 2 +- tests/acceptance/cli_test.py | 24 +++++ .../networks/network-static-addresses.yml | 23 +++++ tests/integration/project_test.py | 91 +++++++++++++++++++ 8 files changed, 178 insertions(+), 9 deletions(-) create mode 100755 tests/fixtures/networks/network-static-addresses.yml diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index a4a30a5f..33afc9b2 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -152,7 +152,9 @@ { "type": "object", "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"} + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/network.py b/compose/network.py index 135502cc..81e3b5bf 100644 --- a/compose/network.py +++ b/compose/network.py @@ -159,26 +159,26 @@ class ProjectNetworks(object): network.ensure() -def get_network_aliases_for_service(service_dict): +def get_network_defs_for_service(service_dict): if 'network_mode' in service_dict: return {} networks = service_dict.get('networks', {'default': None}) return dict( - (net, (config or {}).get('aliases', [])) + (net, (config or {})) for net, config in networks.items() ) def get_network_names_for_service(service_dict): - return get_network_aliases_for_service(service_dict).keys() + return get_network_defs_for_service(service_dict).keys() def get_networks(service_dict, network_definitions): networks = {} - for name, aliases in get_network_aliases_for_service(service_dict).items(): + for name, netdef in get_network_defs_for_service(service_dict).items(): network = network_definitions.get(name) if network: - networks[network.full_name] = aliases + networks[network.full_name] = netdef else: raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' diff --git a/compose/service.py b/compose/service.py index 7ee441f2..fad1c4d9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -451,7 +451,10 @@ class Service(object): def connect_container_to_networks(self, container): connected_networks = container.get('NetworkSettings.Networks') - for network, aliases in self.networks.items(): + for network, netdefs in self.networks.items(): + aliases = netdefs.get('aliases', []) + ipv4_address = netdefs.get('ipv4_address', None) + ipv6_address = netdefs.get('ipv6_address', None) if network in connected_networks: self.client.disconnect_container_from_network( container.id, network) @@ -459,7 +462,9 @@ class Service(object): self.client.connect_container_to_network( container.id, network, aliases=list(self._get_aliases(container).union(aliases)), - links=self._get_links(False), + ipv4_address=ipv4_address, + ipv6_address=ipv6_address, + links=self._get_links(False) ) def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): diff --git a/docs/networking.md b/docs/networking.md index 1fd6c116..e38e5690 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -116,6 +116,30 @@ Here's an example Compose file defining two custom networks. The `proxy` service foo: "1" bar: "2" +Networks can be configured with static IP addresses by setting the ipv4_address and/or ipv6_address for each attached network. The corresponding `network` section must have an `ipam` config entry with subnet and gateway configurations for each static address. If IPv6 addressing is desired, the `com.docker.network.enable_ipv6` driver option must be set to `true`. An example: + + version: '2' + + services: + app: + networks: + app_net: + ipv4_address: 172.16.238.10 + ipv6_address: 2001:3984:3989::10 + + networks: + app_net: + driver: bridge + driver_opts: + com.docker.network.enable_ipv6: "true" + ipam: + driver: default + config: + - subnet: 172.16.238.0/24 + gateway: 172.16.238.1 + - subnet: 2001:3984:3989::/64 + gateway: 2001:3984:3989::1 + For full details of the network configuration options available, see the following references: - [Top-level `networks` key](compose-file.md#network-configuration-reference) diff --git a/requirements.txt b/requirements.txt index b31840c8..074864d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.2.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@81d8caaf36159bf1accd86eab2e157bf8dd071a9#egg=docker-py +git+https://github.com/docker/docker-py.git@d8be3e0fce60fbe25be088b64bccbcee83effdb1#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 24682125..c94578a1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -475,6 +475,30 @@ class CLITestCase(DockerClientTestCase): assert 'forward_facing' in front_aliases assert 'ahead' in front_aliases + @v2_only() + def test_up_with_network_static_addresses(self): + filename = 'network-static-addresses.yml' + ipv4_address = '172.16.100.100' + ipv6_address = 'fe80::1001:100' + self.base_dir = 'tests/fixtures/networks' + self.dispatch(['-f', filename, 'up', '-d'], None) + static_net = '{}_static_test'.format(self.project.name) + + networks = [ + n for n in self.client.networks() + if n['Name'].startswith('{}_'.format(self.project.name)) + ] + + # One networks was created: front + assert sorted(n['Name'] for n in networks) == [static_net] + web_container = self.project.get_service('web').containers()[0] + + ipam_config = web_container.get( + 'NetworkSettings.Networks.{}.IPAMConfig'.format(static_net) + ) + assert ipv4_address in ipam_config.values() + assert ipv6_address in ipam_config.values() + @v2_only() def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' diff --git a/tests/fixtures/networks/network-static-addresses.yml b/tests/fixtures/networks/network-static-addresses.yml new file mode 100755 index 00000000..f820ff6a --- /dev/null +++ b/tests/fixtures/networks/network-static-addresses.yml @@ -0,0 +1,23 @@ +version: "2" + +services: + web: + image: busybox + command: top + networks: + static_test: + ipv4_address: 172.16.100.100 + ipv6_address: fe80::1001:100 + +networks: + static_test: + driver: bridge + driver_opts: + com.docker.network.enable_ipv6: "true" + ipam: + driver: default + config: + - subnet: 172.16.100.0/24 + gateway: 172.16.100.1 + - subnet: fe80::/64 + gateway: fe80::1001:1 diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index daeb9c81..710da9a3 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -5,6 +5,7 @@ import random import py import pytest +from docker.errors import APIError from docker.errors import NotFound from ..helpers import build_config @@ -650,6 +651,96 @@ class ProjectTest(DockerClientTestCase): }], } + @v2_only() + def test_up_with_network_static_addresses(self): + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': { + 'static_test': { + 'ipv4_address': '172.16.100.100', + 'ipv6_address': 'fe80::1001:102' + } + }, + }], + volumes={}, + networks={ + 'static_test': { + 'driver': 'bridge', + 'driver_opts': { + "com.docker.network.enable_ipv6": "true", + }, + 'ipam': { + 'driver': 'default', + 'config': [ + {"subnet": "172.16.100.0/24", + "gateway": "172.16.100.1"}, + {"subnet": "fe80::/64", + "gateway": "fe80::1001:1"} + ] + } + } + } + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + project.up() + + network = self.client.networks(names=['static_test'])[0] + service_container = project.get_service('web').containers()[0] + + assert network['Options'] == { + "com.docker.network.enable_ipv6": "true" + } + + IPAMConfig = (service_container.inspect().get('NetworkSettings', {}). + get('Networks', {}).get('composetest_static_test', {}). + get('IPAMConfig', {})) + assert IPAMConfig.get('IPv4Address') == '172.16.100.100' + assert IPAMConfig.get('IPv6Address') == 'fe80::1001:102' + + @v2_only() + def test_up_with_network_static_addresses_missing_subnet(self): + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': { + 'static_test': { + 'ipv4_address': '172.16.100.100', + 'ipv6_address': 'fe80::1001:101' + } + }, + }], + volumes={}, + networks={ + 'static_test': { + 'driver': 'bridge', + 'driver_opts': { + "com.docker.network.enable_ipv6": "true", + }, + 'ipam': { + 'driver': 'default', + }, + }, + }, + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + + with self.assertRaises(APIError): + project.up() + @v2_only() def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) From 1485a56c758ff77ea5bab07bf9d4b0ac3efb2472 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 9 Mar 2016 14:58:34 -0800 Subject: [PATCH 206/580] Better Compose in production docs The Compose/Swarm integration has been working really well for users, so it seems pretty safe to remove the scary warnings about it not being ready. Signed-off-by: Ben Firshman --- docs/production.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/production.md b/docs/production.md index 40ce1e66..9acf64e5 100644 --- a/docs/production.md +++ b/docs/production.md @@ -12,13 +12,18 @@ weight=22 ## Using Compose in production -> Compose is still primarily aimed at development and testing environments. -> Compose may be used for smaller production deployments, but is probably -> not yet suitable for larger deployments. +When you define your app with Compose in development, you can use this +definition to run your application in different environments such as CI, +staging, and production. -When deploying to production, you'll almost certainly want to make changes to -your app configuration that are more appropriate to a live environment. These -changes may include: +The easiest way to deploy an application is to run it on a single server, +similar to how you would run your development environment. If you want to scale +up your application, you can run Compose apps on a Swarm cluster. + +### Modify your Compose file for production + +You'll almost certainly want to make changes to your app configuration that are +more appropriate to a live environment. These changes may include: - Removing any volume bindings for application code, so that code stays inside the container and can't be changed from outside @@ -73,8 +78,8 @@ commands will work with no further configuration. system, exposes the same API as a single Docker host, which means you can use Compose against a Swarm instance and run your apps across multiple hosts. -Compose/Swarm integration is still in the experimental stage, but if you'd like -to explore and experiment, check out the [integration guide](swarm.md). +Read more about the Compose/Swarm integration in the +[integration guide](swarm.md). ## Compose documentation From f933381a1253f5195406f80be746812a5bfa45a7 Mon Sep 17 00:00:00 2001 From: Ilya Skriblovsky Date: Thu, 10 Mar 2016 23:32:15 +0300 Subject: [PATCH 207/580] Dependency-ordered start/stop/up Signed-off-by: Ilya Skriblovsky --- compose/parallel.py | 106 +++++++++++++++++++++++------------ compose/project.py | 60 +++++++++++++++++--- compose/service.py | 5 +- tests/acceptance/cli_test.py | 3 +- 4 files changed, 128 insertions(+), 46 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 4810a106..439f0f44 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -14,68 +14,98 @@ from compose.cli.signals import ShutdownException from compose.utils import get_output_stream -def perform_operation(func, arg, callback, index): - try: - callback((index, func(arg))) - except Exception as e: - callback((index, e)) +def parallel_execute(objects, func, get_name, msg, get_deps=None): + """Runs func on objects in parallel while ensuring that func is + ran on object only after it is ran on all its dependencies. - -def parallel_execute(objects, func, index_func, msg): - """For a given list of objects, call the callable passing in the first - object we give it. + get_deps called on object must return a collection with its dependencies. + get_name called on object must return its name. """ objects = list(objects) stream = get_output_stream(sys.stderr) + writer = ParallelStreamWriter(stream, msg) - q = setup_queue(writer, objects, func, index_func) + for obj in objects: + writer.initialize(get_name(obj)) + + q = setup_queue(objects, func, get_deps, get_name) done = 0 errors = {} + error_to_reraise = None + returned = [None] * len(objects) while done < len(objects): try: - msg_index, result = q.get(timeout=1) + obj, result, exception = q.get(timeout=1) except Empty: continue # See https://github.com/docker/compose/issues/189 except thread.error: raise ShutdownException() - if isinstance(result, APIError): - errors[msg_index] = "error", result.explanation - writer.write(msg_index, 'error') - elif isinstance(result, Exception): - errors[msg_index] = "unexpected_exception", result + if exception is None: + writer.write(get_name(obj), 'done') + returned[objects.index(obj)] = result + elif isinstance(exception, APIError): + errors[get_name(obj)] = exception.explanation + writer.write(get_name(obj), 'error') else: - writer.write(msg_index, 'done') + errors[get_name(obj)] = exception + error_to_reraise = exception + done += 1 - if not errors: - return + for obj_name, error in errors.items(): + stream.write("\nERROR: for {} {}\n".format(obj_name, error)) - stream.write("\n") - for msg_index, (result, error) in errors.items(): - stream.write("ERROR: for {} {} \n".format(msg_index, error)) - if result == 'unexpected_exception': - raise error + if error_to_reraise: + raise error_to_reraise + + return returned -def setup_queue(writer, objects, func, index_func): - for obj in objects: - writer.initialize(index_func(obj)) +def _no_deps(x): + return [] - q = Queue() - # TODO: limit the number of threads #1828 - for obj in objects: - t = Thread( - target=perform_operation, - args=(func, obj, q.put, index_func(obj))) - t.daemon = True - t.start() +def setup_queue(objects, func, get_deps, get_name): + if get_deps is None: + get_deps = _no_deps - return q + results = Queue() + + started = set() # objects, threads were started for + finished = set() # already finished objects + + def do_op(obj): + try: + result = func(obj) + results.put((obj, result, None)) + except Exception as e: + results.put((obj, None, e)) + + finished.add(obj) + feed() + + def ready(obj): + # Is object ready for performing operation + return obj not in started and all( + dep not in objects or dep in finished + for dep in get_deps(obj) + ) + + def feed(): + ready_objects = [o for o in objects if ready(o)] + for obj in ready_objects: + started.add(obj) + t = Thread(target=do_op, + args=(obj,)) + t.daemon = True + t.start() + + feed() + return results class ParallelStreamWriter(object): @@ -91,11 +121,15 @@ class ParallelStreamWriter(object): self.lines = [] def initialize(self, obj_index): + if self.msg is None: + return self.lines.append(obj_index) self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) self.stream.flush() def write(self, obj_index, status): + if self.msg is None: + return position = self.lines.index(obj_index) diff = len(self.lines) - position # move up diff --git a/compose/project.py b/compose/project.py index c964417f..3de68b2c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import datetime import logging +import operator from functools import reduce from docker.errors import APIError @@ -200,13 +201,40 @@ class Project(object): def start(self, service_names=None, **options): containers = [] - for service in self.get_services(service_names): - service_containers = service.start(**options) + + def start_service(service): + service_containers = service.start(quiet=True, **options) containers.extend(service_containers) + + services = self.get_services(service_names) + + def get_deps(service): + return {self.get_service(dep) for dep in service.get_dependency_names()} + + parallel.parallel_execute( + services, + start_service, + operator.attrgetter('name'), + 'Starting', + get_deps) + return containers def stop(self, service_names=None, **options): - parallel.parallel_stop(self.containers(service_names), options) + containers = self.containers(service_names) + + def get_deps(container): + # actually returning inversed dependencies + return {other for other in containers + if container.service in + self.get_service(other.service).get_dependency_names()} + + parallel.parallel_execute( + containers, + operator.methodcaller('stop', **options), + operator.attrgetter('name'), + 'Stopping', + get_deps) def pause(self, service_names=None, **options): containers = self.containers(service_names) @@ -314,15 +342,33 @@ class Project(object): include_deps=start_deps) plans = self._get_convergence_plans(services, strategy) - return [ - container - for service in services - for container in service.execute_convergence_plan( + + for svc in services: + svc.ensure_image_exists(do_build=do_build) + + def do(service): + return service.execute_convergence_plan( plans[service.name], do_build=do_build, timeout=timeout, detached=detached ) + + def get_deps(service): + return {self.get_service(dep) for dep in service.get_dependency_names()} + + results = parallel.parallel_execute( + services, + do, + operator.attrgetter('name'), + None, + get_deps + ) + return [ + container + for svc_containers in results + if svc_containers is not None + for container in svc_containers ] def initialize(self): diff --git a/compose/service.py b/compose/service.py index fad1c4d9..30d28e4c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -436,9 +436,10 @@ class Service(object): container.remove() return new_container - def start_container_if_stopped(self, container, attach_logs=False): + def start_container_if_stopped(self, container, attach_logs=False, quiet=False): if not container.is_running: - log.info("Starting %s" % container.name) + if not quiet: + log.info("Starting %s" % container.name) if attach_logs: container.attach_log_stream() return self.start_container(container) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c94578a1..825b97be 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -8,6 +8,7 @@ import shlex import signal import subprocess import time +from collections import Counter from collections import namedtuple from operator import attrgetter @@ -1346,7 +1347,7 @@ class CLITestCase(DockerClientTestCase): os.kill(events_proc.pid, signal.SIGINT) result = wait_on_process(events_proc, returncode=1) lines = [json.loads(line) for line in result.stdout.rstrip().split('\n')] - assert [e['action'] for e in lines] == ['create', 'start', 'create', 'start'] + assert Counter(e['action'] for e in lines) == {'create': 2, 'start': 2} def test_events_human_readable(self): events_proc = start_process(self.base_dir, ['events']) From 5df774bd10b68f801368548609ab36ff9eb4885f Mon Sep 17 00:00:00 2001 From: Ilya Skriblovsky Date: Fri, 11 Mar 2016 12:59:24 +0300 Subject: [PATCH 208/580] Fixed testing error handling by `up` Signed-off-by: Ilya Skriblovsky --- tests/integration/project_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 710da9a3..393f4f11 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -5,7 +5,6 @@ import random import py import pytest -from docker.errors import APIError from docker.errors import NotFound from ..helpers import build_config @@ -738,8 +737,7 @@ class ProjectTest(DockerClientTestCase): config_data=config_data, ) - with self.assertRaises(APIError): - project.up() + assert len(project.up()) == 0 @v2_only() def test_project_up_volumes(self): From 34de1f0a4ca5ea8ba404b4ca34a0a488d2fccb9c Mon Sep 17 00:00:00 2001 From: Ilya Skriblovsky Date: Mon, 14 Mar 2016 22:56:58 +0300 Subject: [PATCH 209/580] Removed unused parallel.parallel_stop Signed-off-by: Ilya Skriblovsky --- compose/parallel.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 439f0f44..879d183e 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -155,10 +155,6 @@ def parallel_remove(containers, options): parallel_operation(stopped_containers, 'remove', options, 'Removing') -def parallel_stop(containers, options): - parallel_operation(containers, 'stop', options, 'Stopping') - - def parallel_start(containers, options): parallel_operation(containers, 'start', options, 'Starting') From 65797558f8740fb2bab5333395e903264a4f1042 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Mar 2016 17:44:25 -0500 Subject: [PATCH 210/580] Refactor log printing to support containers that are started later. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 174 ++++++++++++++++++++++------- compose/cli/multiplexer.py | 66 ----------- compose/project.py | 3 +- tests/unit/cli/log_printer_test.py | 39 +++++++ tests/unit/multiplexer_test.py | 61 ---------- tests/unit/project_test.py | 3 + 6 files changed, 176 insertions(+), 170 deletions(-) delete mode 100644 compose/cli/multiplexer.py delete mode 100644 tests/unit/multiplexer_test.py diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 326676ba..29a6159d 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -3,66 +3,127 @@ from __future__ import unicode_literals import sys from itertools import cycle +from threading import Thread + +from six.moves import _thread as thread +from six.moves.queue import Empty +from six.moves.queue import Queue from . import colors -from .multiplexer import Multiplexer from compose import utils +from compose.cli.signals import ShutdownException from compose.utils import split_buffer +STOP = object() + + +class LogPresenter(object): + + def __init__(self, prefix_width, color_func): + self.prefix_width = prefix_width + self.color_func = color_func + + def present(self, container, line): + prefix = container.name_without_project.ljust(self.prefix_width) + return '{prefix} {line}'.format( + prefix=self.color_func(prefix + ' |'), + line=line) + + +def build_log_presenters(service_names, monochrome): + """Return an iterable of functions. + + Each function can be used to format the logs output of a container. + """ + prefix_width = max_name_width(service_names) + + def no_color(text): + return text + + for color_func in cycle([no_color] if monochrome else colors.rainbow()): + yield LogPresenter(prefix_width, color_func) + + +def max_name_width(service_names, max_index_width=3): + """Calculate the maximum width of container names so we can make the log + prefixes line up like so: + + db_1 | Listening + web_1 | Listening + """ + return max(len(name) for name in service_names) + max_index_width + + class LogPrinter(object): """Print logs from many containers to a single output stream.""" def __init__(self, containers, + presenters, + event_stream, output=sys.stdout, - monochrome=False, cascade_stop=False, log_args=None): - log_args = log_args or {} self.containers = containers + self.presenters = presenters + self.event_stream = event_stream self.output = utils.get_output_stream(output) - self.monochrome = monochrome self.cascade_stop = cascade_stop - self.log_args = log_args + self.log_args = log_args or {} def run(self): if not self.containers: return - prefix_width = max_name_width(self.containers) - generators = list(self._make_log_generators(self.monochrome, prefix_width)) - for line in Multiplexer(generators, cascade_stop=self.cascade_stop).loop(): + queue = Queue() + thread_args = queue, self.log_args + thread_map = build_thread_map(self.containers, self.presenters, thread_args) + start_producer_thread( + thread_map, + self.event_stream, + self.presenters, + thread_args) + + for line in consume_queue(queue, self.cascade_stop): self.output.write(line) self.output.flush() - def _make_log_generators(self, monochrome, prefix_width): - def no_color(text): - return text - - if monochrome: - color_funcs = cycle([no_color]) - else: - color_funcs = cycle(colors.rainbow()) - - for color_func, container in zip(color_funcs, self.containers): - generator_func = get_log_generator(container) - prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func, self.log_args) + # TODO: this needs more logic + # TODO: does consume_queue need to yield Nones to get to this point? + if not thread_map: + return -def build_log_prefix(container, prefix_width): - return container.name_without_project.ljust(prefix_width) + ' | ' +def build_thread_map(initial_containers, presenters, thread_args): + def build_thread(container): + tailer = Thread( + target=tail_container_logs, + args=(container, presenters.next()) + thread_args) + tailer.daemon = True + tailer.start() + return tailer + + return { + container.id: build_thread(container) + for container in initial_containers + } -def max_name_width(containers): - """Calculate the maximum width of container names so we can make the log - prefixes line up like so: +def tail_container_logs(container, presenter, queue, log_args): + generator = get_log_generator(container) - db_1 | Listening - web_1 | Listening - """ - return max(len(container.name_without_project) for container in containers) + try: + for item in generator(container, log_args): + queue.put((item, None)) + + if log_args.get('follow'): + yield presenter.color_func(wait_on_exit(container)) + + queue.put((STOP, None)) + + except Exception as e: + queue.put((None, e)) def get_log_generator(container): @@ -71,32 +132,61 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func, log_args): +def build_no_log_generator(container, log_args): """Return a generator that prints a warning about logs and waits for container to exit. """ - yield "{} WARNING: no logs are available with the '{}' log driver\n".format( - prefix, + yield "WARNING: no logs are available with the '{}' log driver\n".format( container.log_driver) - if log_args.get('follow'): - yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_func, log_args): +def build_log_generator(container, log_args): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: stream = container.logs(stdout=True, stderr=True, stream=True, **log_args) - line_generator = split_buffer(stream) else: - line_generator = split_buffer(container.log_stream) + stream = container.log_stream - for line in line_generator: - yield prefix + line - if log_args.get('follow'): - yield color_func(wait_on_exit(container)) + return split_buffer(stream) def wait_on_exit(container): exit_code = container.wait() return "%s exited with code %s\n" % (container.name, exit_code) + + +def start_producer_thread(thread_map, event_stream, presenters, thread_args): + queue, log_args = thread_args + + def watch_events(): + for event in event_stream: + # TODO: handle start and stop events + pass + + producer = Thread(target=watch_events) + producer.daemon = True + producer.start() + + +def consume_queue(queue, cascade_stop): + """Consume the queue by reading lines off of it and yielding them.""" + while True: + try: + item, exception = queue.get(timeout=0.1) + except Empty: + pass + # See https://github.com/docker/compose/issues/189 + except thread.error: + raise ShutdownException() + + if exception: + raise exception + + if item is STOP: + if cascade_stop: + raise StopIteration + else: + continue + + yield item diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py deleted file mode 100644 index ae8aa591..00000000 --- a/compose/cli/multiplexer.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from threading import Thread - -from six.moves import _thread as thread - -try: - from Queue import Queue, Empty -except ImportError: - from queue import Queue, Empty # Python 3.x - -from compose.cli.signals import ShutdownException - -STOP = object() - - -class Multiplexer(object): - """ - Create a single iterator from several iterators by running all of them in - parallel and yielding results as they come in. - """ - - def __init__(self, iterators, cascade_stop=False): - self.iterators = iterators - self.cascade_stop = cascade_stop - self._num_running = len(iterators) - self.queue = Queue() - - def loop(self): - self._init_readers() - - while self._num_running > 0: - try: - item, exception = self.queue.get(timeout=0.1) - - if exception: - raise exception - - if item is STOP: - if self.cascade_stop is True: - break - else: - self._num_running -= 1 - else: - yield item - except Empty: - pass - # See https://github.com/docker/compose/issues/189 - except thread.error: - raise ShutdownException() - - def _init_readers(self): - for iterator in self.iterators: - t = Thread(target=_enqueue_output, args=(iterator, self.queue)) - t.daemon = True - t.start() - - -def _enqueue_output(iterator, queue): - try: - for item in iterator: - queue.put((item, None)) - queue.put((STOP, None)) - except Exception as e: - queue.put((None, e)) diff --git a/compose/project.py b/compose/project.py index 3de68b2c..1169f7db 100644 --- a/compose/project.py +++ b/compose/project.py @@ -309,7 +309,8 @@ class Project(object): 'attributes': { 'name': container.name, 'image': event['from'], - } + }, + 'container': container, } service_names = set(self.service_names) diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 54fef0b2..81c69412 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -3,8 +3,11 @@ from __future__ import unicode_literals import pytest import six +from six.moves.queue import Queue +from compose.cli.log_printer import consume_queue from compose.cli.log_printer import LogPrinter +from compose.cli.log_printer import STOP from compose.cli.log_printer import wait_on_exit from compose.container import Container from tests import mock @@ -36,6 +39,7 @@ def mock_container(): return build_mock_container(reader) +@pytest.mark.skipif(True, reason="wip") class TestLogPrinter(object): def test_single_container(self, output_stream, mock_container): @@ -96,3 +100,38 @@ class TestLogPrinter(object): output = output_stream.getvalue() assert "WARNING: no logs are available with the 'none' log driver\n" in output assert "exited with code" not in output + + +class TestConsumeQueue(object): + + def test_item_is_an_exception(self): + + class Problem(Exception): + pass + + queue = Queue() + error = Problem('oops') + for item in ('a', None), ('b', None), (None, error): + queue.put(item) + + generator = consume_queue(queue, False) + assert generator.next() == 'a' + assert generator.next() == 'b' + with pytest.raises(Problem): + generator.next() + + def test_item_is_stop_without_cascade_stop(self): + queue = Queue() + for item in (STOP, None), ('a', None), ('b', None): + queue.put(item) + + generator = consume_queue(queue, False) + assert generator.next() == 'a' + assert generator.next() == 'b' + + def test_item_is_stop_with_cascade_stop(self): + queue = Queue() + for item in (STOP, None), ('a', None), ('b', None): + queue.put(item) + + assert list(consume_queue(queue, True)) == [] diff --git a/tests/unit/multiplexer_test.py b/tests/unit/multiplexer_test.py deleted file mode 100644 index 737ba25d..00000000 --- a/tests/unit/multiplexer_test.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import unittest -from time import sleep - -from compose.cli.multiplexer import Multiplexer - - -class MultiplexerTest(unittest.TestCase): - def test_no_iterators(self): - mux = Multiplexer([]) - self.assertEqual([], list(mux.loop())) - - def test_empty_iterators(self): - mux = Multiplexer([ - (x for x in []), - (x for x in []), - ]) - - self.assertEqual([], list(mux.loop())) - - def test_aggregates_output(self): - mux = Multiplexer([ - (x for x in [0, 2, 4]), - (x for x in [1, 3, 5]), - ]) - - self.assertEqual( - [0, 1, 2, 3, 4, 5], - sorted(list(mux.loop())), - ) - - def test_exception(self): - class Problem(Exception): - pass - - def problematic_iterator(): - yield 0 - yield 2 - raise Problem(":(") - - mux = Multiplexer([ - problematic_iterator(), - (x for x in [1, 3, 5]), - ]) - - with self.assertRaises(Problem): - list(mux.loop()) - - def test_cascade_stop(self): - def fast_stream(): - for num in range(3): - yield "stream1 %s" % num - - def slow_stream(): - sleep(5) - yield "stream2 FAIL" - - mux = Multiplexer([fast_stream(), slow_stream()], cascade_stop=True) - assert "stream2 FAIL" not in set(mux.loop()) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c28c2152..a815acda 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -307,6 +307,7 @@ class ProjectTest(unittest.TestCase): 'image': 'example/image', }, 'time': dt_with_microseconds(1420092061, 2), + 'container': Container(None, {'Id': 'abcde'}), }, { 'type': 'container', @@ -318,6 +319,7 @@ class ProjectTest(unittest.TestCase): 'image': 'example/image', }, 'time': dt_with_microseconds(1420092061, 3), + 'container': Container(None, {'Id': 'abcde'}), }, { 'type': 'container', @@ -329,6 +331,7 @@ class ProjectTest(unittest.TestCase): 'image': 'example/db', }, 'time': dt_with_microseconds(1420092061, 4), + 'container': Container(None, {'Id': 'ababa'}), }, ] From 44c1747127d320fe35b407aad775cb1a41fd77a4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Mar 2016 17:04:52 -0500 Subject: [PATCH 211/580] Add tests for reactive log printing. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 114 ++++++++++++++++--------- compose/cli/main.py | 56 ++++++++++--- compose/project.py | 1 + tests/acceptance/cli_test.py | 38 ++++++--- tests/unit/cli/log_printer_test.py | 128 +++++++++++++++-------------- tests/unit/cli/main_test.py | 14 ++-- 6 files changed, 218 insertions(+), 133 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 29a6159d..fc36a6bc 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import sys +from collections import namedtuple from itertools import cycle from threading import Thread @@ -15,9 +16,6 @@ from compose.cli.signals import ShutdownException from compose.utils import split_buffer -STOP = object() - - class LogPresenter(object): def __init__(self, prefix_width, color_func): @@ -79,51 +77,74 @@ class LogPrinter(object): queue = Queue() thread_args = queue, self.log_args thread_map = build_thread_map(self.containers, self.presenters, thread_args) - start_producer_thread( + start_producer_thread(( thread_map, self.event_stream, self.presenters, - thread_args) + thread_args)) for line in consume_queue(queue, self.cascade_stop): + remove_stopped_threads(thread_map) + + if not line: + if not thread_map: + return + continue + self.output.write(line) self.output.flush() - # TODO: this needs more logic - # TODO: does consume_queue need to yield Nones to get to this point? - if not thread_map: - return + +def remove_stopped_threads(thread_map): + for container_id, tailer_thread in list(thread_map.items()): + if not tailer_thread.is_alive(): + thread_map.pop(container_id, None) + + +def build_thread(container, presenter, queue, log_args): + tailer = Thread( + target=tail_container_logs, + args=(container, presenter, queue, log_args)) + tailer.daemon = True + tailer.start() + return tailer def build_thread_map(initial_containers, presenters, thread_args): - def build_thread(container): - tailer = Thread( - target=tail_container_logs, - args=(container, presenters.next()) + thread_args) - tailer.daemon = True - tailer.start() - return tailer - return { - container.id: build_thread(container) + container.id: build_thread(container, presenters.next(), *thread_args) for container in initial_containers } +class QueueItem(namedtuple('_QueueItem', 'item is_stop exc')): + + @classmethod + def new(cls, item): + return cls(item, None, None) + + @classmethod + def exception(cls, exc): + return cls(None, None, exc) + + @classmethod + def stop(cls): + return cls(None, True, None) + + def tail_container_logs(container, presenter, queue, log_args): generator = get_log_generator(container) try: for item in generator(container, log_args): - queue.put((item, None)) - - if log_args.get('follow'): - yield presenter.color_func(wait_on_exit(container)) - - queue.put((STOP, None)) - + queue.put(QueueItem.new(presenter.present(container, item))) except Exception as e: - queue.put((None, e)) + queue.put(QueueItem.exception(e)) + return + + if log_args.get('follow'): + queue.put(QueueItem.new(presenter.color_func(wait_on_exit(container)))) + queue.put(QueueItem.stop()) def get_log_generator(container): @@ -156,37 +177,48 @@ def wait_on_exit(container): return "%s exited with code %s\n" % (container.name, exit_code) -def start_producer_thread(thread_map, event_stream, presenters, thread_args): - queue, log_args = thread_args - - def watch_events(): - for event in event_stream: - # TODO: handle start and stop events - pass - - producer = Thread(target=watch_events) +def start_producer_thread(thread_args): + producer = Thread(target=watch_events, args=thread_args) producer.daemon = True producer.start() +def watch_events(thread_map, event_stream, presenters, thread_args): + for event in event_stream: + if event['action'] != 'start': + continue + + if event['id'] in thread_map: + if thread_map[event['id']].is_alive(): + continue + # Container was stopped and started, we need a new thread + thread_map.pop(event['id'], None) + + thread_map[event['id']] = build_thread( + event['container'], + presenters.next(), + *thread_args) + + def consume_queue(queue, cascade_stop): """Consume the queue by reading lines off of it and yielding them.""" while True: try: - item, exception = queue.get(timeout=0.1) + item = queue.get(timeout=0.1) except Empty: - pass + yield None + continue # See https://github.com/docker/compose/issues/189 except thread.error: raise ShutdownException() - if exception: - raise exception + if item.exc: + raise item.exc - if item is STOP: + if item.is_stop: if cascade_stop: raise StopIteration else: continue - yield item + yield item.item diff --git a/compose/cli/main.py b/compose/cli/main.py index 66362168..da622bc1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -35,6 +35,7 @@ from .docopt_command import NoSuchCommand from .errors import UserError from .formatter import ConsoleWarningFormatter from .formatter import Formatter +from .log_printer import build_log_presenters from .log_printer import LogPrinter from .utils import get_version_info from .utils import yesno @@ -277,6 +278,7 @@ class TopLevelCommand(object): def json_format_event(event): event['time'] = event['time'].isoformat() + event.pop('container') return json.dumps(event) for event in self.project.events(): @@ -374,7 +376,6 @@ class TopLevelCommand(object): """ containers = self.project.containers(service_names=options['SERVICE'], stopped=True) - monochrome = options['--no-color'] tail = options['--tail'] if tail is not None: if tail.isdigit(): @@ -387,7 +388,11 @@ class TopLevelCommand(object): 'timestamps': options['--timestamps'] } print("Attaching to", list_containers(containers)) - LogPrinter(containers, monochrome=monochrome, log_args=log_args).run() + log_printer_from_project( + project, + containers, + options['--no-color'], + log_args).run() def pause(self, options): """ @@ -693,7 +698,6 @@ class TopLevelCommand(object): when attached or when containers are already running. (default: 10) """ - monochrome = options['--no-color'] start_deps = not options['--no-deps'] cascade_stop = options['--abort-on-container-exit'] service_names = options['SERVICE'] @@ -704,7 +708,10 @@ class TopLevelCommand(object): raise UserError("--abort-on-container-exit and -d cannot be combined.") with up_shutdown_context(self.project, service_names, timeout, detached): - to_attach = self.project.up( + # start the event stream first so we don't lose any events + event_stream = project.events() + + to_attach = project.up( service_names=service_names, start_deps=start_deps, strategy=convergence_strategy_from_opts(options), @@ -714,8 +721,14 @@ class TopLevelCommand(object): if detached: return - log_args = {'follow': True} - log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop, log_args) + + log_printer = log_printer_from_project( + project, + filter_containers_to_service_names(to_attach, service_names), + options['--no-color'], + {'follow': True}, + cascade_stop, + event_stream=event_stream) print("Attaching to", list_containers(log_printer.containers)) log_printer.run() @@ -827,13 +840,30 @@ def run_one_off_container(container_options, project, service, options): sys.exit(exit_code) -def build_log_printer(containers, service_names, monochrome, cascade_stop, log_args): - if service_names: - containers = [ - container - for container in containers if container.service in service_names - ] - return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop, log_args=log_args) +def log_printer_from_project( + project, + containers, + monochrome, + log_args, + cascade_stop=False, + event_stream=None, +): + return LogPrinter( + containers, + build_log_presenters(project.service_names, monochrome), + event_stream or project.events(), + cascade_stop=cascade_stop, + log_args=log_args) + + +def filter_containers_to_service_names(containers, service_names): + if not service_names: + return containers + + return [ + container + for container in containers if container.service in service_names + ] @contextlib.contextmanager diff --git a/compose/project.py b/compose/project.py index 1169f7db..b40a9c38 100644 --- a/compose/project.py +++ b/compose/project.py @@ -324,6 +324,7 @@ class Project(object): continue # TODO: get labels from the API v1.22 , see github issue 2618 + # TODO: this can fail if the conatiner is removed, wrap in try/except container = Container.from_id(self.client, event['id']) if container.service not in service_names: continue diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 825b97be..c2116553 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1188,7 +1188,7 @@ class CLITestCase(DockerClientTestCase): def test_logs_follow(self): self.base_dir = 'tests/fixtures/echo-services' - self.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d']) result = self.dispatch(['logs', '-f']) @@ -1197,29 +1197,43 @@ class CLITestCase(DockerClientTestCase): assert 'another' in result.stdout assert 'exited with code 0' in result.stdout - def test_logs_unfollow(self): + def test_logs_follow_logs_from_new_containers(self): self.base_dir = 'tests/fixtures/logs-composefile' - self.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d', 'simple']) + + proc = start_process(self.base_dir, ['logs', '-f']) + + self.dispatch(['up', '-d', 'another']) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'logscomposefile_another_1', + running=False)) + + os.kill(proc.pid, signal.SIGINT) + result = wait_on_process(proc, returncode=1) + assert 'test' in result.stdout + + def test_logs_default(self): + self.base_dir = 'tests/fixtures/logs-composefile' + self.dispatch(['up', '-d']) result = self.dispatch(['logs']) - - assert result.stdout.count('\n') >= 1 - assert 'exited with code 0' not in result.stdout + assert 'hello' in result.stdout + assert 'test' in result.stdout + assert 'exited with' not in result.stdout def test_logs_timestamps(self): self.base_dir = 'tests/fixtures/echo-services' - self.dispatch(['up', '-d'], None) - - result = self.dispatch(['logs', '-f', '-t'], None) + self.dispatch(['up', '-d']) + result = self.dispatch(['logs', '-f', '-t']) self.assertRegexpMatches(result.stdout, '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})') def test_logs_tail(self): self.base_dir = 'tests/fixtures/logs-tail-composefile' - self.dispatch(['up'], None) - - result = self.dispatch(['logs', '--tail', '2'], None) + self.dispatch(['up']) + result = self.dispatch(['logs', '--tail', '2']) assert result.stdout.count('\n') == 3 def test_kill(self): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 81c69412..7be1d303 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -5,9 +5,11 @@ import pytest import six from six.moves.queue import Queue +from compose.cli.log_printer import build_log_generator +from compose.cli.log_printer import build_log_presenters +from compose.cli.log_printer import build_no_log_generator from compose.cli.log_printer import consume_queue -from compose.cli.log_printer import LogPrinter -from compose.cli.log_printer import STOP +from compose.cli.log_printer import QueueItem from compose.cli.log_printer import wait_on_exit from compose.container import Container from tests import mock @@ -34,72 +36,73 @@ def output_stream(): @pytest.fixture def mock_container(): - def reader(*args, **kwargs): - yield b"hello\nworld" - return build_mock_container(reader) + return mock.Mock(spec=Container, name_without_project='web_1') -@pytest.mark.skipif(True, reason="wip") -class TestLogPrinter(object): +class TestLogPresenter(object): - def test_single_container(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream, log_args={'follow': True}).run() + def test_monochrome(self, mock_container): + presenters = build_log_presenters(['foo', 'bar'], True) + presenter = presenters.next() + actual = presenter.present(mock_container, "this line") + assert actual == "web_1 | this line" - output = output_stream.getvalue() - assert 'hello' in output - assert 'world' in output - # Call count is 2 lines + "container exited line" - assert output_stream.flush.call_count == 3 + def test_polychrome(self, mock_container): + presenters = build_log_presenters(['foo', 'bar'], False) + presenter = presenters.next() + actual = presenter.present(mock_container, "this line") + assert '\033[' in actual - def test_single_container_without_stream(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream).run() - output = output_stream.getvalue() - assert 'hello' in output - assert 'world' in output - # Call count is 2 lines - assert output_stream.flush.call_count == 2 +def test_wait_on_exit(): + exit_status = 3 + mock_container = mock.Mock( + spec=Container, + name='cname', + wait=mock.Mock(return_value=exit_status)) - def test_monochrome(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream, monochrome=True).run() - assert '\033[' not in output_stream.getvalue() + expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) + assert expected == wait_on_exit(mock_container) - def test_polychrome(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream).run() - assert '\033[' in output_stream.getvalue() + +def test_build_no_log_generator(mock_container): + mock_container.has_api_logs = False + mock_container.log_driver = 'none' + output, = build_no_log_generator(mock_container, None) + assert "WARNING: no logs are available with the 'none' log driver\n" in output + assert "exited with code" not in output + + +class TestBuildLogGenerator(object): + + def test_no_log_stream(self, mock_container): + mock_container.log_stream = None + mock_container.logs.return_value = iter([b"hello\nworld"]) + log_args = {'follow': True} + + generator = build_log_generator(mock_container, log_args) + assert generator.next() == "hello\n" + assert generator.next() == "world" + mock_container.logs.assert_called_once_with( + stdout=True, + stderr=True, + stream=True, + **log_args) + + def test_with_log_stream(self, mock_container): + mock_container.log_stream = iter([b"hello\nworld"]) + log_args = {'follow': True} + + generator = build_log_generator(mock_container, log_args) + assert generator.next() == "hello\n" + assert generator.next() == "world" def test_unicode(self, output_stream): - glyph = u'\u2022' + glyph = u'\u2022\n' + mock_container.log_stream = iter([glyph.encode('utf-8')]) - def reader(*args, **kwargs): - yield glyph.encode('utf-8') + b'\n' - - container = build_mock_container(reader) - LogPrinter([container], output=output_stream).run() - output = output_stream.getvalue() - if six.PY2: - output = output.decode('utf-8') - - assert glyph in output - - def test_wait_on_exit(self): - exit_status = 3 - mock_container = mock.Mock( - spec=Container, - name='cname', - wait=mock.Mock(return_value=exit_status)) - - expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) - assert expected == wait_on_exit(mock_container) - - def test_generator_with_no_logs(self, mock_container, output_stream): - mock_container.has_api_logs = False - mock_container.log_driver = 'none' - LogPrinter([mock_container], output=output_stream).run() - - output = output_stream.getvalue() - assert "WARNING: no logs are available with the 'none' log driver\n" in output - assert "exited with code" not in output + generator = build_log_generator(mock_container, {}) + assert generator.next() == glyph class TestConsumeQueue(object): @@ -111,7 +114,7 @@ class TestConsumeQueue(object): queue = Queue() error = Problem('oops') - for item in ('a', None), ('b', None), (None, error): + for item in QueueItem.new('a'), QueueItem.new('b'), QueueItem.exception(error): queue.put(item) generator = consume_queue(queue, False) @@ -122,7 +125,7 @@ class TestConsumeQueue(object): def test_item_is_stop_without_cascade_stop(self): queue = Queue() - for item in (STOP, None), ('a', None), ('b', None): + for item in QueueItem.stop(), QueueItem.new('a'), QueueItem.new('b'): queue.put(item) generator = consume_queue(queue, False) @@ -131,7 +134,12 @@ class TestConsumeQueue(object): def test_item_is_stop_with_cascade_stop(self): queue = Queue() - for item in (STOP, None), ('a', None), ('b', None): + for item in QueueItem.stop(), QueueItem.new('a'), QueueItem.new('b'): queue.put(item) assert list(consume_queue(queue, True)) == [] + + def test_item_is_none_when_timeout_is_hit(self): + queue = Queue() + generator = consume_queue(queue, False) + assert generator.next() is None diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 9b24776f..dc527880 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -8,8 +8,8 @@ import pytest from compose import container from compose.cli.errors import UserError from compose.cli.formatter import ConsoleWarningFormatter -from compose.cli.main import build_log_printer from compose.cli.main import convergence_strategy_from_opts +from compose.cli.main import filter_containers_to_service_names from compose.cli.main import setup_console_handler from compose.service import ConvergenceStrategy from tests import mock @@ -32,7 +32,7 @@ def logging_handler(): class TestCLIMainTestCase(object): - def test_build_log_printer(self): + def test_filter_containers_to_service_names(self): containers = [ mock_container('web', 1), mock_container('web', 2), @@ -41,18 +41,18 @@ class TestCLIMainTestCase(object): mock_container('another', 1), ] service_names = ['web', 'db'] - log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) - assert log_printer.containers == containers[:3] + actual = filter_containers_to_service_names(containers, service_names) + assert actual == containers[:3] - def test_build_log_printer_all_services(self): + def test_filter_containers_to_service_names_all(self): containers = [ mock_container('web', 1), mock_container('db', 1), mock_container('other', 1), ] service_names = [] - log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) - assert log_printer.containers == containers + actual = filter_containers_to_service_names(containers, service_names) + assert actual == containers class TestSetupConsoleHandlerTestCase(object): From 4cad2a0c5f973c51675e26b67cb84bb1fa03b0f8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Mar 2016 18:53:47 -0500 Subject: [PATCH 212/580] Handle events for removed containers. Signed-off-by: Daniel Nephin --- compose/project.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index b40a9c38..9a2b46e1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -324,8 +324,11 @@ class Project(object): continue # TODO: get labels from the API v1.22 , see github issue 2618 - # TODO: this can fail if the conatiner is removed, wrap in try/except - container = Container.from_id(self.client, event['id']) + try: + # this can fail if the conatiner has been removed + container = Container.from_id(self.client, event['id']) + except APIError: + continue if container.service not in service_names: continue yield build_container_event(event, container) From 4312c93eae2594aafacb695be50480ac6b0341d5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Mar 2016 18:57:07 -0500 Subject: [PATCH 213/580] Add an acceptance test to show logs behaves properly for stopped containers. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c2116553..d3d4b3c0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1222,6 +1222,15 @@ class CLITestCase(DockerClientTestCase): assert 'test' in result.stdout assert 'exited with' not in result.stdout + def test_logs_on_stopped_containers_exits(self): + self.base_dir = 'tests/fixtures/echo-services' + self.dispatch(['up']) + + result = self.dispatch(['logs']) + assert 'simple' in result.stdout + assert 'another' in result.stdout + assert 'exited with' not in result.stdout + def test_logs_timestamps(self): self.base_dir = 'tests/fixtures/echo-services' self.dispatch(['up', '-d']) From 48ed68eeaa371ee31b8aac7186681d86eb84015e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 7 Mar 2016 14:56:14 -0500 Subject: [PATCH 214/580] Fix geneartors for python3. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 4 ++-- tests/unit/cli/log_printer_test.py | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index fc36a6bc..22312c00 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -112,7 +112,7 @@ def build_thread(container, presenter, queue, log_args): def build_thread_map(initial_containers, presenters, thread_args): return { - container.id: build_thread(container, presenters.next(), *thread_args) + container.id: build_thread(container, next(presenters), *thread_args) for container in initial_containers } @@ -196,7 +196,7 @@ def watch_events(thread_map, event_stream, presenters, thread_args): thread_map[event['id']] = build_thread( event['container'], - presenters.next(), + next(presenters), *thread_args) diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 7be1d303..33b2f166 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -43,13 +43,13 @@ class TestLogPresenter(object): def test_monochrome(self, mock_container): presenters = build_log_presenters(['foo', 'bar'], True) - presenter = presenters.next() + presenter = next(presenters) actual = presenter.present(mock_container, "this line") assert actual == "web_1 | this line" def test_polychrome(self, mock_container): presenters = build_log_presenters(['foo', 'bar'], False) - presenter = presenters.next() + presenter = next(presenters) actual = presenter.present(mock_container, "this line") assert '\033[' in actual @@ -81,8 +81,8 @@ class TestBuildLogGenerator(object): log_args = {'follow': True} generator = build_log_generator(mock_container, log_args) - assert generator.next() == "hello\n" - assert generator.next() == "world" + assert next(generator) == "hello\n" + assert next(generator) == "world" mock_container.logs.assert_called_once_with( stdout=True, stderr=True, @@ -94,15 +94,15 @@ class TestBuildLogGenerator(object): log_args = {'follow': True} generator = build_log_generator(mock_container, log_args) - assert generator.next() == "hello\n" - assert generator.next() == "world" + assert next(generator) == "hello\n" + assert next(generator) == "world" def test_unicode(self, output_stream): glyph = u'\u2022\n' mock_container.log_stream = iter([glyph.encode('utf-8')]) generator = build_log_generator(mock_container, {}) - assert generator.next() == glyph + assert next(generator) == glyph class TestConsumeQueue(object): @@ -118,10 +118,10 @@ class TestConsumeQueue(object): queue.put(item) generator = consume_queue(queue, False) - assert generator.next() == 'a' - assert generator.next() == 'b' + assert next(generator) == 'a' + assert next(generator) == 'b' with pytest.raises(Problem): - generator.next() + next(generator) def test_item_is_stop_without_cascade_stop(self): queue = Queue() @@ -129,8 +129,8 @@ class TestConsumeQueue(object): queue.put(item) generator = consume_queue(queue, False) - assert generator.next() == 'a' - assert generator.next() == 'b' + assert next(generator) == 'a' + assert next(generator) == 'b' def test_item_is_stop_with_cascade_stop(self): queue = Queue() @@ -142,4 +142,4 @@ class TestConsumeQueue(object): def test_item_is_none_when_timeout_is_hit(self): queue = Queue() generator = consume_queue(queue, False) - assert generator.next() is None + assert next(generator) is None From 3f7e5bf76895413048ed5af88279899261394b32 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 7 Mar 2016 15:04:42 -0500 Subject: [PATCH 215/580] Filter logs by service names. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/project.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index da622bc1..468e10c4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -709,7 +709,7 @@ class TopLevelCommand(object): with up_shutdown_context(self.project, service_names, timeout, detached): # start the event stream first so we don't lose any events - event_stream = project.events() + event_stream = project.events(service_names=service_names) to_attach = project.up( service_names=service_names, diff --git a/compose/project.py b/compose/project.py index 9a2b46e1..4e25e498 100644 --- a/compose/project.py +++ b/compose/project.py @@ -295,7 +295,7 @@ class Project(object): detached=True, start=False) - def events(self): + def events(self, service_names=None): def build_container_event(event, container): time = datetime.datetime.fromtimestamp(event['time']) time = time.replace( @@ -313,7 +313,7 @@ class Project(object): 'container': container, } - service_names = set(self.service_names) + service_names = set(service_names or self.service_names) for event in self.client.events( filters={'label': self.labels()}, decode=True From 8d9adc0902bf7c4b056007d7e6fb6188f2193fdf Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 7 Mar 2016 15:08:31 -0500 Subject: [PATCH 216/580] Fix flaky log test by using container status, instead of boolean state. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d3d4b3c0..095fb3f1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -78,21 +78,20 @@ class ContainerCountCondition(object): class ContainerStateCondition(object): - def __init__(self, client, name, running): + def __init__(self, client, name, status): self.client = client self.name = name - self.running = running + self.status = status def __call__(self): try: container = self.client.inspect_container(self.name) - return container['State']['Running'] == self.running + return container['State']['Status'] == self.status except errors.APIError: return False def __str__(self): - state = 'running' if self.running else 'stopped' - return "waiting for container to be %s" % state + return "waiting for container to be %s" % self.status class CLITestCase(DockerClientTestCase): @@ -1073,26 +1072,26 @@ class CLITestCase(DockerClientTestCase): wait_on_condition(ContainerStateCondition( self.project.client, 'simplecomposefile_simple_run_1', - running=True)) + 'running')) os.kill(proc.pid, signal.SIGINT) wait_on_condition(ContainerStateCondition( self.project.client, 'simplecomposefile_simple_run_1', - running=False)) + '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', - running=True)) + 'running')) os.kill(proc.pid, signal.SIGTERM) wait_on_condition(ContainerStateCondition( self.project.client, 'simplecomposefile_simple_run_1', - running=False)) + 'exited')) def test_rm(self): service = self.project.get_service('simple') @@ -1207,7 +1206,7 @@ class CLITestCase(DockerClientTestCase): wait_on_condition(ContainerStateCondition( self.project.client, 'logscomposefile_another_1', - running=False)) + 'exited')) os.kill(proc.pid, signal.SIGINT) result = wait_on_process(proc, returncode=1) From e8a93821d43753f19f0511ae8903fe05dac534d5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 7 Mar 2016 15:34:53 -0500 Subject: [PATCH 217/580] Fix race condition where a container stopping and starting again would cause logs to miss logs. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 3 ++ tests/unit/cli/log_printer_test.py | 56 +++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 22312c00..367a534e 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -185,6 +185,9 @@ def start_producer_thread(thread_args): def watch_events(thread_map, event_stream, presenters, thread_args): for event in event_stream: + if event['action'] == 'stop': + thread_map.pop(event['id'], None) + if event['action'] != 'start': continue diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 33b2f166..ab48eefc 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import itertools + import pytest import six from six.moves.queue import Queue @@ -11,22 +13,11 @@ from compose.cli.log_printer import build_no_log_generator from compose.cli.log_printer import consume_queue from compose.cli.log_printer import QueueItem from compose.cli.log_printer import wait_on_exit +from compose.cli.log_printer import watch_events from compose.container import Container from tests import mock -def build_mock_container(reader): - return mock.Mock( - spec=Container, - name='myapp_web_1', - name_without_project='web_1', - has_api_logs=True, - log_stream=None, - logs=reader, - wait=mock.Mock(return_value=0), - ) - - @pytest.fixture def output_stream(): output = six.StringIO() @@ -105,6 +96,47 @@ class TestBuildLogGenerator(object): assert next(generator) == glyph +@pytest.fixture +def thread_map(): + return {'cid': mock.Mock()} + + +@pytest.fixture +def mock_presenters(): + return itertools.cycle([mock.Mock()]) + + +class TestWatchEvents(object): + + def test_stop_event(self, thread_map, mock_presenters): + event_stream = [{'action': 'stop', 'id': 'cid'}] + watch_events(thread_map, event_stream, mock_presenters, ()) + assert not thread_map + + def test_start_event(self, thread_map, mock_presenters): + container_id = 'abcd' + event = {'action': 'start', 'id': container_id, 'container': mock.Mock()} + event_stream = [event] + thread_args = 'foo', 'bar' + + with mock.patch( + 'compose.cli.log_printer.build_thread', + autospec=True + ) as mock_build_thread: + watch_events(thread_map, event_stream, mock_presenters, thread_args) + mock_build_thread.assert_called_once_with( + event['container'], + next(mock_presenters), + *thread_args) + assert container_id in thread_map + + def test_other_event(self, thread_map, mock_presenters): + container_id = 'abcd' + event_stream = [{'action': 'create', 'id': container_id}] + watch_events(thread_map, event_stream, mock_presenters, ()) + assert container_id not in thread_map + + class TestConsumeQueue(object): def test_item_is_an_exception(self): From e5529a89e19fb2325c73c479e23962dbe9e5ef36 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 15 Mar 2016 12:43:37 -0400 Subject: [PATCH 218/580] Make down idempotent, continue to remove resources if one is missing. Signed-off-by: Daniel Nephin --- compose/network.py | 5 ++++- compose/volume.py | 5 ++++- tests/unit/project_test.py | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index 81e3b5bf..affba7c2 100644 --- a/compose/network.py +++ b/compose/network.py @@ -149,7 +149,10 @@ class ProjectNetworks(object): if not self.use_networking: return for network in self.networks.values(): - network.remove() + try: + network.remove() + except NotFound: + log.warn("Network %s not found.", network.full_name) def initialize(self): if not self.use_networking: diff --git a/compose/volume.py b/compose/volume.py index 17e90087..f440ba40 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -76,7 +76,10 @@ class ProjectVolumes(object): def remove(self): for volume in self.volumes.values(): - volume.remove() + try: + volume.remove() + except NotFound: + log.warn("Volume %s not found.", volume.full_name) def initialize(self): try: diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c28c2152..bc6421a5 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import datetime import docker +from docker.errors import NotFound from .. import mock from .. import unittest @@ -12,6 +13,7 @@ from compose.config.types import VolumeFromSpec from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project +from compose.service import ImageType from compose.service import Service @@ -476,3 +478,23 @@ class ProjectTest(unittest.TestCase): ), ) self.assertEqual([c.id for c in project.containers()], ['1']) + + def test_down_with_no_resources(self): + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version='2', + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + }], + networks={'default': {}}, + volumes={'data': {}}, + ), + ) + self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops') + self.mock_client.remove_volume.side_effect = NotFound(None, None, 'oops') + + project.down(ImageType.all, True) + self.mock_client.remove_image.assert_called_once_with("busybox:latest") From bf96edfe11789d4ce13b869be578cc274794cdfc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 7 Mar 2016 15:58:25 -0500 Subject: [PATCH 219/580] Reduce the args of some functions by including presenters as part of the thread_args. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 3 +++ compose/cli/main.py | 11 ++++------- tests/acceptance/cli_test.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 367a534e..b48462ff 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -88,7 +88,10 @@ class LogPrinter(object): if not line: if not thread_map: + # There are no running containers left to tail, so exit return + # We got an empty line because of a timeout, but there are still + # active containers to tail, so continue continue self.output.write(line) diff --git a/compose/cli/main.py b/compose/cli/main.py index 468e10c4..52b4a03b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -389,7 +389,7 @@ class TopLevelCommand(object): } print("Attaching to", list_containers(containers)) log_printer_from_project( - project, + self.project, containers, options['--no-color'], log_args).run() @@ -708,10 +708,7 @@ class TopLevelCommand(object): raise UserError("--abort-on-container-exit and -d cannot be combined.") with up_shutdown_context(self.project, service_names, timeout, detached): - # start the event stream first so we don't lose any events - event_stream = project.events(service_names=service_names) - - to_attach = project.up( + to_attach = self.project.up( service_names=service_names, start_deps=start_deps, strategy=convergence_strategy_from_opts(options), @@ -723,12 +720,12 @@ class TopLevelCommand(object): return log_printer = log_printer_from_project( - project, + self.project, filter_containers_to_service_names(to_attach, service_names), options['--no-color'], {'follow': True}, cascade_stop, - event_stream=event_stream) + event_stream=self.project.events(service_names=service_names)) print("Attaching to", list_containers(log_printer.containers)) log_printer.run() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 095fb3f1..ab74f14e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -396,8 +396,8 @@ class CLITestCase(DockerClientTestCase): self.base_dir = 'tests/fixtures/echo-services' result = self.dispatch(['up', '--no-color']) - assert 'simple_1 | simple' in result.stdout - assert 'another_1 | another' in result.stdout + assert 'simple_1 | simple' in result.stdout + assert 'another_1 | another' in result.stdout assert 'simple_1 exited with code 0' in result.stdout assert 'another_1 exited with code 0' in result.stdout From 658803edf885f490168e223d07b2b1a2cbd22aae Mon Sep 17 00:00:00 2001 From: Simon van der Veldt Date: Mon, 22 Feb 2016 21:05:59 +0100 Subject: [PATCH 220/580] Add -w or --workdir to compose run to override workdir from commandline Signed-off-by: Simon van der Veldt --- compose/cli/main.py | 4 ++++ docs/reference/run.md | 1 + tests/acceptance/cli_test.py | 18 ++++++++++++++++++ tests/fixtures/run-workdir/docker-compose.yml | 4 ++++ tests/unit/cli_test.py | 3 +++ 5 files changed, 30 insertions(+) create mode 100644 tests/fixtures/run-workdir/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 66362168..146b77b4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -527,6 +527,7 @@ class TopLevelCommand(object): to the host. -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. + -w, --workdir="" Working directory inside the container """ service = self.project.get_service(options['SERVICE']) detach = options['-d'] @@ -576,6 +577,9 @@ class TopLevelCommand(object): if options['--name']: container_options['name'] = options['--name'] + if options['--workdir']: + container_options['working_dir'] = options['--workdir'] + run_one_off_container(container_options, self.project, service, options) def scale(self, options): diff --git a/docs/reference/run.md b/docs/reference/run.md index 21890c60..86354424 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -26,6 +26,7 @@ Options: -p, --publish=[] Publish a container's port(s) to the host --service-ports Run command with the service's ports enabled and mapped to the host. -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. +-w, --workdir="" Working directory inside the container ``` Runs a one-time command against a service. For example, the following command starts the `web` service and runs `bash` as its command. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 825b97be..a712de8a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1025,6 +1025,24 @@ class CLITestCase(DockerClientTestCase): container, = service.containers(stopped=True, one_off=True) self.assertEqual(container.name, name) + def test_run_service_with_workdir_overridden(self): + self.base_dir = 'tests/fixtures/run-workdir' + name = 'service' + workdir = '/var' + self.dispatch(['run', '--workdir={workdir}'.format(workdir=workdir), name]) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual(workdir, container.get('Config.WorkingDir')) + + def test_run_service_with_workdir_overridden_short_form(self): + self.base_dir = 'tests/fixtures/run-workdir' + name = 'service' + workdir = '/var' + self.dispatch(['run', '-w', workdir, name]) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual(workdir, container.get('Config.WorkingDir')) + @v2_only() def test_run_interactive_connects_to_network(self): self.base_dir = 'tests/fixtures/networks' diff --git a/tests/fixtures/run-workdir/docker-compose.yml b/tests/fixtures/run-workdir/docker-compose.yml new file mode 100644 index 00000000..dc3ea86a --- /dev/null +++ b/tests/fixtures/run-workdir/docker-compose.yml @@ -0,0 +1,4 @@ +service: + image: busybox:latest + working_dir: /etc + command: /bin/true diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 1d7c13e7..e0ada460 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -102,6 +102,7 @@ class CLITestCase(unittest.TestCase): '--publish': [], '--rm': None, '--name': None, + '--workdir': None, }) _, _, call_kwargs = mock_run_operation.mock_calls[0] @@ -135,6 +136,7 @@ class CLITestCase(unittest.TestCase): '--publish': [], '--rm': None, '--name': None, + '--workdir': None, }) self.assertEquals( @@ -156,6 +158,7 @@ class CLITestCase(unittest.TestCase): '--publish': [], '--rm': True, '--name': None, + '--workdir': None, }) self.assertFalse( From 52b791a2647af6e7ace5b2b1ea480fbec16dc08d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 26 Feb 2016 14:24:02 -0800 Subject: [PATCH 221/580] Split off build_container_options() to reduce the complexity of run Signed-off-by: Daniel Nephin --- compose/cli/main.py | 75 ++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 146b77b4..486fb151 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -538,48 +538,18 @@ class TopLevelCommand(object): "Please pass the -d flag when using `docker-compose run`." ) - if options['COMMAND']: - command = [options['COMMAND']] + options['ARGS'] - else: - command = service.options.get('command') - - container_options = { - 'command': command, - 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), - 'stdin_open': not detach, - 'detach': detach, - } - - if options['-e']: - container_options['environment'] = parse_environment(options['-e']) - - if options['--entrypoint']: - container_options['entrypoint'] = options.get('--entrypoint') - - if options['--rm']: - container_options['restart'] = None - - if options['--user']: - container_options['user'] = options.get('--user') - - if not options['--service-ports']: - container_options['ports'] = [] - - if options['--publish']: - container_options['ports'] = options.get('--publish') - if options['--publish'] and options['--service-ports']: raise UserError( 'Service port mapping and manual port mapping ' 'can not be used togather' ) - if options['--name']: - container_options['name'] = options['--name'] - - if options['--workdir']: - container_options['working_dir'] = options['--workdir'] + if options['COMMAND']: + command = [options['COMMAND']] + options['ARGS'] + else: + command = service.options.get('command') + container_options = build_container_options(options, detach, command) run_one_off_container(container_options, self.project, service, options) def scale(self, options): @@ -780,6 +750,41 @@ def build_action_from_opts(options): return BuildAction.none +def build_container_options(options, detach, command): + container_options = { + 'command': command, + 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), + 'stdin_open': not detach, + 'detach': detach, + } + + if options['-e']: + container_options['environment'] = parse_environment(options['-e']) + + if options['--entrypoint']: + container_options['entrypoint'] = options.get('--entrypoint') + + if options['--rm']: + container_options['restart'] = None + + if options['--user']: + container_options['user'] = options.get('--user') + + if not options['--service-ports']: + container_options['ports'] = [] + + if options['--publish']: + container_options['ports'] = options.get('--publish') + + if options['--name']: + container_options['name'] = options['--name'] + + if options['--workdir']: + container_options['working_dir'] = options['--workdir'] + + return container_options + + def run_one_off_container(container_options, project, service, options): if not options['--no-deps']: deps = service.get_dependency_names() From 20c29f7e47ade7567ee35f3587790f6235d17d59 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Mar 2016 17:35:56 -0800 Subject: [PATCH 222/580] Add flag to up/down to remove orphaned containers Add --remove-orphans to CLI reference docs Add --remove-orphans to bash completion file Test orphan warning and remove_orphan option in up Signed-off-by: Joffrey F --- compose/cli/main.py | 24 ++++++++----- compose/project.py | 48 ++++++++++++++++++++++---- contrib/completion/bash/docker-compose | 4 +-- docs/reference/down.md | 10 +++--- docs/reference/up.md | 2 ++ tests/integration/project_test.py | 39 +++++++++++++++++++++ 6 files changed, 105 insertions(+), 22 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 66362168..110ff6df 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -252,13 +252,15 @@ class TopLevelCommand(object): Usage: down [options] Options: - --rmi type Remove images, type may be one of: 'all' to remove - all images, or 'local' to remove only images that - don't have an custom name set by the `image` field - -v, --volumes Remove data volumes + --rmi type Remove images, type may be one of: 'all' to remove + all images, or 'local' to remove only images that + don't have an custom name set by the `image` field + -v, --volumes Remove data volumes + --remove-orphans Remove containers for services not defined in + the Compose file """ image_type = image_type_from_opt('--rmi', options['--rmi']) - self.project.down(image_type, options['--volumes']) + self.project.down(image_type, options['--volumes'], options['--remove-orphans']) def events(self, options): """ @@ -324,9 +326,9 @@ class TopLevelCommand(object): signals.set_signal_handler_to_shutdown() try: operation = ExecOperation( - self.project.client, - exec_id, - interactive=tty, + self.project.client, + exec_id, + interactive=tty, ) pty = PseudoTerminal(self.project.client, operation) pty.start() @@ -692,12 +694,15 @@ class TopLevelCommand(object): -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10) + --remove-orphans Remove containers for services not + defined in the Compose file """ monochrome = options['--no-color'] start_deps = not options['--no-deps'] cascade_stop = options['--abort-on-container-exit'] service_names = options['SERVICE'] timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + remove_orphans = options['--remove-orphans'] detached = options.get('-d') if detached and cascade_stop: @@ -710,7 +715,8 @@ class TopLevelCommand(object): strategy=convergence_strategy_from_opts(options), do_build=build_action_from_opts(options), timeout=timeout, - detached=detached) + detached=detached, + remove_orphans=remove_orphans) if detached: return diff --git a/compose/project.py b/compose/project.py index 3de68b2c..49cbfbf7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -252,9 +252,11 @@ class Project(object): def remove_stopped(self, service_names=None, **options): parallel.parallel_remove(self.containers(service_names, stopped=True), options) - def down(self, remove_image_type, include_volumes): + def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop() + self.find_orphan_containers(remove_orphans) self.remove_stopped(v=include_volumes) + self.networks.remove() if include_volumes: @@ -334,7 +336,8 @@ class Project(object): strategy=ConvergenceStrategy.changed, do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, - detached=False): + detached=False, + remove_orphans=False): self.initialize() services = self.get_services_without_duplicate( @@ -346,6 +349,8 @@ class Project(object): for svc in services: svc.ensure_image_exists(do_build=do_build) + self.find_orphan_containers(remove_orphans) + def do(service): return service.execute_convergence_plan( plans[service.name], @@ -402,23 +407,52 @@ class Project(object): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) + def _labeled_containers(self, stopped=False, one_off=False): + return list(filter(None, [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off)})]) + ) + def containers(self, service_names=None, stopped=False, one_off=False): if service_names: self.validate_service_names(service_names) else: service_names = self.service_names - containers = list(filter(None, [ - Container.from_ps(self.client, container) - for container in self.client.containers( - all=stopped, - filters={'label': self.labels(one_off=one_off)})])) + containers = self._labeled_containers(stopped, one_off) def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names return [c for c in containers if matches_service_names(c)] + def find_orphan_containers(self, remove_orphans): + def _find(): + containers = self._labeled_containers() + for ctnr in containers: + service_name = ctnr.labels.get(LABEL_SERVICE) + if service_name not in self.service_names: + yield ctnr + orphans = list(_find()) + if not orphans: + return + if remove_orphans: + for ctnr in orphans: + log.info('Removing orphan container "{0}"'.format(ctnr.name)) + ctnr.kill() + ctnr.remove(force=True) + else: + log.warning( + 'Found orphan containers ({0}) for this project. If ' + 'you removed or renamed this service in your compose ' + 'file, you can run this command with the ' + '--remove-orphans flag to clean it up.'.format( + ', '.join(["{}".format(ctnr.name) for ctnr in orphans]) + ) + ) + def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index d926d648..0769e657 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -161,7 +161,7 @@ _docker_compose_down() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --rmi --volumes -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --rmi --volumes -v --remove-orphans" -- "$cur" ) ) ;; esac } @@ -406,7 +406,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) ;; *) __docker_compose_services_all diff --git a/docs/reference/down.md b/docs/reference/down.md index 2495abea..e8b1db59 100644 --- a/docs/reference/down.md +++ b/docs/reference/down.md @@ -18,9 +18,11 @@ created by `up`. Only containers and networks are removed by default. Usage: down [options] Options: - --rmi type Remove images, type may be one of: 'all' to remove - all images, or 'local' to remove only images that - don't have an custom name set by the `image` field - -v, --volumes Remove data volumes + --rmi type Remove images, type may be one of: 'all' to remove + all images, or 'local' to remove only images that + don't have an custom name set by the `image` field + -v, --volumes Remove data volumes + --remove-orphans Remove containers for services not defined in the + Compose file ``` diff --git a/docs/reference/up.md b/docs/reference/up.md index 07ee82f9..3951f879 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -32,6 +32,8 @@ Options: -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10) + --remove-orphans Remove containers for services not defined in + the Compose file ``` diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 393f4f11..9839bf8f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ import py import pytest from docker.errors import NotFound +from .. import mock from ..helpers import build_config from .testcases import DockerClientTestCase from compose.config import config @@ -15,6 +16,7 @@ from compose.config.config import V2_0 from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT +from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy @@ -1055,3 +1057,40 @@ class ProjectTest(DockerClientTestCase): container = service.get_container() assert [mount['Name'] for mount in container.get('Mounts')] == [full_vol_name] assert next((v for v in engine_volumes if v['Name'] == vol_name), None) is None + + def test_project_up_orphans(self): + config_dict = { + 'service1': { + 'image': 'busybox:latest', + 'command': 'top', + } + } + + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + project.up() + config_dict['service2'] = config_dict['service1'] + del config_dict['service1'] + + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + with mock.patch('compose.project.log') as mock_log: + project.up() + + mock_log.warning.assert_called_once_with(mock.ANY) + + assert len([ + ctnr for ctnr in project._labeled_containers() + if ctnr.labels.get(LABEL_SERVICE) == 'service1' + ]) == 1 + + project.up(remove_orphans=True) + + assert len([ + ctnr for ctnr in project._labeled_containers() + if ctnr.labels.get(LABEL_SERVICE) == 'service1' + ]) == 0 From 92d69b0cb6f2d192b02a85d79fd7d99baedadf79 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 17 Mar 2016 09:56:44 +0000 Subject: [PATCH 223/580] Update Mac Engine install URL in error message Signed-off-by: Aanand Prasad --- compose/cli/errors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index a16cad2f..668f8444 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -88,9 +88,9 @@ def exit_with_error(msg): docker_not_found_mac = """ - Couldn't connect to Docker daemon. You might need to install docker-osx: + Couldn't connect to Docker daemon. You might need to install Docker: - https://github.com/noplay/docker-osx + https://docs.docker.com/engine/installation/mac/ """ From 7424938fc8c78eb10f28c5dd0e2b5805f87a4e6e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 17 Mar 2016 11:32:36 +0000 Subject: [PATCH 224/580] Move ipv4_address/ipv6_address docs to reference section Signed-off-by: Aanand Prasad --- docs/compose-file.md | 32 ++++++++++++++++++++++++++++++++ docs/networking.md | 24 +----------------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index d6cb92cf..85875512 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -545,6 +545,38 @@ In the example below, three services are provided (`web`, `worker`, and `db`), a new: legacy: +#### ipv4_address, ipv6_address + +Specify a static IP address for containers for this service when joining the network. + +The corresponding network configuration in the [top-level networks section](#network-configuration-reference) must have an `ipam` block with subnet and gateway configurations covering each static address. If IPv6 addressing is desired, the `com.docker.network.enable_ipv6` driver option must be set to `true`. + +An example: + + version: '2' + + services: + app: + image: busybox + command: ifconfig + networks: + app_net: + ipv4_address: 172.16.238.10 + ipv6_address: 2001:3984:3989::10 + + networks: + app_net: + driver: bridge + driver_opts: + com.docker.network.enable_ipv6: "true" + ipam: + driver: default + config: + - subnet: 172.16.238.0/24 + gateway: 172.16.238.1 + - subnet: 2001:3984:3989::/64 + gateway: 2001:3984:3989::1 + ### pid pid: "host" diff --git a/docs/networking.md b/docs/networking.md index e38e5690..bc568294 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -116,29 +116,7 @@ Here's an example Compose file defining two custom networks. The `proxy` service foo: "1" bar: "2" -Networks can be configured with static IP addresses by setting the ipv4_address and/or ipv6_address for each attached network. The corresponding `network` section must have an `ipam` config entry with subnet and gateway configurations for each static address. If IPv6 addressing is desired, the `com.docker.network.enable_ipv6` driver option must be set to `true`. An example: - - version: '2' - - services: - app: - networks: - app_net: - ipv4_address: 172.16.238.10 - ipv6_address: 2001:3984:3989::10 - - networks: - app_net: - driver: bridge - driver_opts: - com.docker.network.enable_ipv6: "true" - ipam: - driver: default - config: - - subnet: 172.16.238.0/24 - gateway: 172.16.238.1 - - subnet: 2001:3984:3989::/64 - gateway: 2001:3984:3989::1 +Networks can be configured with static IP addresses by setting the [ipv4_address and/or ipv6_address](compose-file.md#ipv4-address-ipv6-address) for each attached network. For full details of the network configuration options available, see the following references: From 20bf05a6e3b79370a680da844dc6c59e77cda293 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 17 Mar 2016 13:59:51 +0000 Subject: [PATCH 225/580] Fix TypeError in Exception handling Traceback (most recent call last): File "/tmp/tmp.02tgGaAGtW/docker-compose/bin/docker-compose", line 11, in sys.exit(main()) File "/tmp/tmp.02tgGaAGtW/docker-compose/lib/python3.4/site-packages/compose/cli/main.py", line 68, in main log_api_error(e) File "/tmp/tmp.02tgGaAGtW/docker-compose/lib/python3.4/site-packages/compose/cli/main.py", line 89, in log_api_error if 'client is newer than server' in e.explanation: TypeError: 'str' does not support the buffer interface Signed-off-by: Thomas Grainger --- compose/cli/errors.py | 2 +- tests/unit/cli/errors_test.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index a16cad2f..2f2907ae 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -66,7 +66,7 @@ def handle_connection_errors(client): def log_api_error(e, client_version): - if 'client is newer than server' not in e.explanation: + if b'client is newer than server' not in e.explanation: log.error(e.explanation) return diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index a99356b4..71fa9dee 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -37,13 +37,13 @@ class TestHandleConnectionErrors(object): def test_api_error_version_mismatch(self, mock_logging): with pytest.raises(errors.ConnectionError): with handle_connection_errors(mock.Mock(api_version='1.22')): - raise APIError(None, None, "client is newer than server") + raise APIError(None, None, b"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): - msg = "Something broke!" + msg = b"Something broke!" with pytest.raises(errors.ConnectionError): with handle_connection_errors(mock.Mock(api_version='1.22')): raise APIError(None, None, msg) From 10dfd54ebedd900525a7cde6ac52853821965d6b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 17 Mar 2016 18:09:38 +0000 Subject: [PATCH 226/580] Update install page with link to Windows Toolbox install instructions Signed-off-by: Aanand Prasad --- docs/install.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/install.md b/docs/install.md index eee0c203..10841cdf 100644 --- a/docs/install.md +++ b/docs/install.md @@ -12,21 +12,21 @@ weight=-90 # Install Docker Compose -You can run Compose on OS X and 64-bit Linux. It is currently not supported on -the Windows operating system. To install Compose, you'll need to install Docker -first. +You can run Compose on OS X, Windows and 64-bit Linux. To install it, you'll need to install Docker first. To install Compose, do the following: 1. Install Docker Engine: - * Mac OS X installation (Toolbox installation includes both Engine and Compose) + * Mac OS X installation + + * Windows installation * Ubuntu installation * other system installations -2. Mac OS X users are done installing. Others should continue to the next step. +2. The Docker Toolbox installation includes both Engine and Compose, so Mac and Windows users are done installing. Others should continue to the next step. 3. Go to the Compose repository release page on GitHub. From 50fe014ba9f6af3dc75cb5f5548dcf0c9825cd05 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 17 Mar 2016 18:10:32 +0000 Subject: [PATCH 227/580] Remove hardcoded host from Engine install URLs Signed-off-by: Aanand Prasad --- docs/install.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/install.md b/docs/install.md index 10841cdf..95416e7a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,13 +18,13 @@ To install Compose, do the following: 1. Install Docker Engine: - * Mac OS X installation + * Mac OS X installation - * Windows installation + * Windows installation - * Ubuntu installation + * Ubuntu installation - * other system installations + * other system installations 2. The Docker Toolbox installation includes both Engine and Compose, so Mac and Windows users are done installing. Others should continue to the next step. From e3c1b5886aa8d28a3fa29d9c58b9868c0db2e831 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 18 Mar 2016 10:47:17 +0100 Subject: [PATCH 228/580] bash completion for `docker-compose run --workdir` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 0769e657..528970b4 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -316,14 +316,14 @@ _docker_compose_run() { __docker_compose_nospace return ;; - --entrypoint|--name|--user|-u) + --entrypoint|--name|--user|-u|--workdir|-w) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all From 25cbc2aae907886d46ff1e55a2c6dea535966e41 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 18 Mar 2016 18:19:16 -0400 Subject: [PATCH 229/580] Fix flaky network test. Signed-off-by: Daniel Nephin --- tests/integration/project_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 9839bf8f..d1732d1e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -659,6 +659,7 @@ class ProjectTest(DockerClientTestCase): services=[{ 'name': 'web', 'image': 'busybox:latest', + 'command': 'top', 'networks': { 'static_test': { 'ipv4_address': '172.16.100.100', @@ -690,7 +691,7 @@ class ProjectTest(DockerClientTestCase): name='composetest', config_data=config_data, ) - project.up() + project.up(detached=True) network = self.client.networks(names=['static_test'])[0] service_container = project.get_service('web').containers()[0] From f1dce50b3da1ccd4f66939965a749b660a48fe16 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 18 Mar 2016 16:09:44 -0400 Subject: [PATCH 230/580] Handle all timeout errors consistently. Signed-off-by: Daniel Nephin --- compose/cli/errors.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 96fc5f8f..2c68d36d 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -3,12 +3,14 @@ from __future__ import unicode_literals import contextlib import logging +import socket from textwrap import dedent from docker.errors import APIError from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ReadTimeout from requests.exceptions import SSLError +from requests.packages.urllib3.exceptions import ReadTimeoutError from ..const import API_VERSION_TO_ENGINE_VERSION from ..const import HTTP_TIMEOUT @@ -42,7 +44,11 @@ def handle_connection_errors(client): except SSLError as e: log.error('SSL error: %s' % e) raise ConnectionError() - except RequestsConnectionError: + except RequestsConnectionError as e: + if e.args and isinstance(e.args[0], ReadTimeoutError): + log_timeout_error() + raise ConnectionError() + if call_silently(['which', 'docker']) != 0: if is_mac(): exit_with_error(docker_not_found_mac) @@ -55,16 +61,20 @@ def handle_connection_errors(client): except APIError as e: log_api_error(e, client.api_version) raise ConnectionError() - except ReadTimeout as e: - log.error( - "An HTTP request took too long to complete. Retry with --verbose to " - "obtain debug information.\n" - "If you encounter this issue regularly because of slow network " - "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher " - "value (current value: %s)." % HTTP_TIMEOUT) + except (ReadTimeout, socket.timeout) as e: + log_timeout_error() raise ConnectionError() +def log_timeout_error(): + log.error( + "An HTTP request took too long to complete. Retry with --verbose to " + "obtain debug information.\n" + "If you encounter this issue regularly because of slow network " + "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher " + "value (current value: %s)." % HTTP_TIMEOUT) + + def log_api_error(e, client_version): if b'client is newer than server' not in e.explanation: log.error(e.explanation) From 85c7d3e5ce821c7e8d6a7c85fc0b786f3a60ec93 Mon Sep 17 00:00:00 2001 From: Philip Walls Date: Sat, 20 Feb 2016 01:18:40 +0000 Subject: [PATCH 231/580] Add support for docker run --tmpfs flag. Signed-off-by: Philip Walls --- compose/config/config.py | 4 ++-- compose/config/config_schema_v1.json | 1 + compose/config/config_schema_v2.0.json | 1 + compose/service.py | 1 + docs/compose-file.md | 9 +++++++++ docs/extends.md | 4 ++-- requirements.txt | 2 +- tests/integration/service_test.py | 5 +++++ tests/unit/config/config_test.py | 15 +++++++++++++++ 9 files changed, 37 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f34809a9..961d0b57 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -591,7 +591,7 @@ def process_service(service_config): if 'extra_hosts' in service_dict: service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts']) - for field in ['dns', 'dns_search']: + for field in ['dns', 'dns_search', 'tmpfs']: if field in service_dict: service_dict[field] = to_list(service_dict[field]) @@ -730,7 +730,7 @@ def merge_service_dicts(base, override, version): ]: md.merge_field(field, operator.add, default=[]) - for field in ['dns', 'dns_search', 'env_file']: + for field in ['dns', 'dns_search', 'env_file', 'tmpfs']: md.merge_field(field, merge_list_or_string) for field in set(ALLOWED_KEYS) - set(md): diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json index 36a93793..9fad7d00 100644 --- a/compose/config/config_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -104,6 +104,7 @@ "shm_size": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { "type": "object", diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 33afc9b2..e84d1317 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -184,6 +184,7 @@ "shm_size": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { "type": "object", diff --git a/compose/service.py b/compose/service.py index 30d28e4c..f8b13607 100644 --- a/compose/service.py +++ b/compose/service.py @@ -668,6 +668,7 @@ class Service(object): cgroup_parent=options.get('cgroup_parent'), cpu_quota=options.get('cpu_quota'), shm_size=options.get('shm_size'), + tmpfs=options.get('tmpfs'), ) def build(self, no_cache=False, pull=False, force_rm=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index 85875512..09de5615 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -226,6 +226,15 @@ Custom DNS search domains. Can be a single value or a list. - dc1.example.com - dc2.example.com +### tmpfs + +Mount a temporary file system inside the container. Can be a single value or a list. + + tmpfs: /run + tmpfs: + - /run + - /tmp + ### entrypoint Override the default entrypoint. diff --git a/docs/extends.md b/docs/extends.md index 9ecccd8a..6f457391 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -302,8 +302,8 @@ replaces the old value. > This is because `build` and `image` cannot be used together in a version 1 > file. -For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and -`dns_search`, Compose concatenates both sets of values: +For the **multi-value options** `ports`, `expose`, `external_links`, `dns`, +`dns_search`, and `tmpfs`, Compose concatenates both sets of values: # original service expose: diff --git a/requirements.txt b/requirements.txt index 074864d4..2b7c85e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.2.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@d8be3e0fce60fbe25be088b64bccbcee83effdb1#egg=docker-py +git+https://github.com/docker/docker-py.git@8c4546f8c8f52bb2923834783a17beb5bb89a724#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6d0c97db..22cbfcee 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -875,6 +875,11 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) + def test_tmpfs(self): + service = self.create_service('web', tmpfs=['/run']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.Tmpfs'), {'/run': ''}) + def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') container = service.create_container() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d0e82420..e3dac160 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1194,6 +1194,21 @@ class ConfigTest(unittest.TestCase): } ] + def test_tmpfs_option(self): + actual = config.load(build_config_details({ + 'web': { + 'image': 'alpine', + 'tmpfs': '/run', + } + })) + assert actual.services == [ + { + 'name': 'web', + 'image': 'alpine', + 'tmpfs': ['/run'], + } + ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): base = { 'volumes': ['.:/app'], From 089ec6652223acdde9c594aadfc104237e1cfbf8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 14 Mar 2016 22:02:25 -0400 Subject: [PATCH 232/580] Include network settings as part of the service config hash. Signed-off-by: Daniel Nephin --- compose/service.py | 2 +- tests/unit/service_test.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index 30d28e4c..3f77ce4e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -498,7 +498,7 @@ class Service(object): 'image_id': self.image()['Id'], 'links': self.get_link_names(), 'net': self.network_mode.id, - 'networks': list(self.networks.keys()), + 'networks': self.networks, 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 199aeeb4..45836e01 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -285,7 +285,7 @@ class ServiceTest(unittest.TestCase): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], - 'f8bfa1058ad1f4231372a0b1639f0dfdb574dafff4e8d7938049ae993f7cf1fc') + '2524a06fcb3d781aa2c981fc40bcfa08013bb318e4273bfa388df22023e6f2aa') assert opts['environment'] == ['also=real'] def test_get_container_create_options_sets_affinity_with_binds(self): @@ -501,6 +501,7 @@ class ServiceTest(unittest.TestCase): image='example.com/foo', client=self.mock_client, network_mode=ServiceNetworkMode(Service('other')), + networks={'default': None}, links=[(Service('one'), 'one')], volumes_from=[VolumeFromSpec(Service('two'), 'rw', 'service')]) @@ -510,7 +511,7 @@ class ServiceTest(unittest.TestCase): 'options': {'image': 'example.com/foo'}, 'links': [('one', 'one')], 'net': 'other', - 'networks': [], + 'networks': {'default': None}, 'volumes_from': [('two', 'rw')], } assert config_dict == expected @@ -531,7 +532,7 @@ class ServiceTest(unittest.TestCase): 'image_id': 'abcd', 'options': {'image': 'example.com/foo'}, 'links': [], - 'networks': [], + 'networks': {}, 'net': 'aaabbb', 'volumes_from': [], } From dfac48f3f58fb777ae8d5e577f1fb1c6fc000e4c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 16 Mar 2016 22:37:28 -0400 Subject: [PATCH 233/580] Make a new flaky test less flaky. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f06b3280..ee1eed53 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1226,6 +1226,10 @@ class CLITestCase(DockerClientTestCase): 'logscomposefile_another_1', 'exited')) + # sleep for a short period to allow the tailing thread to receive the + # event. This is not great, but there isn't an easy way to do this + # without being able to stream stdout from the process. + time.sleep(0.5) os.kill(proc.pid, signal.SIGINT) result = wait_on_process(proc, returncode=1) assert 'test' in result.stdout From 7fc40dd7ccb5b839340c666a0902eb7bc47c80a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kalle=20M=C3=B8ller?= Date: Mon, 14 Mar 2016 01:54:15 +0100 Subject: [PATCH 234/580] Adding ssl_version to docker_clients kwargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select tls version based of COMPOSE_TLS_VERSION Changed from SSL to TLS Also did docs - missing default value Using getattr and raises AttributeError in case of unsupported version Signed-off-by: Kalle Møller --- compose/cli/command.py | 15 ++++++++++++--- compose/cli/docker_client.py | 4 ++-- docs/reference/envvars.md | 4 ++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 55f6df01..7e219e11 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import logging import os import re +import ssl import six @@ -37,8 +38,8 @@ def get_config_path_from_options(options): return None -def get_client(verbose=False, version=None): - client = docker_client(version=version) +def get_client(verbose=False, version=None, tls_version=None): + client = docker_client(version=version, tls_version=tls_version) if verbose: version_info = six.iteritems(client.version()) log.info(get_version_info('full')) @@ -57,7 +58,15 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False) api_version = os.environ.get( 'COMPOSE_API_VERSION', API_VERSIONS[config_data.version]) - client = get_client(verbose=verbose, version=api_version) + compose_tls_version = os.environ.get( + 'COMPOSE_TLS_VERSION', + None) + + tls_version = None + if compose_tls_version: + tls_version = ssl.getattr("PROTOCOL_{}".format(compose_tls_version)) + + client = get_client(verbose=verbose, version=api_version, tls_version=tls_version) return Project.from_config(project_name, config_data, client) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 9e79fe77..5663a57c 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,7 +14,7 @@ from .errors import UserError log = logging.getLogger(__name__) -def docker_client(version=None): +def docker_client(version=None, tls_version=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -24,7 +24,7 @@ def docker_client(version=None): "Please use COMPOSE_HTTP_TIMEOUT instead.") try: - kwargs = kwargs_from_env(assert_hostname=False) + kwargs = kwargs_from_env(assert_hostname=False, ssl_version=tls_version) except TLSParameterError: raise UserError( "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY " diff --git a/docs/reference/envvars.md b/docs/reference/envvars.md index e1170be9..ca88276e 100644 --- a/docs/reference/envvars.md +++ b/docs/reference/envvars.md @@ -75,6 +75,10 @@ Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TL Configures the time (in seconds) a request to the Docker daemon is allowed to hang before Compose considers it failed. Defaults to 60 seconds. +## COMPOSE\_TLS\_VERSION + +Configure which TLS version is used for TLS communication with the `docker` daemon, defaults to `TBD` +Can be `TLSv1`, `TLSv1_1`, `TLSv1_2`. ## Related Information From 187ea4cd814a3de1201afe5a50097935183d7f9f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 15 Mar 2016 17:00:24 -0700 Subject: [PATCH 235/580] Add --all option to rm command - remove one-off containers Signed-off-by: Joffrey F --- compose/cli/main.py | 9 +++++++-- compose/project.py | 16 ++++++++++------ docs/reference/rm.md | 1 + tests/acceptance/cli_test.py | 22 ++++++++++++++++++++++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index ea3054ab..5ca8d23d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -491,8 +491,12 @@ class TopLevelCommand(object): Options: -f, --force Don't ask to confirm removal -v Remove volumes associated with containers + -a, --all Also remove one-off containers """ - all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + all_containers = self.project.containers( + service_names=options['SERVICE'], stopped=True, + one_off=(None if options.get('--all') else False) + ) stopped_containers = [c for c in all_containers if not c.is_running] if len(stopped_containers) > 0: @@ -501,7 +505,8 @@ class TopLevelCommand(object): or yesno("Are you sure? [yN] ", default=False): self.project.remove_stopped( service_names=options['SERVICE'], - v=options.get('-v', False) + v=options.get('-v', False), + one_off=options.get('--all') ) else: print("No stopped containers") diff --git a/compose/project.py b/compose/project.py index dbfe6a12..298396de 100644 --- a/compose/project.py +++ b/compose/project.py @@ -47,10 +47,12 @@ class Project(object): self.networks = networks or ProjectNetworks({}, False) def labels(self, one_off=False): - return [ - '{0}={1}'.format(LABEL_PROJECT, self.name), - '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), - ] + labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] + if one_off is not None: + labels.append( + '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") + ) + return labels @classmethod def from_config(cls, name, config_data, client): @@ -249,8 +251,10 @@ class Project(object): def kill(self, service_names=None, **options): parallel.parallel_kill(self.containers(service_names), options) - def remove_stopped(self, service_names=None, **options): - parallel.parallel_remove(self.containers(service_names, stopped=True), options) + def remove_stopped(self, service_names=None, one_off=False, **options): + parallel.parallel_remove(self.containers( + service_names, stopped=True, one_off=(None if one_off else False) + ), options) def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop() diff --git a/docs/reference/rm.md b/docs/reference/rm.md index f8479224..97698b58 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -17,6 +17,7 @@ Usage: rm [options] [SERVICE...] Options: -f, --force Don't ask to confirm removal -v Remove volumes associated with containers +-a, --all Also remove one-off containers ``` Removes stopped service containers. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f06b3280..778c8ff4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1125,6 +1125,28 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) + def test_rm_all(self): + service = self.project.get_service('simple') + service.create_container(one_off=False) + service.create_container(one_off=True) + kill_service(service) + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=True)), 1) + self.dispatch(['rm', '-f'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + self.assertEqual(len(service.containers(stopped=True, one_off=True)), 1) + self.dispatch(['rm', '-f', '-a'], None) + self.assertEqual(len(service.containers(stopped=True, one_off=True)), 0) + + service.create_container(one_off=False) + service.create_container(one_off=True) + kill_service(service) + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=True)), 1) + self.dispatch(['rm', '-f', '--all'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + self.assertEqual(len(service.containers(stopped=True, one_off=True)), 0) + def test_stop(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') From 5826a2147b1817c278dd8918e9cc8bbce6844b9e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 16 Mar 2016 19:47:46 -0700 Subject: [PATCH 236/580] Use enum to represent 3 possible states of the one_off filter Signed-off-by: Joffrey F --- compose/cli/main.py | 13 ++++++---- compose/project.py | 23 +++++++++++++---- tests/acceptance/cli_test.py | 43 ++++++++++++++++--------------- tests/integration/service_test.py | 5 ++-- tests/unit/service_test.py | 3 ++- 5 files changed, 53 insertions(+), 34 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5ca8d23d..f481d584 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -22,6 +22,7 @@ from ..const import DEFAULT_TIMEOUT from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError from ..project import NoSuchService +from ..project import OneOffFilter from ..service import BuildAction from ..service import BuildError from ..service import ConvergenceStrategy @@ -437,7 +438,7 @@ class TopLevelCommand(object): """ containers = sorted( self.project.containers(service_names=options['SERVICE'], stopped=True) + - self.project.containers(service_names=options['SERVICE'], one_off=True), + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), key=attrgetter('name')) if options['-q']: @@ -491,11 +492,13 @@ class TopLevelCommand(object): Options: -f, --force Don't ask to confirm removal -v Remove volumes associated with containers - -a, --all Also remove one-off containers + -a, --all Also remove one-off containers created by + docker-compose run """ + one_off = OneOffFilter.include if options.get('--all') else OneOffFilter.exclude + all_containers = self.project.containers( - service_names=options['SERVICE'], stopped=True, - one_off=(None if options.get('--all') else False) + service_names=options['SERVICE'], stopped=True, one_off=one_off ) stopped_containers = [c for c in all_containers if not c.is_running] @@ -506,7 +509,7 @@ class TopLevelCommand(object): self.project.remove_stopped( service_names=options['SERVICE'], v=options.get('-v', False), - one_off=options.get('--all') + one_off=one_off ) else: print("No stopped containers") diff --git a/compose/project.py b/compose/project.py index 298396de..aef556e9 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,6 +6,7 @@ import logging import operator from functools import reduce +import enum from docker.errors import APIError from . import parallel @@ -35,6 +36,20 @@ from .volume import ProjectVolumes log = logging.getLogger(__name__) +@enum.unique +class OneOffFilter(enum.Enum): + include = 0 + exclude = 1 + only = 2 + + @classmethod + def update_labels(cls, value, labels): + if value == cls.only: + labels.append('{0}={1}'.format(LABEL_ONE_OFF, "True")) + elif value == cls.exclude or value is False: + labels.append('{0}={1}'.format(LABEL_ONE_OFF, "False")) + + class Project(object): """ A collection of services. @@ -48,10 +63,8 @@ class Project(object): def labels(self, one_off=False): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] - if one_off is not None: - labels.append( - '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") - ) + + OneOffFilter.update_labels(one_off, labels) return labels @classmethod @@ -253,7 +266,7 @@ class Project(object): def remove_stopped(self, service_names=None, one_off=False, **options): parallel.parallel_remove(self.containers( - service_names, stopped=True, one_off=(None if one_off else False) + service_names, stopped=True, one_off=one_off ), options) def down(self, remove_image_type, include_volumes, remove_orphans=False): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 778c8ff4..382fa887 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -18,6 +18,7 @@ from docker import errors from .. import mock from compose.cli.command import get_project from compose.container import Container +from compose.project import OneOffFilter from tests.integration.testcases import DockerClientTestCase from tests.integration.testcases import get_links from tests.integration.testcases import pull_busybox @@ -105,7 +106,7 @@ class CLITestCase(DockerClientTestCase): self.project.kill() self.project.remove_stopped() - for container in self.project.containers(stopped=True, one_off=True): + for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): container.remove(force=True) networks = self.client.networks() @@ -802,7 +803,7 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(self.project.containers()), 0) # Ensure stdin/out was open - container = self.project.containers(stopped=True, one_off=True)[0] + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] config = container.inspect()['Config'] self.assertTrue(config['AttachStderr']) self.assertTrue(config['AttachStdout']) @@ -852,7 +853,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['run', 'implicit']) service = self.project.get_service('implicit') - containers = service.containers(stopped=True, one_off=True) + containers = service.containers(stopped=True, one_off=OneOffFilter.only) self.assertEqual( [c.human_readable_command for c in containers], [u'/bin/sh -c echo "success"'], @@ -860,7 +861,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['run', 'explicit']) service = self.project.get_service('explicit') - containers = service.containers(stopped=True, one_off=True) + containers = service.containers(stopped=True, one_off=OneOffFilter.only) self.assertEqual( [c.human_readable_command for c in containers], [u'/bin/true'], @@ -871,7 +872,7 @@ class CLITestCase(DockerClientTestCase): name = 'service' self.dispatch(['run', '--entrypoint', '/bin/echo', name, 'helloworld']) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] self.assertEqual( shlex.split(container.human_readable_command), [u'/bin/echo', u'helloworld'], @@ -883,7 +884,7 @@ class CLITestCase(DockerClientTestCase): user = 'sshd' self.dispatch(['run', '--user={user}'.format(user=user), name], returncode=1) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] self.assertEqual(user, container.get('Config.User')) def test_run_service_with_user_overridden_short_form(self): @@ -892,7 +893,7 @@ class CLITestCase(DockerClientTestCase): user = 'sshd' self.dispatch(['run', '-u', user, name], returncode=1) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] self.assertEqual(user, container.get('Config.User')) def test_run_service_with_environement_overridden(self): @@ -906,7 +907,7 @@ class CLITestCase(DockerClientTestCase): '/bin/true', ]) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] # env overriden self.assertEqual('notbar', container.environment['foo']) # keep environement from yaml @@ -920,7 +921,7 @@ class CLITestCase(DockerClientTestCase): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_random = container.get_local_port(3000) @@ -937,7 +938,7 @@ class CLITestCase(DockerClientTestCase): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', '--service-ports', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_random = container.get_local_port(3000) @@ -958,7 +959,7 @@ class CLITestCase(DockerClientTestCase): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_short = container.get_local_port(3000) @@ -980,7 +981,7 @@ class CLITestCase(DockerClientTestCase): '--publish', '127.0.0.1:30001:3001', 'simple' ]) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_short = container.get_local_port(3000) @@ -997,7 +998,7 @@ class CLITestCase(DockerClientTestCase): # create one off container self.base_dir = 'tests/fixtures/expose-composefile' self.dispatch(['run', '-d', '--service-ports', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] ports = container.ports self.assertEqual(len(ports), 9) @@ -1021,7 +1022,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['run', '--name', name, 'service', '/bin/true']) service = self.project.get_service('service') - container, = service.containers(stopped=True, one_off=True) + container, = service.containers(stopped=True, one_off=OneOffFilter.only) self.assertEqual(container.name, name) def test_run_service_with_workdir_overridden(self): @@ -1051,7 +1052,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['run', 'app', 'nslookup', 'db']) containers = self.project.get_service('app').containers( - stopped=True, one_off=True) + stopped=True, one_off=OneOffFilter.only) assert len(containers) == 2 for container in containers: @@ -1071,7 +1072,7 @@ class CLITestCase(DockerClientTestCase): self.dispatch(['up', '-d']) self.dispatch(['run', '-d', 'app', 'top']) - container = self.project.get_service('app').containers(one_off=True)[0] + container = self.project.get_service('app').containers(one_off=OneOffFilter.only)[0] networks = container.get('NetworkSettings.Networks') assert sorted(list(networks)) == [ @@ -1131,21 +1132,21 @@ class CLITestCase(DockerClientTestCase): service.create_container(one_off=True) kill_service(service) self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(service.containers(stopped=True, one_off=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) - self.assertEqual(len(service.containers(stopped=True, one_off=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) self.dispatch(['rm', '-f', '-a'], None) - self.assertEqual(len(service.containers(stopped=True, one_off=True)), 0) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) service.create_container(one_off=False) service.create_container(one_off=True) kill_service(service) self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(service.containers(stopped=True, one_off=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) self.dispatch(['rm', '-f', '--all'], None) self.assertEqual(len(service.containers(stopped=True)), 0) - self.assertEqual(len(service.containers(stopped=True, one_off=True)), 0) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) def test_stop(self): self.dispatch(['up', '-d'], None) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6d0c97db..2682e59d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -24,6 +24,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION from compose.container import Container +from compose.project import OneOffFilter from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode @@ -60,7 +61,7 @@ class ServiceTest(DockerClientTestCase): db = self.create_service('db') container = db.create_container(one_off=True) self.assertEqual(db.containers(stopped=True), []) - self.assertEqual(db.containers(one_off=True, stopped=True), [container]) + self.assertEqual(db.containers(one_off=OneOffFilter.only, stopped=True), [container]) def test_project_is_added_to_container_name(self): service = self.create_service('web') @@ -494,7 +495,7 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(db) create_and_start_container(db) - c = create_and_start_container(db, one_off=True) + c = create_and_start_container(db, one_off=OneOffFilter.only) self.assertEqual( set(get_links(c)), diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 199aeeb4..0e3e8a86 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -14,6 +14,7 @@ from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container +from compose.project import OneOffFilter from compose.service import build_ulimits from compose.service import build_volume_binding from compose.service import BuildAction @@ -256,7 +257,7 @@ class ServiceTest(unittest.TestCase): opts = service._get_container_create_options( {'name': name}, 1, - one_off=True) + one_off=OneOffFilter.only) self.assertEqual(opts['name'], name) def test_get_container_create_options_does_not_mutate_options(self): From 1bc946967497d848e4ac18a8420fddd793236a31 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 21 Mar 2016 14:42:57 +0000 Subject: [PATCH 237/580] Don't allow boolean values for one_off in Project methods Signed-off-by: Aanand Prasad --- compose/project.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/compose/project.py b/compose/project.py index aef556e9..c3283db9 100644 --- a/compose/project.py +++ b/compose/project.py @@ -46,8 +46,12 @@ class OneOffFilter(enum.Enum): def update_labels(cls, value, labels): if value == cls.only: labels.append('{0}={1}'.format(LABEL_ONE_OFF, "True")) - elif value == cls.exclude or value is False: + elif value == cls.exclude: labels.append('{0}={1}'.format(LABEL_ONE_OFF, "False")) + elif value == cls.include: + pass + else: + raise ValueError("Invalid value for one_off: {}".format(repr(value))) class Project(object): @@ -61,7 +65,7 @@ class Project(object): self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) - def labels(self, one_off=False): + def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] OneOffFilter.update_labels(one_off, labels) @@ -264,7 +268,7 @@ class Project(object): def kill(self, service_names=None, **options): parallel.parallel_kill(self.containers(service_names), options) - def remove_stopped(self, service_names=None, one_off=False, **options): + def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **options): parallel.parallel_remove(self.containers( service_names, stopped=True, one_off=one_off ), options) @@ -429,7 +433,7 @@ class Project(object): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) - def _labeled_containers(self, stopped=False, one_off=False): + def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude): return list(filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( @@ -437,7 +441,7 @@ class Project(object): filters={'label': self.labels(one_off=one_off)})]) ) - def containers(self, service_names=None, stopped=False, one_off=False): + def containers(self, service_names=None, stopped=False, one_off=OneOffFilter.exclude): if service_names: self.validate_service_names(service_names) else: From 81f6d86ad9121e022b61c03be8977bd79e1b2fdd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 21 Mar 2016 15:14:31 +0000 Subject: [PATCH 238/580] Warn when --all is not passed to rm Signed-off-by: Aanand Prasad --- compose/cli/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index f481d584..a978579c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -495,7 +495,14 @@ class TopLevelCommand(object): -a, --all Also remove one-off containers created by docker-compose run """ - one_off = OneOffFilter.include if options.get('--all') else OneOffFilter.exclude + if options.get('--all'): + one_off = OneOffFilter.include + else: + log.warn( + 'Not including one-off containers created by `docker-compose run`.\n' + 'To include them, use `docker-compose rm --all`.\n' + 'This will be the default behavior in the next version of Compose.\n') + one_off = OneOffFilter.exclude all_containers = self.project.containers( service_names=options['SERVICE'], stopped=True, one_off=one_off From a2317dfac26d5709bf460671bd7e054567fc94de Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 21 Mar 2016 16:15:49 +0000 Subject: [PATCH 239/580] Remove one-off containers in 'docker-compose down' Signed-off-by: Aanand Prasad --- compose/project.py | 2 +- tests/acceptance/cli_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index c3283db9..f0b4f1c6 100644 --- a/compose/project.py +++ b/compose/project.py @@ -276,7 +276,7 @@ class Project(object): def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop() self.find_orphan_containers(remove_orphans) - self.remove_stopped(v=include_volumes) + self.remove_stopped(v=include_volumes, one_off=OneOffFilter.include) self.networks.remove() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 382fa887..15351502 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -366,14 +366,19 @@ class CLITestCase(DockerClientTestCase): @v2_only() def test_down(self): self.base_dir = 'tests/fixtures/v2-full' + self.dispatch(['up', '-d']) wait_on_condition(ContainerCountCondition(self.project, 2)) + self.dispatch(['run', 'web', 'true']) + assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 1 + result = self.dispatch(['down', '--rmi=local', '--volumes']) assert 'Stopping v2full_web_1' in result.stderr assert 'Stopping v2full_other_1' 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 volume v2full_data' in result.stderr assert 'Removing image v2full_web' in result.stderr assert 'Removing image busybox' not in result.stderr From 2bf5e468574f5863ed57a1b5327668e61a578130 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 21 Mar 2016 18:08:07 +0000 Subject: [PATCH 240/580] Stop and remove still-running one-off containers in 'down' Signed-off-by: Aanand Prasad --- compose/project.py | 6 +++--- tests/acceptance/cli_test.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/compose/project.py b/compose/project.py index f0b4f1c6..8aa48731 100644 --- a/compose/project.py +++ b/compose/project.py @@ -239,8 +239,8 @@ class Project(object): return containers - def stop(self, service_names=None, **options): - containers = self.containers(service_names) + def stop(self, service_names=None, one_off=OneOffFilter.exclude, **options): + containers = self.containers(service_names, one_off=one_off) def get_deps(container): # actually returning inversed dependencies @@ -274,7 +274,7 @@ class Project(object): ), options) def down(self, remove_image_type, include_volumes, remove_orphans=False): - self.stop() + self.stop(one_off=OneOffFilter.include) self.find_orphan_containers(remove_orphans) self.remove_stopped(v=include_volumes, one_off=OneOffFilter.include) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 15351502..b81af68d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -371,14 +371,17 @@ class CLITestCase(DockerClientTestCase): wait_on_condition(ContainerCountCondition(self.project, 2)) self.dispatch(['run', 'web', 'true']) - assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 1 + self.dispatch(['run', '-d', 'web', 'tail', '-f', '/dev/null']) + 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 'Removing image busybox' not in result.stderr From be1476f24b5d19eca5078b7305bd6425c7ee7d78 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 21 Mar 2016 14:41:28 -0400 Subject: [PATCH 241/580] Only allow tmpfs on v2. Signed-off-by: Daniel Nephin --- compose/config/config_schema_v1.json | 1 - tests/integration/service_test.py | 2 ++ tests/unit/config/config_test.py | 9 ++++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json index 9fad7d00..36a93793 100644 --- a/compose/config/config_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -104,7 +104,6 @@ "shm_size": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { "type": "object", diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 22cbfcee..e3485346 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,6 +28,7 @@ from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode from compose.service import Service +from tests.integration.testcases import v2_only def create_and_start_container(service, **override_options): @@ -875,6 +876,7 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) + @v2_only() def test_tmpfs(self): service = self.create_service('web', tmpfs=['/run']) container = create_and_start_container(service) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e3dac160..04d82c81 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1196,9 +1196,12 @@ class ConfigTest(unittest.TestCase): def test_tmpfs_option(self): actual = config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'tmpfs': '/run', + 'version': '2', + 'services': { + 'web': { + 'image': 'alpine', + 'tmpfs': '/run', + } } })) assert actual.services == [ From 5c968f9e15e09bb53337a41560ddd2cd517d80c9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 21 Mar 2016 19:07:50 -0400 Subject: [PATCH 242/580] Fix flaky partial_change state test. Signed-off-by: Daniel Nephin --- compose/parallel.py | 18 +++++++----------- tests/integration/state_test.py | 4 ++-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 879d183e..c629a1ab 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -32,8 +32,8 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): done = 0 errors = {} + results = [] error_to_reraise = None - returned = [None] * len(objects) while done < len(objects): try: @@ -46,14 +46,13 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): if exception is None: writer.write(get_name(obj), 'done') - returned[objects.index(obj)] = result + results.append(result) elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation writer.write(get_name(obj), 'error') else: errors[get_name(obj)] = exception error_to_reraise = exception - done += 1 for obj_name, error in errors.items(): @@ -62,7 +61,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): if error_to_reraise: raise error_to_reraise - return returned + return results def _no_deps(x): @@ -74,9 +73,8 @@ def setup_queue(objects, func, get_deps, get_name): get_deps = _no_deps results = Queue() - - started = set() # objects, threads were started for - finished = set() # already finished objects + started = set() # objects being processed + finished = set() # objects which have been processed def do_op(obj): try: @@ -96,11 +94,9 @@ def setup_queue(objects, func, get_deps, get_name): ) def feed(): - ready_objects = [o for o in objects if ready(o)] - for obj in ready_objects: + for obj in filter(ready, objects): started.add(obj) - t = Thread(target=do_op, - args=(obj,)) + t = Thread(target=do_op, args=(obj,)) t.daemon = True t.start() diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 36099d2d..07b28e78 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -38,8 +38,8 @@ class BasicProjectTest(ProjectTestCase): super(BasicProjectTest, self).setUp() self.cfg = { - 'db': {'image': 'busybox:latest'}, - 'web': {'image': 'busybox:latest'}, + 'db': {'image': 'busybox:latest', 'command': 'top'}, + 'web': {'image': 'busybox:latest', 'command': 'top'}, } def test_no_change(self): From 1ac33ea7e5f5c2c4d4facd5b52143f0a962515bc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 15 Mar 2016 14:19:25 -0700 Subject: [PATCH 243/580] Add support for TLS config command-line options Signed-off-by: Joffrey F --- compose/cli/command.py | 15 +++++++++---- compose/cli/docker_client.py | 41 +++++++++++++++++++++++++++++++++++- compose/cli/main.py | 7 ++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 55f6df01..730cd115 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,6 +12,7 @@ from .. import config from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client +from .docker_client import TLSArgs from .utils import get_version_info log = logging.getLogger(__name__) @@ -23,6 +24,8 @@ def project_from_options(project_dir, options): get_config_path_from_options(options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), + host=options.get('--host'), + tls_args=TLSArgs.from_options(options), ) @@ -37,8 +40,8 @@ def get_config_path_from_options(options): return None -def get_client(verbose=False, version=None): - client = docker_client(version=version) +def get_client(verbose=False, version=None, tls_args=None, host=None): + client = docker_client(version=version, tls_args=tls_args, host=host) if verbose: version_info = six.iteritems(client.version()) log.info(get_version_info('full')) @@ -49,7 +52,8 @@ def get_client(verbose=False, version=None): return client -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_args=None): config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) @@ -57,7 +61,10 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False) api_version = os.environ.get( 'COMPOSE_API_VERSION', API_VERSIONS[config_data.version]) - client = get_client(verbose=verbose, version=api_version) + client = get_client( + verbose=verbose, version=api_version, tls_args=tls_args, + host=host + ) return Project.from_config(project_name, config_data, client) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 9e79fe77..cff28f8c 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -3,9 +3,11 @@ from __future__ import unicode_literals import logging import os +from collections import namedtuple from docker import Client from docker.errors import TLSParameterError +from docker.tls import TLSConfig from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT @@ -14,7 +16,24 @@ from .errors import UserError log = logging.getLogger(__name__) -def docker_client(version=None): +class TLSArgs(namedtuple('_TLSArgs', 'tls cert key ca_cert verify')): + @classmethod + def from_options(cls, options): + return cls( + tls=options.get('--tls', False), + ca_cert=options.get('--tlscacert'), + cert=options.get('--tlscert'), + key=options.get('--tlskey'), + verify=options.get('--tlsverify') + ) + + # def has_config(self): + # return ( + # self.tls or self.ca_cert or self.cert or self.key or self.verify + # ) + + +def docker_client(version=None, tls_args=None, host=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -31,6 +50,26 @@ def docker_client(version=None): "and DOCKER_CERT_PATH are set correctly.\n" "You might need to run `eval \"$(docker-machine env default)\"`") + if host: + kwargs['base_url'] = host + if tls_args and any(tls_args): + if tls_args.tls is True: + kwargs['tls'] = True + else: + client_cert = None + if tls_args.cert or tls_args.key: + client_cert = (tls_args.cert, tls_args.key) + try: + kwargs['tls'] = TLSConfig( + client_cert=client_cert, verify=tls_args.verify, + ca_cert=tls_args.ca_cert + ) + except TLSParameterError as e: + raise UserError( + "TLS configuration is invalid. Please double-check the " + "TLS command-line arguments. ({0})".format(e) + ) + if version: kwargs['version'] = version diff --git a/compose/cli/main.py b/compose/cli/main.py index a978579c..17c2ac45 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -149,6 +149,13 @@ class TopLevelCommand(object): -p, --project-name NAME Specify an alternate project name (default: directory name) --verbose Show more output -v, --version Print version and exit + -H, --host HOST Daemon socket to connect to + + --tls Use TLS; implied by --tlsverify + --tlsacert Trust certs signed only by this CA + --tlscert Path to TLS certificate file + --tlskey Path to TLS key file + --tlsverify Use TLS and verify the remote Commands: build Build or rebuild services From 7166408d2a9f9972e4a7f60f30228808f2260117 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 16 Mar 2016 13:40:13 -0700 Subject: [PATCH 244/580] Fixed typos + simplified TLSConfig creation process. Signed-off-by: Joffrey F --- compose/cli/command.py | 12 ++++---- compose/cli/docker_client.py | 53 ++++++++++++++---------------------- compose/cli/main.py | 20 +++++++------- 3 files changed, 36 insertions(+), 49 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 730cd115..63d387f0 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,7 +12,7 @@ from .. import config from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client -from .docker_client import TLSArgs +from .docker_client import tls_config_from_options from .utils import get_version_info log = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def project_from_options(project_dir, options): project_name=options.get('--project-name'), verbose=options.get('--verbose'), host=options.get('--host'), - tls_args=TLSArgs.from_options(options), + tls_config=tls_config_from_options(options), ) @@ -40,8 +40,8 @@ def get_config_path_from_options(options): return None -def get_client(verbose=False, version=None, tls_args=None, host=None): - client = docker_client(version=version, tls_args=tls_args, host=host) +def get_client(verbose=False, version=None, tls_config=None, host=None): + client = docker_client(version=version, tls_config=tls_config, host=host) if verbose: version_info = six.iteritems(client.version()) log.info(get_version_info('full')) @@ -53,7 +53,7 @@ def get_client(verbose=False, version=None, tls_args=None, host=None): def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_args=None): + host=None, tls_config=None): config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) @@ -62,7 +62,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, 'COMPOSE_API_VERSION', API_VERSIONS[config_data.version]) client = get_client( - verbose=verbose, version=api_version, tls_args=tls_args, + verbose=verbose, version=api_version, tls_config=tls_config, host=host ) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index cff28f8c..c8159ad4 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import logging import os -from collections import namedtuple from docker import Client from docker.errors import TLSParameterError @@ -16,24 +15,27 @@ from .errors import UserError log = logging.getLogger(__name__) -class TLSArgs(namedtuple('_TLSArgs', 'tls cert key ca_cert verify')): - @classmethod - def from_options(cls, options): - return cls( - tls=options.get('--tls', False), - ca_cert=options.get('--tlscacert'), - cert=options.get('--tlscert'), - key=options.get('--tlskey'), - verify=options.get('--tlsverify') +def tls_config_from_options(options): + tls = options.get('--tls', False) + ca_cert = options.get('--tlscacert') + cert = options.get('--tlscert') + key = options.get('--tlskey') + verify = options.get('--tlsverify') + + if tls is True: + return True + elif any([ca_cert, cert, key, verify]): + client_cert = None + if cert or key: + client_cert = (cert, key) + return TLSConfig( + client_cert=client_cert, verify=verify, ca_cert=ca_cert ) - - # def has_config(self): - # return ( - # self.tls or self.ca_cert or self.cert or self.key or self.verify - # ) + else: + return None -def docker_client(version=None, tls_args=None, host=None): +def docker_client(version=None, tls_config=None, host=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -52,23 +54,8 @@ def docker_client(version=None, tls_args=None, host=None): if host: kwargs['base_url'] = host - if tls_args and any(tls_args): - if tls_args.tls is True: - kwargs['tls'] = True - else: - client_cert = None - if tls_args.cert or tls_args.key: - client_cert = (tls_args.cert, tls_args.key) - try: - kwargs['tls'] = TLSConfig( - client_cert=client_cert, verify=tls_args.verify, - ca_cert=tls_args.ca_cert - ) - except TLSParameterError as e: - raise UserError( - "TLS configuration is invalid. Please double-check the " - "TLS command-line arguments. ({0})".format(e) - ) + if tls_config: + kwargs['tls'] = tls_config if version: kwargs['version'] = version diff --git a/compose/cli/main.py b/compose/cli/main.py index 17c2ac45..331476e2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -145,17 +145,17 @@ class TopLevelCommand(object): docker-compose -h|--help Options: - -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) - -p, --project-name NAME Specify an alternate project name (default: directory name) - --verbose Show more output - -v, --version Print version and exit - -H, --host HOST Daemon socket to connect to + -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) + -p, --project-name NAME Specify an alternate project name (default: directory name) + --verbose Show more output + -v, --version Print version and exit + -H, --host HOST Daemon socket to connect to - --tls Use TLS; implied by --tlsverify - --tlsacert Trust certs signed only by this CA - --tlscert Path to TLS certificate file - --tlskey Path to TLS key file - --tlsverify Use TLS and verify the remote + --tls Use TLS; implied by --tlsverify + --tlscacert CA_PATH Trust certs signed only by this CA + --tlscert CLIENT_CERT_PATH Path to TLS certificate file + --tlskey TLS_KEY_PATH Path to TLS key file + --tlsverify Use TLS and verify the remote Commands: build Build or rebuild services From 26f3861791a82ddee9171a6710f595b0136c4ab3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 17 Mar 2016 16:09:45 -0700 Subject: [PATCH 245/580] Specifying --tls no longer overrides all other TLS options Add an option to skip hostname verification Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 9 ++++++--- compose/cli/main.py | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index c8159ad4..e2848a90 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -22,14 +22,17 @@ def tls_config_from_options(options): key = options.get('--tlskey') verify = options.get('--tlsverify') - if tls is True: + advanced_opts = any([ca_cert, cert, key, verify]) + + if tls is True and not advanced_opts: return True - elif any([ca_cert, cert, key, verify]): + elif advanced_opts: client_cert = None if cert or key: client_cert = (cert, key) return TLSConfig( - client_cert=client_cert, verify=verify, ca_cert=ca_cert + client_cert=client_cert, verify=verify, ca_cert=ca_cert, + assert_hostname=options.get('--skip-hostname-check') ) else: return None diff --git a/compose/cli/main.py b/compose/cli/main.py index 331476e2..6eada097 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -156,6 +156,9 @@ class TopLevelCommand(object): --tlscert CLIENT_CERT_PATH Path to TLS certificate file --tlskey TLS_KEY_PATH Path to TLS key file --tlsverify Use TLS and verify the remote + --skip-hostname-check 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) Commands: build Build or rebuild services From 442dff72b4568656821189e3d45617e3f87f63c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Mar 2016 12:06:29 -0700 Subject: [PATCH 246/580] Improve assert_hostname setting in tls_config_from_options Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e2848a90..d47bd2db 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -8,6 +8,7 @@ from docker import Client from docker.errors import TLSParameterError from docker.tls import TLSConfig from docker.utils import kwargs_from_env +from requests.utils import urlparse from ..const import HTTP_TIMEOUT from .errors import UserError @@ -21,6 +22,7 @@ def tls_config_from_options(options): cert = options.get('--tlscert') key = options.get('--tlskey') verify = options.get('--tlsverify') + hostname = urlparse(options.get('--host', '')).hostname advanced_opts = any([ca_cert, cert, key, verify]) @@ -32,7 +34,9 @@ def tls_config_from_options(options): client_cert = (cert, key) return TLSConfig( client_cert=client_cert, verify=verify, ca_cert=ca_cert, - assert_hostname=options.get('--skip-hostname-check') + assert_hostname=( + hostname or not options.get('--skip-hostname-check', False) + ) ) else: return None From 472711531749aa3e9909ec8e386e4bd73027530b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Mar 2016 12:10:00 -0700 Subject: [PATCH 247/580] Bump docker-py version to include tcp host fix Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2b7c85e6..88367353 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.2.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@8c4546f8c8f52bb2923834783a17beb5bb89a724#egg=docker-py +git+https://github.com/docker/docker-py.git@5c1c42397cf0fdb74182df2d69822b82df8f2a6a#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 From 2cc87555cb1e1c1c8322ca4dcae21f00025800aa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Mar 2016 14:23:31 -0700 Subject: [PATCH 248/580] tls_config_from_options unit tests Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 2 +- tests/fixtures/tls/ca.pem | 0 tests/fixtures/tls/cert.pem | 0 tests/fixtures/tls/key.key | 0 tests/unit/cli/docker_client_test.py | 89 +++++++++++++++++++++++++++- 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/tls/ca.pem create mode 100644 tests/fixtures/tls/cert.pem create mode 100644 tests/fixtures/tls/key.key diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index d47bd2db..deb56866 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -22,7 +22,7 @@ def tls_config_from_options(options): cert = options.get('--tlscert') key = options.get('--tlskey') verify = options.get('--tlsverify') - hostname = urlparse(options.get('--host', '')).hostname + hostname = urlparse(options.get('--host') or '').hostname advanced_opts = any([ca_cert, cert, key, verify]) diff --git a/tests/fixtures/tls/ca.pem b/tests/fixtures/tls/ca.pem new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/tls/cert.pem b/tests/fixtures/tls/cert.pem new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/tls/key.key b/tests/fixtures/tls/key.key new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index d497495b..b55f1d17 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -3,7 +3,11 @@ from __future__ import unicode_literals import os -from compose.cli import docker_client +import docker +import pytest + +from compose.cli.docker_client import docker_client +from compose.cli.docker_client import tls_config_from_options from tests import mock from tests import unittest @@ -13,10 +17,89 @@ class DockerClientTestCase(unittest.TestCase): def test_docker_client_no_home(self): with mock.patch.dict(os.environ): del os.environ['HOME'] - docker_client.docker_client() + docker_client() def test_docker_client_with_custom_timeout(self): timeout = 300 with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300): - client = docker_client.docker_client() + client = docker_client() self.assertEqual(client.timeout, int(timeout)) + + +class TLSConfigTestCase(unittest.TestCase): + ca_cert = 'tests/fixtures/tls/ca.pem' + client_cert = 'tests/fixtures/tls/cert.pem' + key = 'tests/fixtures/tls/key.key' + + def test_simple_tls(self): + options = {'--tls': True} + result = tls_config_from_options(options) + assert result is True + + def test_tls_ca_cert(self): + options = { + '--tlscacert': self.ca_cert, '--tlsverify': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_ca_cert_explicit(self): + options = { + '--tlscacert': self.ca_cert, '--tls': True, + '--tlsverify': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_client_cert(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + + def test_tls_client_cert_explicit(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key, + '--tls': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + + def test_tls_client_and_ca(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key, + '--tlsverify': True, '--tlscacert': self.ca_cert + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_client_and_ca_explicit(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key, + '--tlsverify': True, '--tlscacert': self.ca_cert, + '--tls': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_client_missing_key(self): + options = {'--tlscert': self.client_cert} + with pytest.raises(docker.errors.TLSParameterError): + tls_config_from_options(options) + + options = {'--tlskey': self.key} + with pytest.raises(docker.errors.TLSParameterError): + tls_config_from_options(options) From a53b29467a681405e51318561ac0167ab2665504 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 22 Mar 2016 17:17:06 -0700 Subject: [PATCH 249/580] Update wordpress example to use official images The orchardup images are very old and not maintained. Signed-off-by: Ben Firshman --- docs/wordpress.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/wordpress.md b/docs/wordpress.md index 62f50c24..fcfaef19 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -36,8 +36,10 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t In this case, your Dockerfile should include these two lines: - FROM orchardup/php5 + FROM php:5.6-fpm + RUN docker-php-ext-install mysql ADD . /code + CMD php -S 0.0.0.0:8000 -t /code/wordpress/ This tells the Docker Engine daemon how to build an image defining a container that contains PHP and WordPress. @@ -47,7 +49,6 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t services: web: build: . - command: php -S 0.0.0.0:8000 -t /code/wordpress/ ports: - "8000:8000" depends_on: @@ -55,9 +56,12 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t volumes: - .:/code db: - image: orchardup/mysql + image: mysql environment: + MYSQL_ROOT_PASSWORD: wordpress MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: wordpress 5. Download WordPress into the current directory: @@ -71,8 +75,8 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t Date: Mon, 21 Mar 2016 18:24:11 -0700 Subject: [PATCH 250/580] fixed links showing as build errors per PR #3180 fixed links per build errors Signed-off-by: Victoria Bialas --- docs/compose-file.md | 2 +- docs/networking.md | 6 +++--- docs/swarm.md | 9 +++------ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 09de5615..e9ec0a2d 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -744,7 +744,7 @@ While it is possible to declare volumes on the fly as part of the service declaration, this section allows you to create named volumes that can be reused across multiple services (without relying on `volumes_from`), and are easily retrieved and inspected using the docker command line or API. -See the [docker volume](/engine/reference/commandline/volume_create.md) +See the [docker volume](https://docs.docker.com/engine/reference/commandline/volume_create/) subcommand documentation for more information. ### driver diff --git a/docs/networking.md b/docs/networking.md index bc568294..9739a088 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -15,7 +15,7 @@ weight=21 > **Note:** This document only applies if you're using [version 2 of the Compose file format](compose-file.md#versioning). Networking features are not supported for version 1 (legacy) Compose files. By default Compose sets up a single -[network](/engine/reference/commandline/network_create.md) for your app. Each +[network](https://docs.docker.com/engine/reference/commandline/network_create/) for your app. Each container for a service joins the default network and is both *reachable* by other containers on that network, and *discoverable* by them at a hostname identical to the container name. @@ -78,11 +78,11 @@ See the [links reference](compose-file.md#links) for more information. When [deploying a Compose application to a Swarm cluster](swarm.md), you can make use of the built-in `overlay` driver to enable multi-host communication between containers with no changes to your Compose file or application code. -Consult the [Getting started with multi-host networking](/engine/userguide/networking/get-started-overlay.md) to see how to set up a Swarm cluster. The cluster will use the `overlay` driver by default, but you can specify it explicitly if you prefer - see below for how to do this. +Consult the [Getting started with multi-host networking](https://docs.docker.com/engine/userguide/networking/get-started-overlay/) to see how to set up a Swarm cluster. The cluster will use the `overlay` driver by default, but you can specify it explicitly if you prefer - see below for how to do this. ## Specifying custom networks -Instead of just using the default app network, you can specify your own networks with the top-level `networks` key. This lets you create more complex topologies and specify [custom network drivers](/engine/extend/plugins_network.md) and options. You can also use it to connect services to externally-created networks which aren't managed by Compose. +Instead of just using the default app network, you can specify your own networks with the top-level `networks` key. This lets you create more complex topologies and specify [custom network drivers](https://docs.docker.com/engine/extend/plugins_network/) and options. You can also use it to connect services to externally-created networks which aren't managed by Compose. Each service can specify what networks to connect to with the *service-level* `networks` key, which is a list of names referencing entries under the *top-level* `networks` key. diff --git a/docs/swarm.md b/docs/swarm.md index 2b609efa..ece72193 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -26,14 +26,11 @@ format](compose-file.md#versioning) you are using: - subject to the [limitations](#limitations) described below, - - as long as the Swarm cluster is configured to use the [overlay - driver](/engine/userguide/networking/dockernetworks.md#an-overlay-network), + - as long as the Swarm cluster is configured to use the [overlay driver](https://docs.docker.com/engine/userguide/networking/dockernetworks/#an-overlay-network), or a custom driver which supports multi-host networking. -Read the [Getting started with multi-host -networking](/engine/userguide/networking/get-started-overlay.md) to see how to -set up a Swarm cluster with [Docker Machine](/machine/overview) and the overlay driver. -Once you've got it running, deploying your app to it should be as simple as: +Read [Get started with multi-host networking](https://docs.docker.com/engine/userguide/networking/get-started-overlay/) to see how to +set up a Swarm cluster with [Docker Machine](/machine/overview) and the overlay driver. Once you've got it running, deploying your app to it should be as simple as: $ eval "$(docker-machine env --swarm )" $ docker-compose up From 9094c4d97de72dc8ba8d607e21823c44f67896aa Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 24 Mar 2016 10:43:09 +0100 Subject: [PATCH 251/580] prepare bash completion for new TLS options Up to now there were two special top-level options that required special treatment: --project-name and --file (and their short forms). For 1.7.0, several TLS related options were added that have to be passed to secondary docker-compose invocations as well. This commit introduces a scalable treatment of those options. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 58 +++++++++++++------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 528970b4..c1c06045 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -18,11 +18,22 @@ __docker_compose_q() { - local file_args - if [ ${#compose_files[@]} -ne 0 ] ; then - file_args="${compose_files[@]/#/-f }" - fi - docker-compose 2>/dev/null $file_args ${compose_project:+-p $compose_project} "$@" + docker-compose 2>/dev/null $daemon_options "$@" +} + +# Transforms a multiline list of strings into a single line string +# with the words separated by "|". +__docker_compose_to_alternatives() { + local parts=( $1 ) + local IFS='|' + echo "${parts[*]}" +} + +# Transforms a multiline list of options into an extglob pattern +# suitable for use in case statements. +__docker_compose_to_extglob() { + local extglob=$( __docker_compose_to_alternatives "$1" ) + echo "@($extglob)" } # suppress trailing whitespace @@ -31,20 +42,6 @@ __docker_compose_nospace() { type compopt &>/dev/null && compopt -o nospace } -# For compatibility reasons, Compose and therefore its completion supports several -# stack compositon files as listed here, in descending priority. -# Support for these filenames might be dropped in some future version. -__docker_compose_compose_file() { - local file - for file in docker-compose.y{,a}ml ; do - [ -e $file ] && { - echo $file - return - } - done - echo docker-compose.yml -} - # Extracts all service names from the compose file. ___docker_compose_all_services_in_compose_file() { __docker_compose_q config --services @@ -142,7 +139,7 @@ _docker_compose_docker_compose() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--file -f --help -h --project-name -p --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$daemon_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -452,6 +449,13 @@ _docker_compose() { version ) + # options for the docker daemon that have to be passed to secondary calls to + # docker-compose executed by this script + local daemon_options_with_args=" + --file -f + --project-name -p + " + COMPREPLY=() local cur prev words cword _get_comp_words_by_ref -n : cur prev words cword @@ -459,17 +463,15 @@ _docker_compose() { # search subcommand and invoke its handler. # special treatment of some top-level options local command='docker_compose' + local daemon_options=() local counter=1 - local compose_files=() compose_project + while [ $counter -lt $cword ]; do case "${words[$counter]}" in - --file|-f) - (( counter++ )) - compose_files+=(${words[$counter]}) - ;; - --project-name|-p) - (( counter++ )) - compose_project="${words[$counter]}" + $(__docker_compose_to_extglob "$daemon_options_with_args") ) + local opt=${words[counter]} + local arg=${words[++counter]} + daemon_options+=($opt $arg) ;; -*) ;; From 5b2c2e332fb5a142b7ed7bdebdcefc4441d0bab9 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 24 Mar 2016 11:15:25 +0100 Subject: [PATCH 252/580] bash completion for TLS options Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index c1c06045..e7dba344 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -128,18 +128,22 @@ _docker_compose_create() { _docker_compose_docker_compose() { case "$prev" in + --tlscacert|--tlscert|--tlskey) + _filedir + return + ;; --file|-f) _filedir "y?(a)ml" return ;; - --project-name|-p) + $(__docker_compose_to_extglob "$daemon_options_with_args") ) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "$daemon_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$daemon_boolean_options $daemon_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -451,9 +455,18 @@ _docker_compose() { # options for the docker daemon that have to be passed to secondary calls to # docker-compose executed by this script + local daemon_boolean_options=" + --skip-hostname-check + --tls + --tlsverify + " local daemon_options_with_args=" --file -f + --host -H --project-name -p + --tlscacert + --tlscert + --tlskey " COMPREPLY=() @@ -468,6 +481,10 @@ _docker_compose() { while [ $counter -lt $cword ]; do case "${words[$counter]}" in + $(__docker_compose_to_extglob "$daemon_boolean_options") ) + local opt=${words[counter]} + daemon_options+=($opt) + ;; $(__docker_compose_to_extglob "$daemon_options_with_args") ) local opt=${words[counter]} local arg=${words[++counter]} From 8282bb1b24cc0f51210ffd94a55edf8876bcb814 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 24 Mar 2016 14:41:51 +0000 Subject: [PATCH 253/580] Add TLS flags to CLI reference Signed-off-by: Aanand Prasad --- docs/reference/overview.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 09f2817a..d59fa565 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -25,10 +25,20 @@ Usage: docker-compose -h|--help Options: - -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) - -p, --project-name NAME Specify an alternate project name (default: directory name) - --verbose Show more output - -v, --version Print version and exit + -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) + -p, --project-name NAME Specify an alternate project name (default: directory name) + --verbose Show more output + -v, --version Print version and exit + -H, --host HOST Daemon socket to connect to + + --tls Use TLS; implied by --tlsverify + --tlscacert CA_PATH Trust certs signed only by this CA + --tlscert CLIENT_CERT_PATH Path to TLS certificate file + --tlskey TLS_KEY_PATH Path to TLS key file + --tlsverify Use TLS and verify the remote + --skip-hostname-check 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) Commands: build Build or rebuild services From d8fb9d8831143c1f037f6a494883cfa9b6d85313 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 24 Mar 2016 17:11:10 +0100 Subject: [PATCH 254/580] bash completion for new `docker logs` options Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e7dba344..3b9c9aed 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -211,9 +211,15 @@ _docker_compose_kill() { _docker_compose_logs() { + case "$prev" in + --tail) + return + ;; + esac + case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --no-color" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--follow -f --help --no-color --tail --timestamps -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From 5416e4c99bea4a5c80705e7813d76bf8fa927578 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 24 Mar 2016 17:21:56 +0100 Subject: [PATCH 255/580] bash completion for `docker-compose up --build` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e7dba344..043edafd 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -407,7 +407,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) + 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" ) ) ;; *) __docker_compose_services_all From b030c3928a3d06cce644542001f41712d3c5fbb1 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 24 Mar 2016 17:29:04 +0100 Subject: [PATCH 256/580] bash completion for `docker-compose rm --all` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e7dba344..054ccafc 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -301,7 +301,7 @@ _docker_compose_restart() { _docker_compose_rm() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force -f --help -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--all -a --force -f --help -v" -- "$cur" ) ) ;; *) __docker_compose_services_stopped From 732531b722453d62a3202d5bbc76a42f30c6e2cc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 24 Mar 2016 12:07:53 -0400 Subject: [PATCH 257/580] Disable a test that is failing against 1.11.0rc1. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b0541406..e2ef1161 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -6,6 +6,7 @@ import shutil import tempfile from os import path +import pytest from docker.errors import APIError from six import StringIO from six import text_type @@ -985,6 +986,7 @@ class ServiceTest(DockerClientTestCase): one_off_container = service.create_container(one_off=True) self.assertNotEqual(one_off_container.name, 'my-web-container') + @pytest.mark.skipif(True, reason="Broken on 1.11.0rc1") def test_log_drive_invalid(self): service = self.create_service('web', logging={'driver': 'xxx'}) expected_error_msg = "logger: no log driver named 'xxx' is registered" From c9b02b7b34de0463ff22ee7bfda666543d1f2955 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 24 Mar 2016 18:02:12 +0100 Subject: [PATCH 258/580] bash completion for `docker-compose exec` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e7dba344..9476d854 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -186,6 +186,24 @@ _docker_compose_events() { } +_docker_compose_exec() { + case "$prev" in + --index|--user) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user" -- "$cur" ) ) + ;; + *) + __docker_compose_services_running + ;; + esac +} + + _docker_compose_help() { COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) } @@ -435,6 +453,7 @@ _docker_compose() { create down events + exec help kill logs From 60470fb9f121eaf690d5594a1a01639f20d152fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 23 Mar 2016 16:33:00 -0700 Subject: [PATCH 259/580] Require docker-py 1.8.0rc2 Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 88367353..91d0487c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 +docker-py==1.8.0rc2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@5c1c42397cf0fdb74182df2d69822b82df8f2a6a#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/setup.py b/setup.py index df4172ce..7caae97d 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.7.0, < 2', + 'docker-py > 1.7.2, < 2', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From c69d8a3bd2584044348aa6a444cf22ed1fd5f43d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Mar 2016 15:49:42 -0800 Subject: [PATCH 260/580] Implement environment singleton to be accessed throughout the code Load and parse environment file from working dir Signed-off-by: Joffrey F --- compose/cli/command.py | 13 +++-- compose/cli/main.py | 2 +- compose/config/__init__.py | 1 + compose/config/config.py | 40 +++++++++----- compose/config/environment.py | 69 +++++++++++++++++++++++++ compose/config/interpolation.py | 23 +-------- tests/acceptance/cli_test.py | 4 +- tests/helpers.py | 13 +++++ tests/integration/service_test.py | 3 +- tests/unit/cli_test.py | 7 +-- tests/unit/config/config_test.py | 36 +++++++------ tests/unit/config/interpolation_test.py | 2 + tests/unit/interpolation_test.py | 2 +- 13 files changed, 151 insertions(+), 64 deletions(-) create mode 100644 compose/config/environment.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 63d387f0..3fcfbb4b 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) def project_from_options(project_dir, options): return get_project( project_dir, - get_config_path_from_options(options), + get_config_path_from_options(project_dir, options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), host=options.get('--host'), @@ -29,12 +29,13 @@ def project_from_options(project_dir, options): ) -def get_config_path_from_options(options): +def get_config_path_from_options(base_dir, options): file_option = options.get('--file') if file_option: return file_option - config_files = os.environ.get('COMPOSE_FILE') + environment = config.environment.get_instance(base_dir) + config_files = environment.get('COMPOSE_FILE') if config_files: return config_files.split(os.pathsep) return None @@ -57,8 +58,9 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) + environment = config.environment.get_instance(project_dir) - api_version = os.environ.get( + api_version = environment.get( 'COMPOSE_API_VERSION', API_VERSIONS[config_data.version]) client = get_client( @@ -73,7 +75,8 @@ def get_project_name(working_dir, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') + environment = config.environment.get_instance(working_dir) + project_name = project_name or environment.get('COMPOSE_PROJECT_NAME') if project_name: return normalize_name(project_name) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6eada097..3fa3e3a0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -222,7 +222,7 @@ class TopLevelCommand(object): --services Print the service names, one per line. """ - config_path = get_config_path_from_options(config_options) + config_path = get_config_path_from_options(self.project_dir, config_options) compose_config = config.load(config.find(self.project_dir, config_path)) if options['--quiet']: diff --git a/compose/config/__init__.py b/compose/config/__init__.py index dd01f221..7cf71eb9 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +from . import environment from .config import ConfigurationError from .config import DOCKER_CONFIG_KEYS from .config import find diff --git a/compose/config/config.py b/compose/config/config.py index 961d0b57..c9c4e308 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -17,6 +17,7 @@ from cached_property import cached_property from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V2_0 as V2_0 from ..utils import build_string_dict +from .environment import Environment from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError @@ -211,7 +212,8 @@ def find(base_dir, filenames): if filenames == ['-']: return ConfigDetails( os.getcwd(), - [ConfigFile(None, yaml.safe_load(sys.stdin))]) + [ConfigFile(None, yaml.safe_load(sys.stdin))], + ) if filenames: filenames = [os.path.join(base_dir, f) for f in filenames] @@ -221,7 +223,8 @@ def find(base_dir, filenames): log.debug("Using configuration files: {}".format(",".join(filenames))) return ConfigDetails( os.path.dirname(filenames[0]), - [ConfigFile.from_filename(f) for f in filenames]) + [ConfigFile.from_filename(f) for f in filenames], + ) def validate_config_version(config_files): @@ -288,6 +291,10 @@ def load(config_details): """ validate_config_version(config_details.config_files) + # load environment in working dir for later use in interpolation + # it is done here to avoid having to pass down working_dir + Environment.get_instance(config_details.working_dir) + processed_files = [ process_config_file(config_file) for config_file in config_details.config_files @@ -302,9 +309,8 @@ def load(config_details): config_details.config_files, 'get_networks', 'Network' ) service_dicts = load_services( - config_details.working_dir, - main_file, - [file.get_service_dicts() for file in config_details.config_files]) + config_details, main_file, + ) if main_file.version != V1: for service_dict in service_dicts: @@ -348,14 +354,16 @@ def load_mapping(config_files, get_func, entity_type): return mapping -def load_services(working_dir, config_file, service_configs): +def load_services(config_details, config_file): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( - working_dir, + config_details.working_dir, config_file.filename, service_name, service_dict) - resolver = ServiceExtendsResolver(service_config, config_file) + resolver = ServiceExtendsResolver( + service_config, config_file + ) service_dict = process_service(resolver.run()) service_config = service_config._replace(config=service_dict) @@ -383,6 +391,10 @@ def load_services(working_dir, config_file, service_configs): for name in all_service_names } + service_configs = [ + file.get_service_dicts() for file in config_details.config_files + ] + service_config = service_configs[0] for next_config in service_configs[1:]: service_config = merge_services(service_config, next_config) @@ -462,8 +474,8 @@ class ServiceExtendsResolver(object): extends_file = ConfigFile.from_filename(config_path) validate_config_version([self.config_file, extends_file]) extended_file = process_config_file( - extends_file, - service_name=service_name) + extends_file, service_name=service_name + ) service_config = extended_file.get_service(service_name) return config_path, service_config, service_name @@ -476,7 +488,8 @@ class ServiceExtendsResolver(object): service_name, service_dict), self.config_file, - already_seen=self.already_seen + [self.signature]) + already_seen=self.already_seen + [self.signature], + ) service_config = resolver.run() other_service_dict = process_service(service_config) @@ -824,10 +837,11 @@ def parse_ulimits(ulimits): def resolve_env_var(key, val): + environment = Environment.get_instance() if val is not None: return key, val - elif key in os.environ: - return key, os.environ[key] + elif key in environment: + return key, environment[key] else: return key, None diff --git a/compose/config/environment.py b/compose/config/environment.py new file mode 100644 index 00000000..45f1c43f --- /dev/null +++ b/compose/config/environment.py @@ -0,0 +1,69 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import logging +import os + +from .errors import ConfigurationError + +log = logging.getLogger(__name__) + + +class BlankDefaultDict(dict): + def __init__(self, *args, **kwargs): + super(BlankDefaultDict, self).__init__(*args, **kwargs) + self.missing_keys = [] + + def __getitem__(self, key): + try: + return super(BlankDefaultDict, self).__getitem__(key) + except KeyError: + if key not in self.missing_keys: + log.warn( + "The {} variable is not set. Defaulting to a blank string." + .format(key) + ) + self.missing_keys.append(key) + + return "" + + +class Environment(BlankDefaultDict): + __instance = None + + @classmethod + def get_instance(cls, base_dir='.'): + if cls.__instance: + return cls.__instance + + instance = cls(base_dir) + cls.__instance = instance + return instance + + @classmethod + def reset(cls): + cls.__instance = None + + def __init__(self, base_dir): + super(Environment, self).__init__() + self.load_environment_file(os.path.join(base_dir, '.env')) + self.update(os.environ) + + def load_environment_file(self, path): + if not os.path.exists(path): + return + mapping = {} + with open(path, 'r') as f: + for line in f.readlines(): + line = line.strip() + if '=' not in line: + raise ConfigurationError( + 'Invalid environment variable mapping in env file. ' + 'Missing "=" in "{0}"'.format(line) + ) + mapping.__setitem__(*line.split('=', 1)) + self.update(mapping) + + +def get_instance(base_dir=None): + return Environment.get_instance(base_dir) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 1e56ebb6..b76638d9 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -2,17 +2,17 @@ from __future__ import absolute_import from __future__ import unicode_literals import logging -import os from string import Template import six +from .environment import Environment from .errors import ConfigurationError log = logging.getLogger(__name__) def interpolate_environment_variables(config, section): - mapping = BlankDefaultDict(os.environ) + mapping = Environment.get_instance() def process_item(name, config_dict): return dict( @@ -60,25 +60,6 @@ def interpolate(string, mapping): raise InvalidInterpolation(string) -class BlankDefaultDict(dict): - def __init__(self, *args, **kwargs): - super(BlankDefaultDict, self).__init__(*args, **kwargs) - self.missing_keys = [] - - def __getitem__(self, key): - try: - return super(BlankDefaultDict, self).__getitem__(key) - except KeyError: - if key not in self.missing_keys: - log.warn( - "The {} variable is not set. Defaulting to a blank string." - .format(key) - ) - self.missing_keys.append(key) - - return "" - - class InvalidInterpolation(Exception): def __init__(self, string): self.string = string diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 707c2492..9d50ea99 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -15,7 +15,7 @@ from operator import attrgetter import yaml from docker import errors -from .. import mock +from ..helpers import clear_environment from compose.cli.command import get_project from compose.container import Container from compose.project import OneOffFilter @@ -1452,7 +1452,7 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(containers), 1) self.assertIn("FOO=1", containers[0].get('Config.Env')) - @mock.patch.dict(os.environ) + @clear_environment def test_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' os.environ['HOME'] = '/tmp/home-dir' diff --git a/tests/helpers.py b/tests/helpers.py index dd0b668e..2c3d5a98 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,9 +1,14 @@ from __future__ import absolute_import from __future__ import unicode_literals +import functools +import os + +from . import mock from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load +from compose.config.environment import Environment def build_config(contents, **kwargs): @@ -14,3 +19,11 @@ def build_config_details(contents, working_dir='working_dir', filename='filename return ConfigDetails( working_dir, [ConfigFile(filename, contents)]) + + +def clear_environment(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + Environment.reset() + with mock.patch.dict(os.environ): + f(self, *args, **kwargs) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index e2ef1161..a1857b58 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -12,6 +12,7 @@ from six import StringIO from six import text_type from .. import mock +from ..helpers import clear_environment from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox @@ -912,7 +913,7 @@ class ServiceTest(DockerClientTestCase): }.items(): self.assertEqual(env[k], v) - @mock.patch.dict(os.environ) + @clear_environment def test_resolve_env(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index e0ada460..fd8aa95c 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -11,6 +11,7 @@ import pytest from .. import mock from .. import unittest from ..helpers import build_config +from ..helpers import clear_environment from compose.cli.command import get_project from compose.cli.command import get_project_name from compose.cli.docopt_command import NoSuchCommand @@ -43,11 +44,11 @@ class CLITestCase(unittest.TestCase): project_name = get_project_name(None, project_name=name) self.assertEquals('explicitprojectname', project_name) + @clear_environment def test_project_name_from_environment_new_var(self): name = 'namefromenv' - with mock.patch.dict(os.environ): - os.environ['COMPOSE_PROJECT_NAME'] = name - project_name = get_project_name(None) + os.environ['COMPOSE_PROJECT_NAME'] = name + project_name = get_project_name(None) self.assertEquals(project_name, name) def test_project_name_with_empty_environment_var(self): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 04d82c81..9bd76fb9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -23,6 +23,7 @@ from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest +from tests.helpers import clear_environment DEFAULT_VERSION = V2_0 @@ -1581,7 +1582,7 @@ class PortsTest(unittest.TestCase): class InterpolationTest(unittest.TestCase): - @mock.patch.dict(os.environ) + @clear_environment def test_config_file_with_environment_variable(self): os.environ.update( IMAGE="busybox", @@ -1604,7 +1605,7 @@ class InterpolationTest(unittest.TestCase): } ]) - @mock.patch.dict(os.environ) + @clear_environment def test_unset_variable_produces_warning(self): os.environ.pop('FOO', None) os.environ.pop('BAR', None) @@ -1628,7 +1629,7 @@ class InterpolationTest(unittest.TestCase): self.assertIn('BAR', warnings[0]) self.assertIn('FOO', warnings[1]) - @mock.patch.dict(os.environ) + @clear_environment def test_invalid_interpolation(self): with self.assertRaises(config.ConfigurationError) as cm: config.load( @@ -1667,7 +1668,7 @@ class VolumeConfigTest(unittest.TestCase): d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') self.assertEqual(d['volumes'], ['/data']) - @mock.patch.dict(os.environ) + @clear_environment def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' @@ -1681,7 +1682,7 @@ class VolumeConfigTest(unittest.TestCase): self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') - @mock.patch.dict(os.environ) + @clear_environment def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.') @@ -1739,7 +1740,7 @@ class VolumeConfigTest(unittest.TestCase): working_dir='c:\\Users\\me\\myproject') self.assertEqual(d['volumes'], ['c:\\Users\\me\\otherproject:/data']) - @mock.patch.dict(os.environ) + @clear_environment def test_home_directory_with_driver_does_not_expand(self): os.environ['NAME'] = 'surprise!' d = make_service_dict('foo', { @@ -2025,7 +2026,7 @@ class EnvTest(unittest.TestCase): def test_parse_environment_empty(self): self.assertEqual(config.parse_environment(None), {}) - @mock.patch.dict(os.environ) + @clear_environment def test_resolve_environment(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' @@ -2072,7 +2073,7 @@ class EnvTest(unittest.TestCase): assert 'Couldn\'t find env file' in exc.exconly() assert 'nonexistent.env' in exc.exconly() - @mock.patch.dict(os.environ) + @clear_environment def test_resolve_environment_from_env_file_with_empty_values(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' @@ -2087,7 +2088,7 @@ class EnvTest(unittest.TestCase): }, ) - @mock.patch.dict(os.environ) + @clear_environment def test_resolve_build_args(self): os.environ['env_arg'] = 'value2' @@ -2106,7 +2107,7 @@ class EnvTest(unittest.TestCase): ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') - @mock.patch.dict(os.environ) + @clear_environment def test_resolve_path(self): os.environ['HOSTENV'] = '/tmp' os.environ['CONTAINERENV'] = '/host/tmp' @@ -2393,7 +2394,7 @@ class ExtendsTest(unittest.TestCase): assert 'net: container' in excinfo.exconly() assert 'cannot be extended' in excinfo.exconly() - @mock.patch.dict(os.environ) + @clear_environment def test_load_config_runs_interpolation_in_extended_service(self): os.environ.update(HOSTNAME_VALUE="penguin") expected_interpolated_value = "host-penguin" @@ -2465,6 +2466,7 @@ class ExtendsTest(unittest.TestCase): }, ])) + @clear_environment def test_extends_with_environment_and_env_files(self): tmpdir = py.test.ensuretemp('test_extends_with_environment') self.addCleanup(tmpdir.remove) @@ -2520,12 +2522,12 @@ class ExtendsTest(unittest.TestCase): }, }, ] - with mock.patch.dict(os.environ): - os.environ['SECRET'] = 'secret' - os.environ['THING'] = 'thing' - os.environ['COMMON_ENV_FILE'] = 'secret' - os.environ['TOP_ENV_FILE'] = 'secret' - config = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + + os.environ['SECRET'] = 'secret' + os.environ['THING'] = 'thing' + os.environ['COMMON_ENV_FILE'] = 'secret' + os.environ['TOP_ENV_FILE'] = 'secret' + config = load_from_filename(str(tmpdir.join('docker-compose.yml'))) assert config == expected diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 0691e886..b4ba7b40 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -6,12 +6,14 @@ import os import mock import pytest +from compose.config.environment import Environment from compose.config.interpolation import interpolate_environment_variables @pytest.yield_fixture def mock_env(): with mock.patch.dict(os.environ): + Environment.reset() os.environ['USER'] = 'jenny' os.environ['FOO'] = 'bar' yield diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py index 317982a9..b19fcdac 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest -from compose.config.interpolation import BlankDefaultDict as bddict +from compose.config.environment import BlankDefaultDict as bddict from compose.config.interpolation import interpolate from compose.config.interpolation import InvalidInterpolation From bf8e501b5e7f255459dd232afa937d065b28f905 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 7 Mar 2016 14:42:17 -0800 Subject: [PATCH 261/580] Fix pre-commit config Signed-off-by: Joffrey F --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e37677c6..1ae45dec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ - id: end-of-file-fixer - id: flake8 - id: name-tests-test - exclude: 'tests/(helpers\.py|integration/testcases\.py)' + exclude: 'tests/(integration/testcases\.py)|(helpers\.py)' - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports From b9ca5188a21f99a2798c5451049e27be3e75ac27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 7 Mar 2016 17:16:25 -0800 Subject: [PATCH 262/580] Remove Environment singleton, instead carry instance during config processing Project name and compose file detection also updated Signed-off-by: Joffrey F --- compose/cli/command.py | 6 +-- compose/config/config.py | 69 ++++++++++++++++++++------------- compose/config/environment.py | 22 +---------- compose/config/interpolation.py | 6 +-- 4 files changed, 48 insertions(+), 55 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 3fcfbb4b..8c0bf07f 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -34,7 +34,7 @@ def get_config_path_from_options(base_dir, options): if file_option: return file_option - environment = config.environment.get_instance(base_dir) + environment = config.environment.Environment(base_dir) config_files = environment.get('COMPOSE_FILE') if config_files: return config_files.split(os.pathsep) @@ -58,7 +58,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) - environment = config.environment.get_instance(project_dir) + environment = config.environment.Environment(project_dir) api_version = environment.get( 'COMPOSE_API_VERSION', @@ -75,7 +75,7 @@ def get_project_name(working_dir, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - environment = config.environment.get_instance(working_dir) + environment = config.environment.Environment(working_dir) project_name = project_name or environment.get('COMPOSE_PROJECT_NAME') if project_name: return normalize_name(project_name) diff --git a/compose/config/config.py b/compose/config/config.py index c9c4e308..7db66004 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -114,14 +114,24 @@ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' log = logging.getLogger(__name__) -class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files')): +class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files environment')): """ :param working_dir: the directory to use for relative paths in the config :type working_dir: string :param config_files: list of configuration files to load :type config_files: list of :class:`ConfigFile` + :param environment: computed environment values for this project + :type environment: :class:`environment.Environment` """ + def __new__(cls, working_dir, config_files): + return super(ConfigDetails, cls).__new__( + cls, + working_dir, + config_files, + Environment(working_dir), + ) + class ConfigFile(namedtuple('_ConfigFile', 'filename config')): """ @@ -291,12 +301,8 @@ def load(config_details): """ validate_config_version(config_details.config_files) - # load environment in working dir for later use in interpolation - # it is done here to avoid having to pass down working_dir - Environment.get_instance(config_details.working_dir) - processed_files = [ - process_config_file(config_file) + process_config_file(config_file, config_details.environment) for config_file in config_details.config_files ] config_details = config_details._replace(config_files=processed_files) @@ -362,7 +368,7 @@ def load_services(config_details, config_file): service_name, service_dict) resolver = ServiceExtendsResolver( - service_config, config_file + service_config, config_file, environment=config_details.environment ) service_dict = process_service(resolver.run()) @@ -371,7 +377,8 @@ def load_services(config_details, config_file): service_dict = finalize_service( service_config, service_names, - config_file.version) + config_file.version, + config_details.environment) return service_dict def build_services(service_config): @@ -402,16 +409,17 @@ def load_services(config_details, config_file): return build_services(service_config) -def interpolate_config_section(filename, config, section): +def interpolate_config_section(filename, config, section, environment): validate_config_section(filename, config, section) - return interpolate_environment_variables(config, section) + return interpolate_environment_variables(config, section, environment) -def process_config_file(config_file, service_name=None): +def process_config_file(config_file, environment, service_name=None): services = interpolate_config_section( config_file.filename, config_file.get_service_dicts(), - 'service') + 'service', + environment,) if config_file.version == V2_0: processed_config = dict(config_file.config) @@ -419,11 +427,13 @@ def process_config_file(config_file, service_name=None): processed_config['volumes'] = interpolate_config_section( config_file.filename, config_file.get_volumes(), - 'volume') + 'volume', + environment,) processed_config['networks'] = interpolate_config_section( config_file.filename, config_file.get_networks(), - 'network') + 'network', + environment,) if config_file.version == V1: processed_config = services @@ -440,11 +450,12 @@ def process_config_file(config_file, service_name=None): class ServiceExtendsResolver(object): - def __init__(self, service_config, config_file, already_seen=None): + def __init__(self, service_config, config_file, environment=None, already_seen=None): self.service_config = service_config self.working_dir = service_config.working_dir self.already_seen = already_seen or [] self.config_file = config_file + self.environment = environment or Environment(None) @property def signature(self): @@ -474,7 +485,7 @@ class ServiceExtendsResolver(object): extends_file = ConfigFile.from_filename(config_path) validate_config_version([self.config_file, extends_file]) extended_file = process_config_file( - extends_file, service_name=service_name + extends_file, self.environment, service_name=service_name ) service_config = extended_file.get_service(service_name) @@ -489,6 +500,7 @@ class ServiceExtendsResolver(object): service_dict), self.config_file, already_seen=self.already_seen + [self.signature], + environment=self.environment ) service_config = resolver.run() @@ -518,7 +530,7 @@ class ServiceExtendsResolver(object): return filename -def resolve_environment(service_dict): +def resolve_environment(service_dict, environment=None): """Unpack any environment variables from an env_file, if set. Interpolate environment values if set. """ @@ -527,12 +539,12 @@ def resolve_environment(service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) - return dict(resolve_env_var(k, v) 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): +def resolve_build_args(build, environment): args = parse_build_arguments(build.get('args')) - return dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) + return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args)) def validate_extended_service_dict(service_dict, filename, service): @@ -611,11 +623,11 @@ def process_service(service_config): return service_dict -def finalize_service(service_config, service_names, version): +def finalize_service(service_config, service_names, version, environment): service_dict = dict(service_config.config) if 'environment' in service_dict or 'env_file' in service_dict: - service_dict['environment'] = resolve_environment(service_dict) + service_dict['environment'] = resolve_environment(service_dict, environment) service_dict.pop('env_file', None) if 'volumes_from' in service_dict: @@ -642,7 +654,7 @@ def finalize_service(service_config, service_names, version): if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) - normalize_build(service_dict, service_config.working_dir) + normalize_build(service_dict, service_config.working_dir, environment) service_dict['name'] = service_config.name return normalize_v1_service_format(service_dict) @@ -836,11 +848,10 @@ def parse_ulimits(ulimits): return dict(ulimits) -def resolve_env_var(key, val): - environment = Environment.get_instance() +def resolve_env_var(key, val, environment): if val is not None: return key, val - elif key in environment: + elif environment and key in environment: return key, environment[key] else: return key, None @@ -880,7 +891,7 @@ def resolve_volume_path(working_dir, volume): return container_path -def normalize_build(service_dict, working_dir): +def normalize_build(service_dict, working_dir, environment): if 'build' in service_dict: build = {} @@ -890,7 +901,9 @@ def normalize_build(service_dict, working_dir): else: build.update(service_dict['build']) if 'args' in build: - build['args'] = build_string_dict(resolve_build_args(build)) + build['args'] = build_string_dict( + resolve_build_args(build, environment) + ) service_dict['build'] = build diff --git a/compose/config/environment.py b/compose/config/environment.py index 45f1c43f..87b41223 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -29,24 +29,10 @@ class BlankDefaultDict(dict): class Environment(BlankDefaultDict): - __instance = None - - @classmethod - def get_instance(cls, base_dir='.'): - if cls.__instance: - return cls.__instance - - instance = cls(base_dir) - cls.__instance = instance - return instance - - @classmethod - def reset(cls): - cls.__instance = None - def __init__(self, base_dir): super(Environment, self).__init__() - self.load_environment_file(os.path.join(base_dir, '.env')) + if base_dir: + self.load_environment_file(os.path.join(base_dir, '.env')) self.update(os.environ) def load_environment_file(self, path): @@ -63,7 +49,3 @@ class Environment(BlankDefaultDict): ) mapping.__setitem__(*line.split('=', 1)) self.update(mapping) - - -def get_instance(base_dir=None): - return Environment.get_instance(base_dir) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index b76638d9..63020d91 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -6,17 +6,15 @@ from string import Template import six -from .environment import Environment from .errors import ConfigurationError log = logging.getLogger(__name__) -def interpolate_environment_variables(config, section): - mapping = Environment.get_instance() +def interpolate_environment_variables(config, section, environment): def process_item(name, config_dict): return dict( - (key, interpolate_value(name, key, val, section, mapping)) + (key, interpolate_value(name, key, val, section, environment)) for key, val in (config_dict or {}).items() ) From 5831b869e879b1350f0610f41b2dbd58c5e6285f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 7 Mar 2016 17:18:19 -0800 Subject: [PATCH 263/580] Update tests for new environment handling Signed-off-by: Joffrey F --- .pre-commit-config.yaml | 2 +- tests/acceptance/cli_test.py | 4 +-- tests/helpers.py | 13 --------- tests/integration/service_test.py | 3 +-- tests/integration/testcases.py | 3 ++- tests/unit/cli_test.py | 3 +-- tests/unit/config/config_test.py | 36 +++++++++++++------------ tests/unit/config/interpolation_test.py | 9 ++++--- 8 files changed, 32 insertions(+), 41 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ae45dec..462fcc4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ - id: end-of-file-fixer - id: flake8 - id: name-tests-test - exclude: 'tests/(integration/testcases\.py)|(helpers\.py)' + exclude: 'tests/integration/testcases\.py' - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9d50ea99..707c2492 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -15,7 +15,7 @@ from operator import attrgetter import yaml from docker import errors -from ..helpers import clear_environment +from .. import mock from compose.cli.command import get_project from compose.container import Container from compose.project import OneOffFilter @@ -1452,7 +1452,7 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(containers), 1) self.assertIn("FOO=1", containers[0].get('Config.Env')) - @clear_environment + @mock.patch.dict(os.environ) def test_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' os.environ['HOME'] = '/tmp/home-dir' diff --git a/tests/helpers.py b/tests/helpers.py index 2c3d5a98..dd0b668e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,14 +1,9 @@ from __future__ import absolute_import from __future__ import unicode_literals -import functools -import os - -from . import mock from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load -from compose.config.environment import Environment def build_config(contents, **kwargs): @@ -19,11 +14,3 @@ def build_config_details(contents, working_dir='working_dir', filename='filename return ConfigDetails( working_dir, [ConfigFile(filename, contents)]) - - -def clear_environment(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - Environment.reset() - with mock.patch.dict(os.environ): - f(self, *args, **kwargs) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a1857b58..e2ef1161 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -12,7 +12,6 @@ from six import StringIO from six import text_type from .. import mock -from ..helpers import clear_environment from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox @@ -913,7 +912,7 @@ class ServiceTest(DockerClientTestCase): }.items(): self.assertEqual(env[k], v) - @clear_environment + @mock.patch.dict(os.environ) def test_resolve_env(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 8e2f2593..aaa002f3 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -12,6 +12,7 @@ from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment from compose.config.config import V1 from compose.config.config import V2_0 +from compose.config.environment import Environment from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output @@ -89,7 +90,7 @@ class DockerClientTestCase(unittest.TestCase): if 'command' not in kwargs: kwargs['command'] = ["top"] - kwargs['environment'] = resolve_environment(kwargs) + kwargs['environment'] = resolve_environment(kwargs, Environment(None)) labels = dict(kwargs.setdefault('labels', {})) labels['com.docker.compose.test-name'] = self.id() diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fd8aa95c..d8e0b33f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -11,7 +11,6 @@ import pytest from .. import mock from .. import unittest from ..helpers import build_config -from ..helpers import clear_environment from compose.cli.command import get_project from compose.cli.command import get_project_name from compose.cli.docopt_command import NoSuchCommand @@ -44,7 +43,7 @@ class CLITestCase(unittest.TestCase): project_name = get_project_name(None, project_name=name) self.assertEquals('explicitprojectname', project_name) - @clear_environment + @mock.patch.dict(os.environ) def test_project_name_from_environment_new_var(self): name = 'namefromenv' os.environ['COMPOSE_PROJECT_NAME'] = name diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 9bd76fb9..6dc7dbca 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -17,13 +17,13 @@ from compose.config.config import resolve_build_args from compose.config.config import resolve_environment from compose.config.config import V1 from compose.config.config import V2_0 +from compose.config.environment import Environment from compose.config.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest -from tests.helpers import clear_environment DEFAULT_VERSION = V2_0 @@ -1582,7 +1582,7 @@ class PortsTest(unittest.TestCase): class InterpolationTest(unittest.TestCase): - @clear_environment + @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): os.environ.update( IMAGE="busybox", @@ -1605,7 +1605,7 @@ class InterpolationTest(unittest.TestCase): } ]) - @clear_environment + @mock.patch.dict(os.environ) def test_unset_variable_produces_warning(self): os.environ.pop('FOO', None) os.environ.pop('BAR', None) @@ -1621,7 +1621,7 @@ class InterpolationTest(unittest.TestCase): None, ) - with mock.patch('compose.config.interpolation.log') as log: + with mock.patch('compose.config.environment.log') as log: config.load(config_details) self.assertEqual(2, log.warn.call_count) @@ -1629,7 +1629,7 @@ class InterpolationTest(unittest.TestCase): self.assertIn('BAR', warnings[0]) self.assertIn('FOO', warnings[1]) - @clear_environment + @mock.patch.dict(os.environ) def test_invalid_interpolation(self): with self.assertRaises(config.ConfigurationError) as cm: config.load( @@ -1668,7 +1668,7 @@ class VolumeConfigTest(unittest.TestCase): d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') self.assertEqual(d['volumes'], ['/data']) - @clear_environment + @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' @@ -1682,7 +1682,7 @@ class VolumeConfigTest(unittest.TestCase): self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') - @clear_environment + @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.') @@ -1740,7 +1740,7 @@ class VolumeConfigTest(unittest.TestCase): working_dir='c:\\Users\\me\\myproject') self.assertEqual(d['volumes'], ['c:\\Users\\me\\otherproject:/data']) - @clear_environment + @mock.patch.dict(os.environ) def test_home_directory_with_driver_does_not_expand(self): os.environ['NAME'] = 'surprise!' d = make_service_dict('foo', { @@ -2026,7 +2026,7 @@ class EnvTest(unittest.TestCase): def test_parse_environment_empty(self): self.assertEqual(config.parse_environment(None), {}) - @clear_environment + @mock.patch.dict(os.environ) def test_resolve_environment(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' @@ -2042,7 +2042,7 @@ class EnvTest(unittest.TestCase): }, } self.assertEqual( - resolve_environment(service_dict), + resolve_environment(service_dict, Environment(None)), {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, ) @@ -2073,13 +2073,15 @@ class EnvTest(unittest.TestCase): assert 'Couldn\'t find env file' in exc.exconly() assert 'nonexistent.env' in exc.exconly() - @clear_environment + @mock.patch.dict(os.environ) def test_resolve_environment_from_env_file_with_empty_values(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' self.assertEqual( - resolve_environment({'env_file': ['tests/fixtures/env/resolve.env']}), + resolve_environment( + {'env_file': ['tests/fixtures/env/resolve.env']}, Environment(None) + ), { 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', @@ -2088,7 +2090,7 @@ class EnvTest(unittest.TestCase): }, ) - @clear_environment + @mock.patch.dict(os.environ) def test_resolve_build_args(self): os.environ['env_arg'] = 'value2' @@ -2102,12 +2104,12 @@ class EnvTest(unittest.TestCase): } } self.assertEqual( - resolve_build_args(build), + resolve_build_args(build, Environment(build['context'])), {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') - @clear_environment + @mock.patch.dict(os.environ) def test_resolve_path(self): os.environ['HOSTENV'] = '/tmp' os.environ['CONTAINERENV'] = '/host/tmp' @@ -2394,7 +2396,7 @@ class ExtendsTest(unittest.TestCase): assert 'net: container' in excinfo.exconly() assert 'cannot be extended' in excinfo.exconly() - @clear_environment + @mock.patch.dict(os.environ) def test_load_config_runs_interpolation_in_extended_service(self): os.environ.update(HOSTNAME_VALUE="penguin") expected_interpolated_value = "host-penguin" @@ -2466,7 +2468,7 @@ class ExtendsTest(unittest.TestCase): }, ])) - @clear_environment + @mock.patch.dict(os.environ) def test_extends_with_environment_and_env_files(self): tmpdir = py.test.ensuretemp('test_extends_with_environment') self.addCleanup(tmpdir.remove) diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index b4ba7b40..f83caea9 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -13,7 +13,6 @@ from compose.config.interpolation import interpolate_environment_variables @pytest.yield_fixture def mock_env(): with mock.patch.dict(os.environ): - Environment.reset() os.environ['USER'] = 'jenny' os.environ['FOO'] = 'bar' yield @@ -44,7 +43,9 @@ def test_interpolate_environment_variables_in_services(mock_env): } } } - assert interpolate_environment_variables(services, 'service') == expected + assert interpolate_environment_variables( + services, 'service', Environment(None) + ) == expected def test_interpolate_environment_variables_in_volumes(mock_env): @@ -68,4 +69,6 @@ def test_interpolate_environment_variables_in_volumes(mock_env): }, 'other': {}, } - assert interpolate_environment_variables(volumes, 'volume') == expected + assert interpolate_environment_variables( + volumes, 'volume', Environment(None) + ) == expected From fd020ed2cfa2fb2b03a31a3fe87d6da47edfd713 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 7 Mar 2016 17:46:55 -0800 Subject: [PATCH 264/580] Tests use updated get_config_paths_from_options signature Signed-off-by: Joffrey F --- .pre-commit-config.yaml | 2 +- tests/unit/cli/command_test.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 462fcc4c..0e7b9d5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ - id: end-of-file-fixer - id: flake8 - id: name-tests-test - exclude: 'tests/integration/testcases\.py' + exclude: 'tests/(integration/testcases\.py|helpers\.py)' - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index b524a5f3..11fea16f 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -15,24 +15,24 @@ class TestGetConfigPathFromOptions(object): def test_path_from_options(self): paths = ['one.yml', 'two.yml'] opts = {'--file': paths} - assert get_config_path_from_options(opts) == paths + assert get_config_path_from_options('.', opts) == paths def test_single_path_from_env(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_FILE'] = 'one.yml' - assert get_config_path_from_options({}) == ['one.yml'] + assert get_config_path_from_options('.', {}) == ['one.yml'] @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator') def test_multiple_path_from_env(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_FILE'] = 'one.yml:two.yml' - assert get_config_path_from_options({}) == ['one.yml', 'two.yml'] + assert get_config_path_from_options('.', {}) == ['one.yml', 'two.yml'] @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator') def test_multiple_path_from_env_windows(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_FILE'] = 'one.yml;two.yml' - assert get_config_path_from_options({}) == ['one.yml', 'two.yml'] + assert get_config_path_from_options('.', {}) == ['one.yml', 'two.yml'] def test_no_path(self): - assert not get_config_path_from_options({}) + assert not get_config_path_from_options('.', {}) From 1801f83bb83f59fd508e5f9ad85b4c14d1f9d1d7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 8 Mar 2016 16:54:14 -0800 Subject: [PATCH 265/580] Environment class cleanup Signed-off-by: Joffrey F --- compose/cli/command.py | 6 +-- compose/config/config.py | 35 ++----------- compose/config/environment.py | 69 +++++++++++++++---------- tests/integration/testcases.py | 2 +- tests/unit/config/config_test.py | 6 +-- tests/unit/config/interpolation_test.py | 4 +- tests/unit/interpolation_test.py | 2 +- 7 files changed, 58 insertions(+), 66 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 8c0bf07f..07293f74 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -34,7 +34,7 @@ def get_config_path_from_options(base_dir, options): if file_option: return file_option - environment = config.environment.Environment(base_dir) + environment = config.environment.Environment.from_env_file(base_dir) config_files = environment.get('COMPOSE_FILE') if config_files: return config_files.split(os.pathsep) @@ -58,7 +58,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) - environment = config.environment.Environment(project_dir) + environment = config.environment.Environment.from_env_file(project_dir) api_version = environment.get( 'COMPOSE_API_VERSION', @@ -75,7 +75,7 @@ def get_project_name(working_dir, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - environment = config.environment.Environment(working_dir) + environment = config.environment.Environment.from_env_file(working_dir) project_name = project_name or environment.get('COMPOSE_PROJECT_NAME') if project_name: return normalize_name(project_name) diff --git a/compose/config/config.py b/compose/config/config.py index 7db66004..a50efdf8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from __future__ import unicode_literals -import codecs import functools import logging import operator @@ -17,7 +16,9 @@ from cached_property import cached_property from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V2_0 as V2_0 from ..utils import build_string_dict +from .environment import env_vars_from_file from .environment import Environment +from .environment import split_env from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError @@ -129,7 +130,7 @@ class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files envir cls, working_dir, config_files, - Environment(working_dir), + Environment.from_env_file(working_dir), ) @@ -314,9 +315,7 @@ def load(config_details): networks = load_mapping( config_details.config_files, 'get_networks', 'Network' ) - service_dicts = load_services( - config_details, main_file, - ) + service_dicts = load_services(config_details, main_file) if main_file.version != V1: for service_dict in service_dicts: @@ -455,7 +454,7 @@ class ServiceExtendsResolver(object): self.working_dir = service_config.working_dir self.already_seen = already_seen or [] self.config_file = config_file - self.environment = environment or Environment(None) + self.environment = environment or Environment() @property def signature(self): @@ -802,15 +801,6 @@ def merge_environment(base, override): return env -def split_env(env): - if isinstance(env, six.binary_type): - env = env.decode('utf-8', 'replace') - if '=' in env: - return env.split('=', 1) - else: - return env, None - - def split_label(label): if '=' in label: return label.split('=', 1) @@ -857,21 +847,6 @@ def resolve_env_var(key, val, environment): return key, None -def env_vars_from_file(filename): - """ - Read in a line delimited file of environment variables. - """ - if not os.path.exists(filename): - raise ConfigurationError("Couldn't find env file: %s" % filename) - env = {} - for line in codecs.open(filename, 'r', 'utf-8'): - line = line.strip() - if line and not line.startswith('#'): - k, v = split_env(line) - env[k] = v - return env - - def resolve_volume_paths(working_dir, service_dict): return [ resolve_volume_path(working_dir, volume) diff --git a/compose/config/environment.py b/compose/config/environment.py index 87b41223..8066c50f 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -1,22 +1,62 @@ from __future__ import absolute_import from __future__ import unicode_literals +import codecs import logging import os +import six + from .errors import ConfigurationError log = logging.getLogger(__name__) -class BlankDefaultDict(dict): +def split_env(env): + if isinstance(env, six.binary_type): + env = env.decode('utf-8', 'replace') + if '=' in env: + return env.split('=', 1) + else: + return env, None + + +def env_vars_from_file(filename): + """ + Read in a line delimited file of environment variables. + """ + if not os.path.exists(filename): + raise ConfigurationError("Couldn't find env file: %s" % filename) + env = {} + for line in codecs.open(filename, 'r', 'utf-8'): + line = line.strip() + if line and not line.startswith('#'): + k, v = split_env(line) + env[k] = v + return env + + +class Environment(dict): def __init__(self, *args, **kwargs): - super(BlankDefaultDict, self).__init__(*args, **kwargs) + super(Environment, self).__init__(*args, **kwargs) self.missing_keys = [] + self.update(os.environ) + + @classmethod + def from_env_file(cls, base_dir): + result = cls() + if base_dir is None: + return result + env_file_path = os.path.join(base_dir, '.env') + try: + result.update(env_vars_from_file(env_file_path)) + except ConfigurationError: + pass + return result def __getitem__(self, key): try: - return super(BlankDefaultDict, self).__getitem__(key) + return super(Environment, self).__getitem__(key) except KeyError: if key not in self.missing_keys: log.warn( @@ -26,26 +66,3 @@ class BlankDefaultDict(dict): self.missing_keys.append(key) return "" - - -class Environment(BlankDefaultDict): - def __init__(self, base_dir): - super(Environment, self).__init__() - if base_dir: - self.load_environment_file(os.path.join(base_dir, '.env')) - self.update(os.environ) - - def load_environment_file(self, path): - if not os.path.exists(path): - return - mapping = {} - with open(path, 'r') as f: - for line in f.readlines(): - line = line.strip() - if '=' not in line: - raise ConfigurationError( - 'Invalid environment variable mapping in env file. ' - 'Missing "=" in "{0}"'.format(line) - ) - mapping.__setitem__(*line.split('=', 1)) - self.update(mapping) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index aaa002f3..98e0540f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -90,7 +90,7 @@ class DockerClientTestCase(unittest.TestCase): if 'command' not in kwargs: kwargs['command'] = ["top"] - kwargs['environment'] = resolve_environment(kwargs, Environment(None)) + kwargs['environment'] = resolve_environment(kwargs, Environment()) labels = dict(kwargs.setdefault('labels', {})) labels['com.docker.compose.test-name'] = self.id() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6dc7dbca..daf724a8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2042,7 +2042,7 @@ class EnvTest(unittest.TestCase): }, } self.assertEqual( - resolve_environment(service_dict, Environment(None)), + resolve_environment(service_dict, Environment()), {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, ) @@ -2080,7 +2080,7 @@ class EnvTest(unittest.TestCase): os.environ['ENV_DEF'] = 'E3' self.assertEqual( resolve_environment( - {'env_file': ['tests/fixtures/env/resolve.env']}, Environment(None) + {'env_file': ['tests/fixtures/env/resolve.env']}, Environment() ), { 'FILE_DEF': u'bär', @@ -2104,7 +2104,7 @@ class EnvTest(unittest.TestCase): } } self.assertEqual( - resolve_build_args(build, Environment(build['context'])), + resolve_build_args(build, Environment.from_env_file(build['context'])), {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, ) diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index f83caea9..282ba779 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -44,7 +44,7 @@ def test_interpolate_environment_variables_in_services(mock_env): } } assert interpolate_environment_variables( - services, 'service', Environment(None) + services, 'service', Environment() ) == expected @@ -70,5 +70,5 @@ def test_interpolate_environment_variables_in_volumes(mock_env): 'other': {}, } assert interpolate_environment_variables( - volumes, 'volume', Environment(None) + volumes, 'volume', Environment() ) == expected diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py index b19fcdac..c3050c2c 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest -from compose.config.environment import BlankDefaultDict as bddict +from compose.config.environment import Environment as bddict from compose.config.interpolation import interpolate from compose.config.interpolation import InvalidInterpolation From d55fc85feadf51e753c06b34f7cc200305915a54 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 8 Mar 2016 16:55:05 -0800 Subject: [PATCH 266/580] Added default env file test. Signed-off-by: Joffrey F --- tests/fixtures/default-env-file/.env | 4 ++++ tests/fixtures/default-env-file/docker-compose.yml | 6 ++++++ tests/unit/config/config_test.py | 13 +++++++++++++ 3 files changed, 23 insertions(+) create mode 100644 tests/fixtures/default-env-file/.env create mode 100644 tests/fixtures/default-env-file/docker-compose.yml diff --git a/tests/fixtures/default-env-file/.env b/tests/fixtures/default-env-file/.env new file mode 100644 index 00000000..996c886c --- /dev/null +++ b/tests/fixtures/default-env-file/.env @@ -0,0 +1,4 @@ +IMAGE=alpine:latest +COMMAND=true +PORT1=5643 +PORT2=9999 \ No newline at end of file diff --git a/tests/fixtures/default-env-file/docker-compose.yml b/tests/fixtures/default-env-file/docker-compose.yml new file mode 100644 index 00000000..aa8e4409 --- /dev/null +++ b/tests/fixtures/default-env-file/docker-compose.yml @@ -0,0 +1,6 @@ +web: + image: ${IMAGE} + command: ${COMMAND} + ports: + - $PORT1 + - $PORT2 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index daf724a8..913cbed9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1582,6 +1582,19 @@ class PortsTest(unittest.TestCase): class InterpolationTest(unittest.TestCase): + @mock.patch.dict(os.environ) + def test_config_file_with_environment_file(self): + service_dicts = config.load( + config.find('tests/fixtures/default-env-file', None) + ).services + + self.assertEqual(service_dicts[0], { + 'name': 'web', + 'image': 'alpine:latest', + 'ports': ['5643', '9999'], + 'command': 'true' + }) + @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): os.environ.update( From f48da96e8b5a732399d5ab18e32c53156934e694 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 8 Mar 2016 17:18:04 -0800 Subject: [PATCH 267/580] Test get_project_name from env file Signed-off-by: Joffrey F --- compose/config/environment.py | 2 +- tests/unit/cli_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 8066c50f..17eacf1f 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -49,7 +49,7 @@ class Environment(dict): return result env_file_path = os.path.join(base_dir, '.env') try: - result.update(env_vars_from_file(env_file_path)) + return cls(env_vars_from_file(env_file_path)) except ConfigurationError: pass return result diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index d8e0b33f..bd35dc06 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -3,6 +3,8 @@ from __future__ import absolute_import from __future__ import unicode_literals import os +import shutil +import tempfile import docker import py @@ -57,6 +59,22 @@ class CLITestCase(unittest.TestCase): project_name = get_project_name(base_dir) self.assertEquals('simplecomposefile', project_name) + @mock.patch.dict(os.environ) + def test_project_name_with_environment_file(self): + base_dir = tempfile.mkdtemp() + try: + name = 'namefromenvfile' + with open(os.path.join(base_dir, '.env'), 'w') as f: + f.write('COMPOSE_PROJECT_NAME={}'.format(name)) + project_name = get_project_name(base_dir) + assert project_name == name + + # Environment has priority over .env file + os.environ['COMPOSE_PROJECT_NAME'] = 'namefromenv' + assert get_project_name(base_dir) == os.environ['COMPOSE_PROJECT_NAME'] + finally: + shutil.rmtree(base_dir) + def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir) From 21aa7a0448703e3e59bc39300a82f68ded659f45 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 8 Mar 2016 17:48:23 -0800 Subject: [PATCH 268/580] Documentation for .env file Signed-off-by: Joffrey F --- docs/env-file.md | 38 ++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 2 files changed, 39 insertions(+) create mode 100644 docs/env-file.md diff --git a/docs/env-file.md b/docs/env-file.md new file mode 100644 index 00000000..3dec592f --- /dev/null +++ b/docs/env-file.md @@ -0,0 +1,38 @@ + + + +# Environment file + +Compose supports declaring default environment variables in an environment +file named `.env` and placed in the same folder as your +[compose file](compose-file.md). + +Compose expects each line in an env file to be in `VAR=VAL` format. Lines +beginning with `#` (i.e. comments) are ignored, as are blank lines. + +> Note: Values present in the environment at runtime will always override +> those defined inside the `.env` file. + +Those environment variables will be used for +[variable substitution](compose-file.md#variable-substitution) in your Compose +file, but can also be used to define the following +[CLI variables](reference/envvars.md): + +- `COMPOSE_PROJECT_NAME` +- `COMPOSE_FILE` +- `COMPOSE_API_VERSION` + +## More Compose documentation + +- [User guide](index.md) +- [Command line reference](./reference/index.md) +- [Compose file reference](compose-file.md) diff --git a/docs/index.md b/docs/index.md index f5d84218..f1b71079 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ Compose is a tool for defining and running multi-container Docker applications. - [Frequently asked questions](faq.md) - [Command line reference](./reference/index.md) - [Compose file reference](compose-file.md) +- [Environment file](env-file.md) To see a detailed list of changes for past and current releases of Docker Compose, please refer to the From dcdcf4869b6df77e16e243ace9e49c136d336b78 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Mar 2016 17:29:01 -0800 Subject: [PATCH 269/580] Mention environment file in envvars.md Signed-off-by: Joffrey F --- docs/reference/envvars.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/envvars.md b/docs/reference/envvars.md index e1170be9..6f7fb791 100644 --- a/docs/reference/envvars.md +++ b/docs/reference/envvars.md @@ -17,6 +17,9 @@ Several environment variables are available for you to configure the Docker Comp Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using `docker-machine`, then the `eval "$(docker-machine env my-docker-vm)"` command should set them to their correct values. (In this example, `my-docker-vm` is the name of a machine you created.) +> Note: Some of these variables can also be provided using an +> [environment file](../env-file.md) + ## COMPOSE\_PROJECT\_NAME Sets the project name. This value is prepended along with the service name to the container container on start up. For example, if you project name is `myapp` and it includes two services `db` and `web` then compose starts containers named `myapp_db_1` and `myapp_web_1` respectively. @@ -81,3 +84,4 @@ it failed. Defaults to 60 seconds. - [User guide](../index.md) - [Installing Compose](../install.md) - [Compose file reference](../compose-file.md) +- [Environment file](../env-file.md) From 0ff53d9668670efe99736e045d4476bccf9435ca Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 14 Mar 2016 13:02:55 -0700 Subject: [PATCH 270/580] Less verbose environment invocation Signed-off-by: Joffrey F --- compose/cli/command.py | 7 ++++--- docs/env-file.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 07293f74..d726c7b3 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -9,6 +9,7 @@ import six from . import verbose_proxy from .. import config +from ..config.environment import Environment from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client @@ -34,7 +35,7 @@ def get_config_path_from_options(base_dir, options): if file_option: return file_option - environment = config.environment.Environment.from_env_file(base_dir) + environment = Environment.from_env_file(base_dir) config_files = environment.get('COMPOSE_FILE') if config_files: return config_files.split(os.pathsep) @@ -58,7 +59,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) - environment = config.environment.Environment.from_env_file(project_dir) + environment = Environment.from_env_file(project_dir) api_version = environment.get( 'COMPOSE_API_VERSION', @@ -75,7 +76,7 @@ def get_project_name(working_dir, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - environment = config.environment.Environment.from_env_file(working_dir) + environment = Environment.from_env_file(working_dir) project_name = project_name or environment.get('COMPOSE_PROJECT_NAME') if project_name: return normalize_name(project_name) diff --git a/docs/env-file.md b/docs/env-file.md index 3dec592f..6d12d228 100644 --- a/docs/env-file.md +++ b/docs/env-file.md @@ -20,7 +20,8 @@ Compose expects each line in an env file to be in `VAR=VAL` format. Lines beginning with `#` (i.e. comments) are ignored, as are blank lines. > Note: Values present in the environment at runtime will always override -> those defined inside the `.env` file. +> those defined inside the `.env` file. Similarly, values passed via +> command-line arguments take precedence as well. Those environment variables will be used for [variable substitution](compose-file.md#variable-substitution) in your Compose From 36f1b4589cd0dfd343c0b597abe56824d95cea09 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Mar 2016 16:08:07 -0700 Subject: [PATCH 271/580] Limit occurrences of creating an environment object. .env file is always read from the project_dir Signed-off-by: Joffrey F --- compose/cli/command.py | 23 ++++++++++++++--------- compose/cli/main.py | 10 ++++++++-- compose/config/config.py | 14 +++++++------- tests/helpers.py | 3 ++- tests/unit/cli/command_test.py | 20 +++++++++++++++----- tests/unit/config/config_test.py | 14 +++++++++++--- 6 files changed, 57 insertions(+), 27 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index d726c7b3..73eccc96 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -20,22 +20,23 @@ log = logging.getLogger(__name__) def project_from_options(project_dir, options): + environment = Environment.from_env_file(project_dir) return get_project( project_dir, - get_config_path_from_options(project_dir, options), + get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), host=options.get('--host'), tls_config=tls_config_from_options(options), + environment=environment ) -def get_config_path_from_options(base_dir, options): +def get_config_path_from_options(base_dir, options, environment): file_option = options.get('--file') if file_option: return file_option - environment = Environment.from_env_file(base_dir) config_files = environment.get('COMPOSE_FILE') if config_files: return config_files.split(os.pathsep) @@ -55,11 +56,14 @@ def get_client(verbose=False, version=None, tls_config=None, host=None): def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None): - config_details = config.find(project_dir, config_path) - project_name = get_project_name(config_details.working_dir, project_name) + host=None, tls_config=None, environment=None): + if not environment: + environment = Environment.from_env_file(project_dir) + config_details = config.find(project_dir, config_path, environment) + project_name = get_project_name( + config_details.working_dir, project_name, environment + ) config_data = config.load(config_details) - environment = Environment.from_env_file(project_dir) api_version = environment.get( 'COMPOSE_API_VERSION', @@ -72,11 +76,12 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, return Project.from_config(project_name, config_data, client) -def get_project_name(working_dir, project_name=None): +def get_project_name(working_dir, project_name=None, environment=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - environment = Environment.from_env_file(working_dir) + if not environment: + environment = Environment.from_env_file(working_dir) project_name = project_name or environment.get('COMPOSE_PROJECT_NAME') if project_name: return normalize_name(project_name) diff --git a/compose/cli/main.py b/compose/cli/main.py index 3fa3e3a0..8348b8c3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -17,6 +17,7 @@ from .. import __version__ from ..config import config from ..config import ConfigurationError from ..config import parse_environment +from ..config.environment import Environment from ..config.serialize import serialize_config from ..const import DEFAULT_TIMEOUT from ..const import IS_WINDOWS_PLATFORM @@ -222,8 +223,13 @@ class TopLevelCommand(object): --services Print the service names, one per line. """ - config_path = get_config_path_from_options(self.project_dir, config_options) - compose_config = config.load(config.find(self.project_dir, config_path)) + environment = Environment.from_env_file(self.project_dir) + config_path = get_config_path_from_options( + self.project_dir, config_options, environment + ) + compose_config = config.load( + config.find(self.project_dir, config_path, environment) + ) if options['--quiet']: return diff --git a/compose/config/config.py b/compose/config/config.py index a50efdf8..47cb2331 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -124,13 +124,11 @@ class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files envir :param environment: computed environment values for this project :type environment: :class:`environment.Environment` """ - - def __new__(cls, working_dir, config_files): + def __new__(cls, working_dir, config_files, environment=None): + if environment is None: + environment = Environment.from_env_file(working_dir) return super(ConfigDetails, cls).__new__( - cls, - working_dir, - config_files, - Environment.from_env_file(working_dir), + cls, working_dir, config_files, environment ) @@ -219,11 +217,12 @@ class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name conf config) -def find(base_dir, filenames): +def find(base_dir, filenames, environment): if filenames == ['-']: return ConfigDetails( os.getcwd(), [ConfigFile(None, yaml.safe_load(sys.stdin))], + environment ) if filenames: @@ -235,6 +234,7 @@ def find(base_dir, filenames): return ConfigDetails( os.path.dirname(filenames[0]), [ConfigFile.from_filename(f) for f in filenames], + environment ) diff --git a/tests/helpers.py b/tests/helpers.py index dd0b668e..4b422a6a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -13,4 +13,5 @@ def build_config(contents, **kwargs): def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): return ConfigDetails( working_dir, - [ConfigFile(filename, contents)]) + [ConfigFile(filename, contents)], + ) diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 11fea16f..3502d636 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -6,6 +6,7 @@ import os import pytest from compose.cli.command import get_config_path_from_options +from compose.config.environment import Environment from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -15,24 +16,33 @@ class TestGetConfigPathFromOptions(object): def test_path_from_options(self): paths = ['one.yml', 'two.yml'] opts = {'--file': paths} - assert get_config_path_from_options('.', opts) == paths + environment = Environment.from_env_file('.') + assert get_config_path_from_options('.', opts, environment) == paths def test_single_path_from_env(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_FILE'] = 'one.yml' - assert get_config_path_from_options('.', {}) == ['one.yml'] + environment = Environment.from_env_file('.') + assert get_config_path_from_options('.', {}, environment) == ['one.yml'] @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator') def test_multiple_path_from_env(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_FILE'] = 'one.yml:two.yml' - assert get_config_path_from_options('.', {}) == ['one.yml', 'two.yml'] + environment = Environment.from_env_file('.') + assert get_config_path_from_options( + '.', {}, environment + ) == ['one.yml', 'two.yml'] @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator') def test_multiple_path_from_env_windows(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_FILE'] = 'one.yml;two.yml' - assert get_config_path_from_options('.', {}) == ['one.yml', 'two.yml'] + environment = Environment.from_env_file('.') + assert get_config_path_from_options( + '.', {}, environment + ) == ['one.yml', 'two.yml'] def test_no_path(self): - assert not get_config_path_from_options('.', {}) + environment = Environment.from_env_file('.') + assert not get_config_path_from_options('.', {}, environment) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 913cbed9..11152871 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1584,8 +1584,11 @@ class PortsTest(unittest.TestCase): class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) def test_config_file_with_environment_file(self): + project_dir = 'tests/fixtures/default-env-file' service_dicts = config.load( - config.find('tests/fixtures/default-env-file', None) + config.find( + project_dir, None, Environment.from_env_file(project_dir) + ) ).services self.assertEqual(service_dicts[0], { @@ -1597,6 +1600,7 @@ class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): + project_dir = 'tests/fixtures/environment-interpolation' os.environ.update( IMAGE="busybox", HOST_PORT="80", @@ -1604,7 +1608,9 @@ class InterpolationTest(unittest.TestCase): ) service_dicts = config.load( - config.find('tests/fixtures/environment-interpolation', None), + config.find( + project_dir, None, Environment.from_env_file(project_dir) + ) ).services self.assertEqual(service_dicts, [ @@ -2149,7 +2155,9 @@ class EnvTest(unittest.TestCase): def load_from_filename(filename): - return config.load(config.find('.', [filename])).services + return config.load( + config.find('.', [filename], Environment.from_env_file('.')) + ).services class ExtendsTest(unittest.TestCase): From c7afe16419945d74f956fa065f7b9f79712ed626 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Mar 2016 16:33:58 -0700 Subject: [PATCH 272/580] Account for case-insensitive env on windows platform Signed-off-by: Joffrey F --- compose/config/environment.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/config/environment.py b/compose/config/environment.py index 17eacf1f..7f7269b7 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -7,6 +7,7 @@ import os import six +from ..const import IS_WINDOWS_PLATFORM from .errors import ConfigurationError log = logging.getLogger(__name__) @@ -58,6 +59,11 @@ class Environment(dict): try: return super(Environment, self).__getitem__(key) except KeyError: + if IS_WINDOWS_PLATFORM: + try: + return super(Environment, self).__getitem__(key.upper()) + except KeyError: + pass if key not in self.missing_keys: log.warn( "The {} variable is not set. Defaulting to a blank string." From b99037b4a61e10c9377dd707e35860cec298a268 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Mar 2016 18:32:13 -0700 Subject: [PATCH 273/580] Add support for DOCKER_* variables in .env file Signed-off-by: Joffrey F --- compose/cli/command.py | 9 ++++++--- compose/cli/docker_client.py | 13 ++++++++----- compose/const.py | 2 +- docs/env-file.md | 8 ++++++-- tests/integration/testcases.py | 2 +- tests/unit/cli/docker_client_test.py | 4 ++-- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 73eccc96..b7160dee 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -43,8 +43,11 @@ def get_config_path_from_options(base_dir, options, environment): return None -def get_client(verbose=False, version=None, tls_config=None, host=None): - client = docker_client(version=version, tls_config=tls_config, host=host) +def get_client(environment, verbose=False, version=None, tls_config=None, host=None): + client = docker_client( + version=version, tls_config=tls_config, host=host, + environment=environment + ) if verbose: version_info = six.iteritems(client.version()) log.info(get_version_info('full')) @@ -70,7 +73,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, API_VERSIONS[config_data.version]) client = get_client( verbose=verbose, version=api_version, tls_config=tls_config, - host=host + host=host, environment=environment ) return Project.from_config(project_name, config_data, client) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index deb56866..f782a1ae 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -2,7 +2,6 @@ from __future__ import absolute_import from __future__ import unicode_literals import logging -import os from docker import Client from docker.errors import TLSParameterError @@ -42,17 +41,17 @@ def tls_config_from_options(options): return None -def docker_client(version=None, tls_config=None, host=None): +def docker_client(environment, version=None, tls_config=None, host=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. """ - if 'DOCKER_CLIENT_TIMEOUT' in os.environ: + if 'DOCKER_CLIENT_TIMEOUT' in environment: log.warn("The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. " "Please use COMPOSE_HTTP_TIMEOUT instead.") try: - kwargs = kwargs_from_env(assert_hostname=False) + kwargs = kwargs_from_env(assert_hostname=False, environment=environment) except TLSParameterError: raise UserError( "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY " @@ -67,6 +66,10 @@ def docker_client(version=None, tls_config=None, host=None): if version: kwargs['version'] = version - kwargs['timeout'] = HTTP_TIMEOUT + timeout = environment.get('COMPOSE_HTTP_TIMEOUT') + if timeout: + kwargs['timeout'] = int(timeout) + else: + kwargs['timeout'] = HTTP_TIMEOUT return Client(**kwargs) diff --git a/compose/const.py b/compose/const.py index db5e2fb4..9e00d96e 100644 --- a/compose/const.py +++ b/compose/const.py @@ -5,7 +5,7 @@ import os import sys DEFAULT_TIMEOUT = 10 -HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) +HTTP_TIMEOUT = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', 'tag', 'untag'] IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' diff --git a/docs/env-file.md b/docs/env-file.md index 6d12d228..a285a790 100644 --- a/docs/env-file.md +++ b/docs/env-file.md @@ -28,9 +28,13 @@ Those environment variables will be used for file, but can also be used to define the following [CLI variables](reference/envvars.md): -- `COMPOSE_PROJECT_NAME` -- `COMPOSE_FILE` - `COMPOSE_API_VERSION` +- `COMPOSE_FILE` +- `COMPOSE_HTTP_TIMEOUT` +- `COMPOSE_PROJECT_NAME` +- `DOCKER_CERT_PATH` +- `DOCKER_HOST` +- `DOCKER_TLS_VERIFY` ## More Compose documentation diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 98e0540f..e8b2f35d 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -61,7 +61,7 @@ class DockerClientTestCase(unittest.TestCase): else: version = API_VERSIONS[V2_0] - cls.client = docker_client(version) + cls.client = docker_client(Environment(), version) def tearDown(self): for c in self.client.containers( diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index b55f1d17..56bab19c 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -17,12 +17,12 @@ class DockerClientTestCase(unittest.TestCase): def test_docker_client_no_home(self): with mock.patch.dict(os.environ): del os.environ['HOME'] - docker_client() + docker_client(os.environ) def test_docker_client_with_custom_timeout(self): timeout = 300 with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300): - client = docker_client() + client = docker_client(os.environ) self.assertEqual(client.timeout, int(timeout)) From 1506f997def80fdfc4cf6e0377a1cabeaad35d43 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 22 Mar 2016 11:43:03 -0700 Subject: [PATCH 274/580] Better windows support for Environment class Signed-off-by: Joffrey F --- compose/config/environment.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/compose/config/environment.py b/compose/config/environment.py index 7f7269b7..8d2f5e36 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -72,3 +72,19 @@ class Environment(dict): self.missing_keys.append(key) return "" + + def __contains__(self, key): + result = super(Environment, self).__contains__(key) + if IS_WINDOWS_PLATFORM: + return ( + result or super(Environment, self).__contains__(key.upper()) + ) + return result + + def get(self, key, *args, **kwargs): + if IS_WINDOWS_PLATFORM: + return super(Environment, self).get( + key, + super(Environment, self).get(key.upper(), *args, **kwargs) + ) + return super(Environment, self).get(key, *args, **kwargs) From 12ad3ff30194e8b4cd5d5e8874fffba297f09dc4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 22 Mar 2016 15:42:30 -0700 Subject: [PATCH 275/580] Injecting os.environ in Environment instance happens outside of init method Signed-off-by: Joffrey F --- compose/config/config.py | 4 ++-- compose/config/environment.py | 21 ++++++++++++--------- tests/integration/testcases.py | 4 +++- tests/unit/config/config_test.py | 11 ++++++++--- tests/unit/config/interpolation_test.py | 8 ++++---- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 47cb2331..dc3f56ea 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -449,12 +449,12 @@ def process_config_file(config_file, environment, service_name=None): class ServiceExtendsResolver(object): - def __init__(self, service_config, config_file, environment=None, already_seen=None): + def __init__(self, service_config, config_file, environment, already_seen=None): self.service_config = service_config self.working_dir = service_config.working_dir self.already_seen = already_seen or [] self.config_file = config_file - self.environment = environment or Environment() + self.environment = environment @property def signature(self): diff --git a/compose/config/environment.py b/compose/config/environment.py index 8d2f5e36..ad5c0b3d 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -41,19 +41,22 @@ class Environment(dict): def __init__(self, *args, **kwargs): super(Environment, self).__init__(*args, **kwargs) self.missing_keys = [] - self.update(os.environ) @classmethod def from_env_file(cls, base_dir): - result = cls() - if base_dir is None: + def _initialize(): + result = cls() + if base_dir is None: + return result + env_file_path = os.path.join(base_dir, '.env') + try: + return cls(env_vars_from_file(env_file_path)) + except ConfigurationError: + pass return result - env_file_path = os.path.join(base_dir, '.env') - try: - return cls(env_vars_from_file(env_file_path)) - except ConfigurationError: - pass - return result + instance = _initialize() + instance.update(os.environ) + return instance def __getitem__(self, key): try: diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index e8b2f35d..8d69d531 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -90,7 +90,9 @@ class DockerClientTestCase(unittest.TestCase): if 'command' not in kwargs: kwargs['command'] = ["top"] - kwargs['environment'] = resolve_environment(kwargs, Environment()) + kwargs['environment'] = resolve_environment( + kwargs, Environment.from_env_file(None) + ) labels = dict(kwargs.setdefault('labels', {})) labels['com.docker.compose.test-name'] = self.id() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 11152871..2bbbe614 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -37,7 +37,9 @@ def make_service_dict(name, service_dict, working_dir, filename=None): filename=filename, name=name, config=service_dict), - config.ConfigFile(filename=filename, config={})) + config.ConfigFile(filename=filename, config={}), + environment=Environment.from_env_file(working_dir) + ) return config.process_service(resolver.run()) @@ -2061,7 +2063,9 @@ class EnvTest(unittest.TestCase): }, } self.assertEqual( - resolve_environment(service_dict, Environment()), + resolve_environment( + service_dict, Environment.from_env_file(None) + ), {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, ) @@ -2099,7 +2103,8 @@ class EnvTest(unittest.TestCase): os.environ['ENV_DEF'] = 'E3' self.assertEqual( resolve_environment( - {'env_file': ['tests/fixtures/env/resolve.env']}, Environment() + {'env_file': ['tests/fixtures/env/resolve.env']}, + Environment.from_env_file(None) ), { 'FILE_DEF': u'bär', diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 282ba779..42b5db6e 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -20,7 +20,7 @@ def mock_env(): def test_interpolate_environment_variables_in_services(mock_env): services = { - 'servivea': { + 'servicea': { 'image': 'example:${USER}', 'volumes': ['$FOO:/target'], 'logging': { @@ -32,7 +32,7 @@ def test_interpolate_environment_variables_in_services(mock_env): } } expected = { - 'servivea': { + 'servicea': { 'image': 'example:jenny', 'volumes': ['bar:/target'], 'logging': { @@ -44,7 +44,7 @@ def test_interpolate_environment_variables_in_services(mock_env): } } assert interpolate_environment_variables( - services, 'service', Environment() + services, 'service', Environment.from_env_file(None) ) == expected @@ -70,5 +70,5 @@ def test_interpolate_environment_variables_in_volumes(mock_env): 'other': {}, } assert interpolate_environment_variables( - volumes, 'volume', Environment() + volumes, 'volume', Environment.from_env_file(None) ) == expected From 0f1fb42326cb000efe6f06f7c1974430c474afe0 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 18:52:28 +0100 Subject: [PATCH 276/580] Add zsh completion for 'docker-compose rm -a --all' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 1 + 1 file changed, 1 insertion(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f67bc9f6..64e79428 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -266,6 +266,7 @@ __docker-compose_subcommand() { (rm) _arguments \ $opts_help \ + '(-a --all)'{-a,--all}"[Also remove one-off containers]" \ '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ '-v[Remove volumes associated with containers]' \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 From 63b448120a960194d3d0f23f751ec5e5534e397e Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:03:36 +0100 Subject: [PATCH 277/580] Add zsh completion for 'docker-compose exec' command Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f67bc9f6..a40f1010 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -223,6 +223,18 @@ __docker-compose_subcommand() { '--json[Output events as a stream of json objects.]' \ '*:services:__docker-compose_services_all' && ret=0 ;; + (exec) + _arguments \ + $opts_help \ + '-d[Detached mode: Run command in the background.]' \ + '--privileged[Give extended privileges to the process.]' \ + '--user=[Run the command as this user.]:username:_users' \ + '-T[Disable pseudo-tty allocation. By default `docker-compose exec` allocates a TTY.]' \ + '--index=[Index of the container if there are multiple instances of a service (default: 1)]:index: ' \ + '(-):running services:__docker-compose_runningservices' \ + '(-):command: _command_names -e' \ + '*::arguments: _normal' && ret=0 + ;; (help) _arguments ':subcommand:__docker-compose_commands' && ret=0 ;; From 9d58b19ecc21c56c5b6763361265fe66c2652601 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:09:53 +0100 Subject: [PATCH 278/580] Add zsh completion for 'docker-compose logs -f --follow --tail -t --timestamps' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f67bc9f6..ecd8db93 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -235,7 +235,10 @@ __docker-compose_subcommand() { (logs) _arguments \ $opts_help \ + '(-f --follow)'{-f,--follow}'[Follow log output]' \ '--no-color[Produce monochrome output.]' \ + '--tail=[Number of lines to show from the end of the logs for each container.]:number of lines: ' \ + '(-t --timestamps)'{-t,--timestamps}'[Show timestamps]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (pause) From 9729c0d3c72f0c16932efd9dd2574d08f3d5a3a7 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:15:34 +0100 Subject: [PATCH 279/580] Add zsh completion for 'docker-compose up --build' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 1 + 1 file changed, 1 insertion(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f67bc9f6..d837e61e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -313,6 +313,7 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names.]' \ + '--build[Build images before starting containers.]' \ '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ "--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ From 8ae8f7ed4befe40578eddb005907f49943a063cb Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:25:33 +0100 Subject: [PATCH 280/580] Add zsh completion for 'docker-compose run -w --workdir' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f67bc9f6..3e3f24d0 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -274,15 +274,16 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '-d[Detached mode: Run container in the background, print new container name.]' \ - '--name[Assign a name to the container]:name: ' \ - '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \ '*-e[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \ - '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ + '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \ + '--name[Assign a name to the container]:name: ' \ "--no-deps[Don't start linked services.]" \ + '(-p --publish)'{-p,--publish=-}"[Run command with manually mapped container's port(s) to the host.]" \ '--rm[Remove container after run. Ignored in detached mode.]' \ "--service-ports[Run command with the service's ports enabled and mapped to the host.]" \ - '(-p --publish)'{-p,--publish=-}"[Run command with manually mapped container's port(s) to the host.]" \ '-T[Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY.]' \ + '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ + '(-w --workdir)'{-w=,--workdir=}'[Working directory inside the container]:workdir: ' \ '(-):services:__docker-compose_services' \ '(-):command: _command_names -e' \ '*::arguments: _normal' && ret=0 From 93901ec4805b0a72ba71ae910d3214e4856cd876 Mon Sep 17 00:00:00 2001 From: Jon Lemmon Date: Mon, 28 Mar 2016 13:29:01 +1300 Subject: [PATCH 281/580] Rails Docs: Add nodejs to apt-get install command When using the latest version of Rails, the tutorial currently errors when running `docker-compose up` with the following error: ``` /usr/local/lib/ruby/gems/2.3.0/gems/bundler-1.11.2/lib/bundler/runtime.rb:80:in `rescue in block (2 levels) in require': There was an error while trying to load the gem 'uglifier'. (Bundler::GemRequireError) ``` Installing nodejs in the build fixes the issue. Signed-off-by: Jon Lemmon --- docs/rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rails.md b/docs/rails.md index a8fc383e..eef6b2f4 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -22,7 +22,7 @@ container. This is done using a file called `Dockerfile`. To begin with, the Dockerfile consists of: FROM ruby:2.2.0 - RUN apt-get update -qq && apt-get install -y build-essential libpq-dev + RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs RUN mkdir /myapp WORKDIR /myapp ADD Gemfile /myapp/Gemfile From 7116aefe4310c77a6d8f80a9f928ce6437e8bb49 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Mar 2016 17:39:20 -0700 Subject: [PATCH 282/580] Fix assert_hostname logic in tls_config_from_options Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 27 ++++++++++++++++++++------- tests/unit/cli/docker_client_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index f782a1ae..83cd8626 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -21,24 +21,37 @@ def tls_config_from_options(options): cert = options.get('--tlscert') key = options.get('--tlskey') verify = options.get('--tlsverify') - hostname = urlparse(options.get('--host') or '').hostname + host = options.get('--host') + skip_hostname_check = options.get('--skip-hostname-check', False) + + if not skip_hostname_check: + hostname = urlparse(host).hostname if host else None + # If the protocol is omitted, urlparse fails to extract the hostname. + # Make another attempt by appending a protocol. + if not hostname and host: + hostname = urlparse('tcp://{0}'.format(host)).hostname advanced_opts = any([ca_cert, cert, key, verify]) if tls is True and not advanced_opts: return True - elif advanced_opts: + elif advanced_opts: # --tls is a noop client_cert = None if cert or key: client_cert = (cert, key) + + assert_hostname = None + if skip_hostname_check: + assert_hostname = False + elif hostname: + assert_hostname = hostname + return TLSConfig( client_cert=client_cert, verify=verify, ca_cert=ca_cert, - assert_hostname=( - hostname or not options.get('--skip-hostname-check', False) - ) + assert_hostname=assert_hostname ) - else: - return None + + return None def docker_client(environment, version=None, tls_config=None, host=None): diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 56bab19c..f4476ad3 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -103,3 +103,31 @@ class TLSConfigTestCase(unittest.TestCase): options = {'--tlskey': self.key} with pytest.raises(docker.errors.TLSParameterError): tls_config_from_options(options) + + def test_assert_hostname_explicit_host(self): + options = { + '--tlscacert': self.ca_cert, '--host': 'tcp://foobar.co.uk:1254' + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.assert_hostname == 'foobar.co.uk' + + def test_assert_hostname_explicit_host_no_proto(self): + options = { + '--tlscacert': self.ca_cert, '--host': 'foobar.co.uk:1254' + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.assert_hostname == 'foobar.co.uk' + + def test_assert_hostname_implicit_host(self): + options = {'--tlscacert': self.ca_cert} + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.assert_hostname is None + + def test_assert_hostname_explicit_skip(self): + options = {'--tlscacert': self.ca_cert, '--skip-hostname-check': True} + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.assert_hostname is False From 71c86acaa4af0af5dec9baf7f1f4d7b236f249a3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 29 Mar 2016 18:01:27 -0700 Subject: [PATCH 283/580] Update docker-py version to include match_hostname fix Removed unnecessary assert_hostname computation in tls_config_from_options Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 17 +---------------- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 83cd8626..e9f39d01 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -7,7 +7,6 @@ from docker import Client from docker.errors import TLSParameterError from docker.tls import TLSConfig from docker.utils import kwargs_from_env -from requests.utils import urlparse from ..const import HTTP_TIMEOUT from .errors import UserError @@ -21,16 +20,8 @@ def tls_config_from_options(options): cert = options.get('--tlscert') key = options.get('--tlskey') verify = options.get('--tlsverify') - host = options.get('--host') skip_hostname_check = options.get('--skip-hostname-check', False) - if not skip_hostname_check: - hostname = urlparse(host).hostname if host else None - # If the protocol is omitted, urlparse fails to extract the hostname. - # Make another attempt by appending a protocol. - if not hostname and host: - hostname = urlparse('tcp://{0}'.format(host)).hostname - advanced_opts = any([ca_cert, cert, key, verify]) if tls is True and not advanced_opts: @@ -40,15 +31,9 @@ def tls_config_from_options(options): if cert or key: client_cert = (cert, key) - assert_hostname = None - if skip_hostname_check: - assert_hostname = False - elif hostname: - assert_hostname = hostname - return TLSConfig( client_cert=client_cert, verify=verify, ca_cert=ca_cert, - assert_hostname=assert_hostname + assert_hostname=False if skip_hostname_check else None ) return None diff --git a/requirements.txt b/requirements.txt index 91d0487c..4bee21ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.8.0rc2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 +git+https://github.com/docker/docker-py.git@ac3d4aae2c525b052e661f42307223676ca1b313#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 From d27b82207cc0ef4364b56a3d1e823b47791836ba Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 29 Mar 2016 18:05:37 -0700 Subject: [PATCH 284/580] Remove obsolete assert_hostname tests Signed-off-by: Joffrey F --- requirements.txt | 2 +- tests/unit/cli/docker_client_test.py | 22 ---------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4bee21ef..898df373 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 +docker-py==1.8.0rc3 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@ac3d4aae2c525b052e661f42307223676ca1b313#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index f4476ad3..5334a944 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -104,28 +104,6 @@ class TLSConfigTestCase(unittest.TestCase): with pytest.raises(docker.errors.TLSParameterError): tls_config_from_options(options) - def test_assert_hostname_explicit_host(self): - options = { - '--tlscacert': self.ca_cert, '--host': 'tcp://foobar.co.uk:1254' - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.assert_hostname == 'foobar.co.uk' - - def test_assert_hostname_explicit_host_no_proto(self): - options = { - '--tlscacert': self.ca_cert, '--host': 'foobar.co.uk:1254' - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.assert_hostname == 'foobar.co.uk' - - def test_assert_hostname_implicit_host(self): - options = {'--tlscacert': self.ca_cert} - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.assert_hostname is None - def test_assert_hostname_explicit_skip(self): options = {'--tlscacert': self.ca_cert, '--skip-hostname-check': True} result = tls_config_from_options(options) From 78a8be07adc0f83ec627d6865eb17da5c69093fa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 30 Mar 2016 11:11:19 -0700 Subject: [PATCH 285/580] Re-enabling assert_hostname when instantiating docker_client from the environment. Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e9f39d01..0c0113bb 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -49,7 +49,7 @@ def docker_client(environment, version=None, tls_config=None, host=None): "Please use COMPOSE_HTTP_TIMEOUT instead.") try: - kwargs = kwargs_from_env(assert_hostname=False, environment=environment) + kwargs = kwargs_from_env(environment=environment) except TLSParameterError: raise UserError( "TLS configuration is invalid - make sure your DOCKER_TLS_VERIFY " From 3034803258612e66bff99cdcc718253633da6bb3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 31 Mar 2016 15:45:14 +0100 Subject: [PATCH 286/580] Better variable substitution example Signed-off-by: Aanand Prasad --- docs/compose-file.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index e9ec0a2d..5aef5aca 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -1089,21 +1089,24 @@ It's more complicated if you're using particular configuration features: ## Variable substitution Your configuration options can contain environment variables. Compose uses the -variable values from the shell environment in which `docker-compose` is run. For -example, suppose the shell contains `POSTGRES_VERSION=9.3` and you supply this -configuration: +variable values from the shell environment in which `docker-compose` is run. +For example, suppose the shell contains `EXTERNAL_PORT=8000` and you supply +this configuration: - db: - image: "postgres:${POSTGRES_VERSION}" + web: + build: . + ports: + - "${EXTERNAL_PORT}:5000" -When you run `docker-compose up` with this configuration, Compose looks for the -`POSTGRES_VERSION` environment variable in the shell and substitutes its value -in. For this example, Compose resolves the `image` to `postgres:9.3` before -running the configuration. +When you run `docker-compose up` with this configuration, Compose looks for +the `EXTERNAL_PORT` environment variable in the shell and substitutes its +value in. In this example, Compose resolves the port mapping to `"8000:5000"` +before creating the `web` container. If an environment variable is not set, Compose substitutes with an empty -string. In the example above, if `POSTGRES_VERSION` is not set, the value for -the `image` option is `postgres:`. +string. In the example above, if `EXTERNAL_PORT` is not set, the value for the +port mapping is `:5000` (which is of course an invalid port mapping, and will +result in an error when attempting to create the container). Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Extended shell-style features, such as `${VARIABLE-default}` and `${VARIABLE/foo/bar}`, are not From 1a7a65f84da129cb3491c2dec3f37367444ce807 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 30 Mar 2016 11:58:28 -0700 Subject: [PATCH 287/580] Include docker-py requirements fix Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 898df373..76f224fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.8.0rc3 +docker-py==1.8.0rc5 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 From c1026e815a114b1210070d2daa56599d62d9a76e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 10 Jan 2016 12:50:38 +0000 Subject: [PATCH 288/580] Update roadmap Bring it inline with current plans: - Use in production is not necessarily about the command-line tool, but also improving file format, integrations, new tools, etc. Signed-off-by: Ben Firshman --- ROADMAP.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 67903492..c57397bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,13 +1,21 @@ # Roadmap +## An even better tool for development environments + +Compose is a great tool for development environments, but it could be even better. For example: + +- It should be possible to define hostnames for containers which work from the host machine, e.g. “mywebcontainer.local”. This is needed by apps comprising multiple web services which generate links to one another (e.g. a frontend website and a separate admin webapp) + ## More than just development environments -Over time we will extend Compose's remit to cover test, staging and production environments. This is not a simple task, and will take many incremental improvements such as: +Compose currently works really well in development, but we want to make the Compose file format better for test, staging, and production environments. To support these use cases, there will need to be improvements to the file format, improvements to the command-line tool, integrations with other tools, and perhaps new tools altogether. + +Some specific things we are considering: - Compose currently will attempt to get your application into the correct state when running `up`, but it has a number of shortcomings: - 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 be possible to partially modify the config file for different environments (dev/test/staging/prod), passing in e.g. custom ports or volume mount paths. ([#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. - It should be possible to continuously attempt to keep an application in the correct state, instead of just performing `up` a single time. @@ -23,9 +31,3 @@ Compose works well for applications that are in a single repository and depend o There are several ideas about how this could work, such as [including external files](https://github.com/docker/fig/issues/318). -## An even better tool for development environments - -Compose is a great tool for development environments, but it could be even better. For example: - -- [Compose could watch your code and automatically kick off builds when something changes.](https://github.com/docker/fig/issues/184) -- It should be possible to define hostnames for containers which work from the host machine, e.g. “mywebcontainer.local”. This is needed by apps comprising multiple web services which generate links to one another (e.g. a frontend website and a separate admin webapp) From 129fb5b356eddbb9d4939bd04e6944559f905672 Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Mon, 4 Apr 2016 13:15:28 -0400 Subject: [PATCH 289/580] Added code to output the top level command options if docker-compose help with no command options provided Signed-off-by: Tony Witherspoon --- compose/cli/main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6eada097..cf92a57b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -355,10 +355,14 @@ class TopLevelCommand(object): """ Get help on a command. - Usage: help COMMAND + Usage: help [COMMAND] """ - handler = get_handler(cls, options['COMMAND']) - raise SystemExit(getdoc(handler)) + if options['COMMAND']: + subject = get_handler(cls, options['COMMAND']) + else: + subject = cls + + print(getdoc(subject)) def kill(self, options): """ From b33d7b3dd88dbadbcd4230e38dc0a5504f9a6297 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 5 Apr 2016 11:26:23 -0400 Subject: [PATCH 290/580] Prevent unnecessary inspection of containers when created from an inspect. Signed-off-by: Daniel Nephin --- ROADMAP.md | 1 - compose/container.py | 2 +- tests/integration/service_test.py | 12 ++++++------ tests/unit/project_test.py | 9 +++++++++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c57397bd..287e5468 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,4 +30,3 @@ The current state of integration is documented in [SWARM.md](SWARM.md). Compose works well for applications that are in a single repository and depend on services that are hosted on Docker Hub. If your application depends on another application within your organisation, Compose doesn't work as well. There are several ideas about how this could work, such as [including external files](https://github.com/docker/fig/issues/318). - diff --git a/compose/container.py b/compose/container.py index 6dac9499..2c16863d 100644 --- a/compose/container.py +++ b/compose/container.py @@ -39,7 +39,7 @@ class Container(object): @classmethod def from_id(cls, client, id): - return cls(client, client.inspect_container(id)) + return cls(client, client.inspect_container(id), has_been_inspected=True) @classmethod def create(cls, client, **options): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index e2ef1161..0a109ada 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -769,17 +769,17 @@ class ServiceTest(DockerClientTestCase): container = service.create_container(number=next_number, quiet=True) container.start() - self.assertTrue(container.is_running) - self.assertEqual(len(service.containers()), 1) + container.inspect() + assert container.is_running + assert len(service.containers()) == 1 service.scale(1) - - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 container.inspect() - self.assertTrue(container.is_running) + assert container.is_running captured_output = mock_log.info.call_args[0] - self.assertIn('Desired container number already achieved', captured_output) + assert 'Desired container number already achieved' in captured_output @mock.patch('compose.service.log') def test_scale_with_custom_container_name_outputs_warning(self, mock_log): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 0d381951..b6a52e08 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -270,12 +270,21 @@ class ProjectTest(unittest.TestCase): 'time': 1420092061, 'timeNano': 14200920610000004000, }, + { + 'status': 'destroy', + 'from': 'example/db', + 'id': 'eeeee', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, ]) def dt_with_microseconds(dt, us): return datetime.datetime.fromtimestamp(dt).replace(microsecond=us) def get_container(cid): + if cid == 'eeeee': + raise NotFound(None, None, "oops") if cid == 'abcde': name = 'web' labels = {LABEL_SERVICE: name} From 3ef6b17bfc1d6aeb97b5ef2ac77c3659cd28ac4e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 6 Apr 2016 13:28:45 -0700 Subject: [PATCH 291/580] Use docker-py 1.8.0 Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 76f224fb..b9b0f403 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.8.0rc5 +docker-py==1.8.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 From 5d0aab4a8e3a231f6fd548be6f9881ddefc60cfc Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Thu, 7 Apr 2016 12:42:14 -0400 Subject: [PATCH 292/580] updated cli_test.py to no longer expect raised SystemExit exceptions Signed-off-by: Tony Witherspoon --- tests/unit/cli_test.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index e0ada460..b1475f84 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -64,12 +64,6 @@ class CLITestCase(unittest.TestCase): self.assertTrue(project.client) self.assertTrue(project.services) - def test_command_help(self): - with pytest.raises(SystemExit) as exc: - TopLevelCommand.help({'COMMAND': 'up'}) - - assert 'Usage: up' in exc.exconly() - def test_command_help_nonexistent(self): with pytest.raises(NoSuchCommand): TopLevelCommand.help({'COMMAND': 'nonexistent'}) From bcdf541c8c6ccc0070ab011a909f244f501676d6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 12:58:19 +0100 Subject: [PATCH 293/580] Refactor setup_queue() - Stop sharing set objects across threads - Use a second queue to signal when producer threads are done - Use a single consumer thread to check dependencies and kick off new producers Signed-off-by: Aanand Prasad --- compose/parallel.py | 64 ++++++++++++++++++++++++++++++--------------- compose/service.py | 3 +++ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index c629a1ab..79699236 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import logging import operator import sys from threading import Thread @@ -14,6 +15,9 @@ from compose.cli.signals import ShutdownException from compose.utils import get_output_stream +log = logging.getLogger(__name__) + + def parallel_execute(objects, func, get_name, msg, get_deps=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -73,35 +77,53 @@ def setup_queue(objects, func, get_deps, get_name): get_deps = _no_deps results = Queue() - started = set() # objects being processed - finished = set() # objects which have been processed + output = Queue() - def do_op(obj): + def consumer(): + started = set() # objects being processed + finished = set() # objects which have been processed + + def ready(obj): + """ + Returns true if obj is ready to be processed: + - all dependencies have been processed + - obj is not already being processed + """ + return obj not in started and all( + dep not in objects or dep in finished + for dep in get_deps(obj) + ) + + while len(finished) < len(objects): + for obj in filter(ready, objects): + log.debug('Starting producer thread for {}'.format(obj)) + t = Thread(target=producer, args=(obj,)) + t.daemon = True + t.start() + started.add(obj) + + try: + event = results.get(timeout=1) + except Empty: + continue + + obj = event[0] + log.debug('Finished processing: {}'.format(obj)) + finished.add(obj) + output.put(event) + + def producer(obj): try: result = func(obj) results.put((obj, result, None)) except Exception as e: results.put((obj, None, e)) - finished.add(obj) - feed() + t = Thread(target=consumer) + t.daemon = True + t.start() - def ready(obj): - # Is object ready for performing operation - return obj not in started and all( - dep not in objects or dep in finished - for dep in get_deps(obj) - ) - - def feed(): - for obj in filter(ready, objects): - started.add(obj) - t = Thread(target=do_op, args=(obj,)) - t.daemon = True - t.start() - - feed() - return results + return output class ParallelStreamWriter(object): diff --git a/compose/service.py b/compose/service.py index ed45f078..05cfc7c6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -135,6 +135,9 @@ class Service(object): self.networks = networks or {} self.options = options + def __repr__(self): + return ''.format(self.name) + def containers(self, stopped=False, one_off=False, filters={}): filters.update({'label': self.labels(one_off=one_off)}) From 141b96bb312d85753de2189227941512bd42f33e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 17:46:13 +0100 Subject: [PATCH 294/580] Abort operations if their dependencies fail Signed-off-by: Aanand Prasad --- compose/parallel.py | 102 +++++++++++++++++++++--------------- tests/unit/parallel_test.py | 73 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 tests/unit/parallel_test.py diff --git a/compose/parallel.py b/compose/parallel.py index 79699236..745d4635 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -32,7 +32,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): for obj in objects: writer.initialize(get_name(obj)) - q = setup_queue(objects, func, get_deps, get_name) + q = setup_queue(objects, func, get_deps) done = 0 errors = {} @@ -54,6 +54,8 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation writer.write(get_name(obj), 'error') + elif isinstance(exception, UpstreamError): + writer.write(get_name(obj), 'error') else: errors[get_name(obj)] = exception error_to_reraise = exception @@ -72,60 +74,74 @@ def _no_deps(x): return [] -def setup_queue(objects, func, get_deps, get_name): +def setup_queue(objects, func, get_deps): if get_deps is None: get_deps = _no_deps results = Queue() output = Queue() - def consumer(): - started = set() # objects being processed - finished = set() # objects which have been processed - - def ready(obj): - """ - Returns true if obj is ready to be processed: - - all dependencies have been processed - - obj is not already being processed - """ - return obj not in started and all( - dep not in objects or dep in finished - for dep in get_deps(obj) - ) - - while len(finished) < len(objects): - for obj in filter(ready, objects): - log.debug('Starting producer thread for {}'.format(obj)) - t = Thread(target=producer, args=(obj,)) - t.daemon = True - t.start() - started.add(obj) - - try: - event = results.get(timeout=1) - except Empty: - continue - - obj = event[0] - log.debug('Finished processing: {}'.format(obj)) - finished.add(obj) - output.put(event) - - def producer(obj): - try: - result = func(obj) - results.put((obj, result, None)) - except Exception as e: - results.put((obj, None, e)) - - t = Thread(target=consumer) + t = Thread(target=queue_consumer, args=(objects, func, get_deps, results, output)) t.daemon = True t.start() return output +def queue_producer(obj, func, results): + try: + result = func(obj) + results.put((obj, result, None)) + except Exception as e: + results.put((obj, None, e)) + + +def queue_consumer(objects, func, get_deps, results, output): + started = set() # objects being processed + finished = set() # objects which have been processed + failed = set() # objects which either failed or whose dependencies failed + + while len(finished) + len(failed) < len(objects): + pending = set(objects) - started - finished - failed + log.debug('Pending: {}'.format(pending)) + + for obj in pending: + deps = get_deps(obj) + + if any(dep in failed for dep in deps): + log.debug('{} has upstream errors - not processing'.format(obj)) + output.put((obj, None, UpstreamError())) + failed.add(obj) + elif all( + dep not in objects or dep in finished + for dep in deps + ): + log.debug('Starting producer thread for {}'.format(obj)) + t = Thread(target=queue_producer, args=(obj, func, results)) + t.daemon = True + t.start() + started.add(obj) + + try: + event = results.get(timeout=1) + except Empty: + continue + + obj, _, exception = event + if exception is None: + log.debug('Finished processing: {}'.format(obj)) + finished.add(obj) + else: + log.debug('Failed: {}'.format(obj)) + failed.add(obj) + + output.put(event) + + +class UpstreamError(Exception): + pass + + class ParallelStreamWriter(object): """Write out messages for operations happening in parallel. diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py new file mode 100644 index 00000000..6be56015 --- /dev/null +++ b/tests/unit/parallel_test.py @@ -0,0 +1,73 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import six +from docker.errors import APIError + +from compose.parallel import parallel_execute + + +web = 'web' +db = 'db' +data_volume = 'data_volume' +cache = 'cache' + +objects = [web, db, data_volume, cache] + +deps = { + web: [db, cache], + db: [data_volume], + data_volume: [], + cache: [], +} + + +def test_parallel_execute(): + results = parallel_execute( + objects=[1, 2, 3, 4, 5], + func=lambda x: x * 2, + get_name=six.text_type, + msg="Doubling", + ) + + assert sorted(results) == [2, 4, 6, 8, 10] + + +def test_parallel_execute_with_deps(): + log = [] + + def process(x): + log.append(x) + + parallel_execute( + objects=objects, + func=process, + get_name=lambda obj: obj, + msg="Processing", + get_deps=lambda obj: deps[obj], + ) + + assert sorted(log) == sorted(objects) + + assert log.index(data_volume) < log.index(db) + assert log.index(db) < log.index(web) + assert log.index(cache) < log.index(web) + + +def test_parallel_execute_with_upstream_errors(): + log = [] + + def process(x): + if x is data_volume: + raise APIError(None, None, "Something went wrong") + log.append(x) + + parallel_execute( + objects=objects, + func=process, + get_name=lambda obj: obj, + msg="Processing", + get_deps=lambda obj: deps[obj], + ) + + assert log == [cache] From af9526fb820f40a8b7eafb16d29f990b1696f4fe Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:30:28 +0100 Subject: [PATCH 295/580] Move queue logic out of parallel_execute() Signed-off-by: Aanand Prasad --- compose/parallel.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 745d4635..8172d8ea 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -32,22 +32,13 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): for obj in objects: writer.initialize(get_name(obj)) - q = setup_queue(objects, func, get_deps) + events = parallel_execute_stream(objects, func, get_deps) - done = 0 errors = {} results = [] error_to_reraise = None - while done < len(objects): - try: - obj, result, exception = q.get(timeout=1) - except Empty: - continue - # See https://github.com/docker/compose/issues/189 - except thread.error: - raise ShutdownException() - + for obj, result, exception in events: if exception is None: writer.write(get_name(obj), 'done') results.append(result) @@ -59,7 +50,6 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): else: errors[get_name(obj)] = exception error_to_reraise = exception - done += 1 for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) @@ -74,7 +64,7 @@ def _no_deps(x): return [] -def setup_queue(objects, func, get_deps): +def parallel_execute_stream(objects, func, get_deps): if get_deps is None: get_deps = _no_deps @@ -85,7 +75,17 @@ def setup_queue(objects, func, get_deps): t.daemon = True t.start() - return output + done = 0 + + while done < len(objects): + try: + yield output.get(timeout=1) + done += 1 + except Empty: + continue + # See https://github.com/docker/compose/issues/189 + except thread.error: + raise ShutdownException() def queue_producer(obj, func, results): From 3720b50c3b8c5534c0b139962f7f6d95dd32a066 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:48:07 +0100 Subject: [PATCH 296/580] Extract get_deps test helper Signed-off-by: Aanand Prasad --- tests/unit/parallel_test.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 6be56015..889af4e2 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -22,6 +22,10 @@ deps = { } +def get_deps(obj): + return deps[obj] + + def test_parallel_execute(): results = parallel_execute( objects=[1, 2, 3, 4, 5], @@ -44,7 +48,7 @@ def test_parallel_execute_with_deps(): func=process, get_name=lambda obj: obj, msg="Processing", - get_deps=lambda obj: deps[obj], + get_deps=get_deps, ) assert sorted(log) == sorted(objects) @@ -67,7 +71,7 @@ def test_parallel_execute_with_upstream_errors(): func=process, get_name=lambda obj: obj, msg="Processing", - get_deps=lambda obj: deps[obj], + get_deps=get_deps, ) assert log == [cache] From ffab27c0496769fade7f2aa32bd86f66a3c9c0e5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:53:16 +0100 Subject: [PATCH 297/580] Test events coming out of parallel_execute_stream in error case Signed-off-by: Aanand Prasad --- tests/unit/parallel_test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 889af4e2..9ed1b362 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -5,6 +5,8 @@ import six from docker.errors import APIError from compose.parallel import parallel_execute +from compose.parallel import parallel_execute_stream +from compose.parallel import UpstreamError web = 'web' @@ -75,3 +77,14 @@ def test_parallel_execute_with_upstream_errors(): ) assert log == [cache] + + events = [ + (obj, result, type(exception)) + for obj, result, exception + in parallel_execute_stream(objects, process, get_deps) + ] + + assert (cache, None, type(None)) in events + assert (data_volume, None, APIError) in events + assert (db, None, UpstreamError) in events + assert (web, None, UpstreamError) in events From 54b6fc42195da8f7ca1b45828e49ce5e378baee0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:54:02 +0100 Subject: [PATCH 298/580] Refactor so there's only one queue Signed-off-by: Aanand Prasad --- compose/parallel.py | 79 +++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 8172d8ea..b3ca0153 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -69,24 +69,33 @@ def parallel_execute_stream(objects, func, get_deps): get_deps = _no_deps results = Queue() - output = Queue() - t = Thread(target=queue_consumer, args=(objects, func, get_deps, results, output)) - t.daemon = True - t.start() + started = set() # objects being processed + finished = set() # objects which have been processed + failed = set() # objects which either failed or whose dependencies failed - done = 0 + while len(finished) + len(failed) < len(objects): + for event in feed_queue(objects, func, get_deps, results, started, finished, failed): + yield event - while done < len(objects): try: - yield output.get(timeout=1) - done += 1 + event = results.get(timeout=1) except Empty: continue # See https://github.com/docker/compose/issues/189 except thread.error: raise ShutdownException() + obj, _, exception = event + if exception is None: + log.debug('Finished processing: {}'.format(obj)) + finished.add(obj) + else: + log.debug('Failed: {}'.format(obj)) + failed.add(obj) + + yield event + def queue_producer(obj, func, results): try: @@ -96,46 +105,26 @@ def queue_producer(obj, func, results): results.put((obj, None, e)) -def queue_consumer(objects, func, get_deps, results, output): - started = set() # objects being processed - finished = set() # objects which have been processed - failed = set() # objects which either failed or whose dependencies failed +def feed_queue(objects, func, get_deps, results, started, finished, failed): + pending = set(objects) - started - finished - failed + log.debug('Pending: {}'.format(pending)) - while len(finished) + len(failed) < len(objects): - pending = set(objects) - started - finished - failed - log.debug('Pending: {}'.format(pending)) + for obj in pending: + deps = get_deps(obj) - for obj in pending: - deps = get_deps(obj) - - if any(dep in failed for dep in deps): - log.debug('{} has upstream errors - not processing'.format(obj)) - output.put((obj, None, UpstreamError())) - failed.add(obj) - elif all( - dep not in objects or dep in finished - for dep in deps - ): - log.debug('Starting producer thread for {}'.format(obj)) - t = Thread(target=queue_producer, args=(obj, func, results)) - t.daemon = True - t.start() - started.add(obj) - - try: - event = results.get(timeout=1) - except Empty: - continue - - obj, _, exception = event - if exception is None: - log.debug('Finished processing: {}'.format(obj)) - finished.add(obj) - else: - log.debug('Failed: {}'.format(obj)) + if any(dep in failed for dep in deps): + log.debug('{} has upstream errors - not processing'.format(obj)) + yield (obj, None, UpstreamError()) failed.add(obj) - - output.put(event) + elif all( + dep not in objects or dep in finished + for dep in deps + ): + log.debug('Starting producer thread for {}'.format(obj)) + t = Thread(target=queue_producer, args=(obj, func, results)) + t.daemon = True + t.start() + started.add(obj) class UpstreamError(Exception): From 5450a67c2d75192b962c3c36cf73a417af4386b3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 19:06:07 +0100 Subject: [PATCH 299/580] Hold state in an object Signed-off-by: Aanand Prasad --- compose/parallel.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index b3ca0153..f400b223 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -64,18 +64,30 @@ def _no_deps(x): return [] +class State(object): + def __init__(self, objects): + self.objects = objects + + self.started = set() # objects being processed + self.finished = set() # objects which have been processed + self.failed = set() # objects which either failed or whose dependencies failed + + def is_done(self): + return len(self.finished) + len(self.failed) >= len(self.objects) + + def pending(self): + return set(self.objects) - self.started - self.finished - self.failed + + def parallel_execute_stream(objects, func, get_deps): if get_deps is None: get_deps = _no_deps results = Queue() + state = State(objects) - started = set() # objects being processed - finished = set() # objects which have been processed - failed = set() # objects which either failed or whose dependencies failed - - while len(finished) + len(failed) < len(objects): - for event in feed_queue(objects, func, get_deps, results, started, finished, failed): + while not state.is_done(): + for event in feed_queue(objects, func, get_deps, results, state): yield event try: @@ -89,10 +101,10 @@ def parallel_execute_stream(objects, func, get_deps): obj, _, exception = event if exception is None: log.debug('Finished processing: {}'.format(obj)) - finished.add(obj) + state.finished.add(obj) else: log.debug('Failed: {}'.format(obj)) - failed.add(obj) + state.failed.add(obj) yield event @@ -105,26 +117,26 @@ def queue_producer(obj, func, results): results.put((obj, None, e)) -def feed_queue(objects, func, get_deps, results, started, finished, failed): - pending = set(objects) - started - finished - failed +def feed_queue(objects, func, get_deps, results, state): + pending = state.pending() log.debug('Pending: {}'.format(pending)) for obj in pending: deps = get_deps(obj) - if any(dep in failed for dep in deps): + if any(dep in state.failed for dep in deps): log.debug('{} has upstream errors - not processing'.format(obj)) yield (obj, None, UpstreamError()) - failed.add(obj) + state.failed.add(obj) elif all( - dep not in objects or dep in finished + dep not in objects or dep in state.finished for dep in deps ): log.debug('Starting producer thread for {}'.format(obj)) t = Thread(target=queue_producer, args=(obj, func, results)) t.daemon = True t.start() - started.add(obj) + state.started.add(obj) class UpstreamError(Exception): From be27e266da9bbb252e74d71ab22044628c6839d2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 19:07:40 +0100 Subject: [PATCH 300/580] Reduce queue timeout Signed-off-by: Aanand Prasad --- compose/parallel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/parallel.py b/compose/parallel.py index f400b223..e360ca35 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -91,7 +91,7 @@ def parallel_execute_stream(objects, func, get_deps): yield event try: - event = results.get(timeout=1) + event = results.get(timeout=0.1) except Empty: continue # See https://github.com/docker/compose/issues/189 From 83df95d5118a340fca71ca912825b3e9ba89ff96 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Apr 2016 11:59:06 -0400 Subject: [PATCH 301/580] Remove extra ensure_image_exists() which causes duplicate builds. Signed-off-by: Daniel Nephin --- compose/project.py | 11 +++++------ compose/service.py | 11 ++++------- tests/integration/service_test.py | 6 ++---- tests/unit/service_test.py | 18 +++++++++--------- 4 files changed, 20 insertions(+), 26 deletions(-) diff --git a/compose/project.py b/compose/project.py index 8aa48731..0d891e45 100644 --- a/compose/project.py +++ b/compose/project.py @@ -309,12 +309,13 @@ class Project(object): ): services = self.get_services_without_duplicate(service_names, include_deps=True) + for svc in services: + svc.ensure_image_exists(do_build=do_build) plans = self._get_convergence_plans(services, strategy) for service in services: service.execute_convergence_plan( plans[service.name], - do_build, detached=True, start=False) @@ -366,21 +367,19 @@ class Project(object): remove_orphans=False): self.initialize() + self.find_orphan_containers(remove_orphans) + services = self.get_services_without_duplicate( service_names, include_deps=start_deps) - plans = self._get_convergence_plans(services, strategy) - for svc in services: svc.ensure_image_exists(do_build=do_build) - - self.find_orphan_containers(remove_orphans) + plans = self._get_convergence_plans(services, strategy) def do(service): return service.execute_convergence_plan( plans[service.name], - do_build=do_build, timeout=timeout, detached=detached ) diff --git a/compose/service.py b/compose/service.py index 05cfc7c6..e0f23888 100644 --- a/compose/service.py +++ b/compose/service.py @@ -254,7 +254,6 @@ class Service(object): def create_container(self, one_off=False, - do_build=BuildAction.none, previous_container=None, number=None, quiet=False, @@ -263,7 +262,9 @@ class Service(object): Create a container for this service. If the image doesn't exist, attempt to pull it. """ - self.ensure_image_exists(do_build=do_build) + # This is only necessary for `scale` and `volumes_from` + # auto-creating containers to satisfy the dependency. + self.ensure_image_exists() container_options = self._get_container_create_options( override_options, @@ -363,7 +364,6 @@ class Service(object): def execute_convergence_plan(self, plan, - do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, detached=False, start=True): @@ -371,7 +371,7 @@ class Service(object): should_attach_logs = not detached if action == 'create': - container = self.create_container(do_build=do_build) + container = self.create_container() if should_attach_logs: container.attach_log_stream() @@ -385,7 +385,6 @@ class Service(object): return [ self.recreate_container( container, - do_build=do_build, timeout=timeout, attach_logs=should_attach_logs, start_new_container=start @@ -412,7 +411,6 @@ class Service(object): def recreate_container( self, container, - do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, attach_logs=False, start_new_container=True): @@ -427,7 +425,6 @@ class Service(object): container.stop(timeout=timeout) container.rename_to_tmp_name() new_container = self.create_container( - do_build=do_build, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0a109ada..df50d513 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1037,12 +1037,10 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(set(service.duplicate_containers()), set([duplicate])) -def converge(service, - strategy=ConvergenceStrategy.changed, - do_build=True): +def converge(service, strategy=ConvergenceStrategy.changed): """Create a converge plan from a strategy and execute the plan.""" plan = service.convergence_plan(strategy) - return service.execute_convergence_plan(plan, do_build=do_build, timeout=1) + return service.execute_convergence_plan(plan, timeout=1) class ConfigHashTest(DockerClientTestCase): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5231237a..fe3794da 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -420,7 +420,7 @@ class ServiceTest(unittest.TestCase): parse_repository_tag("url:5000/repo@sha256:digest"), ("url:5000/repo", "sha256:digest", "@")) - def test_create_container_with_build(self): + def test_create_container(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.side_effect = [ NoSuchImageError, @@ -431,7 +431,7 @@ class ServiceTest(unittest.TestCase): ] with mock.patch('compose.service.log', autospec=True) as mock_log: - service.create_container(do_build=BuildAction.none) + service.create_container() assert mock_log.warn.called _, args, _ = mock_log.warn.mock_calls[0] assert 'was built because it did not already exist' in args[0] @@ -448,20 +448,20 @@ class ServiceTest(unittest.TestCase): buildargs=None, ) - def test_create_container_no_build(self): + def test_ensure_image_exists_no_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.return_value = {'Id': 'abc123'} - service.create_container(do_build=BuildAction.skip) - self.assertFalse(self.mock_client.build.called) + service.ensure_image_exists(do_build=BuildAction.skip) + assert not self.mock_client.build.called - def test_create_container_no_build_but_needs_build(self): + def test_ensure_image_exists_no_build_but_needs_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.side_effect = NoSuchImageError with pytest.raises(NeedsBuildError): - service.create_container(do_build=BuildAction.skip) + service.ensure_image_exists(do_build=BuildAction.skip) - def test_create_container_force_build(self): + def test_ensure_image_exists_force_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.return_value = {'Id': 'abc123'} self.mock_client.build.return_value = [ @@ -469,7 +469,7 @@ class ServiceTest(unittest.TestCase): ] with mock.patch('compose.service.log', autospec=True) as mock_log: - service.create_container(do_build=BuildAction.force) + service.ensure_image_exists(do_build=BuildAction.force) assert not mock_log.warn.called self.mock_client.build.assert_called_once_with( From d4e9a3b6b144d2dd126dee2369c10284ec52cdbc Mon Sep 17 00:00:00 2001 From: Sanyam Kapoor Date: Wed, 6 Apr 2016 23:05:40 +0530 Subject: [PATCH 302/580] Updated Wordpress tutorial The new tutorial now uses official Wordpress Docker Image. Signed-off-by: Sanyam Kapoor <1sanyamkapoor@gmail.com> --- docs/wordpress.md | 115 +++++++++++----------------------------------- 1 file changed, 26 insertions(+), 89 deletions(-) diff --git a/docs/wordpress.md b/docs/wordpress.md index fcfaef19..c257ad1a 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -22,7 +22,7 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t You can name the directory something easy for you to remember. This directory is the context for your application image. The directory should only contain resources to build that image. - This project directory will contain a `Dockerfile`, a `docker-compose.yaml` file, along with a downloaded `wordpress` directory and a custom `wp-config.php`, all of which you will create in the following steps. + This project directory will contain a `docker-compose.yaml` file which will be complete in itself for a good starter wordpress project. 2. Change directories into your project directory. @@ -30,113 +30,50 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t $ cd my-wordpress/ -3. Create a `Dockerfile`, a file that defines the environment in which your application will run. - - For more information on how to write Dockerfiles, see the [Docker Engine user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](https://docs.docker.com/engine/reference/builder/). - - In this case, your Dockerfile should include these two lines: - - FROM php:5.6-fpm - RUN docker-php-ext-install mysql - ADD . /code - CMD php -S 0.0.0.0:8000 -t /code/wordpress/ - - This tells the Docker Engine daemon how to build an image defining a container that contains PHP and WordPress. - -4. Create a `docker-compose.yml` file that will start your web service and a separate MySQL instance: +3. Create a `docker-compose.yml` file that will start your `Wordpress` blog and a separate `MySQL` instance with a volume mount for data persistence: version: '2' services: - web: - build: . - ports: - - "8000:8000" - depends_on: - - db - volumes: - - .:/code db: - image: mysql + image: mysql:5.7 + volumes: + - "./.data/db:/var/lib/mysql" + restart: always environment: MYSQL_ROOT_PASSWORD: wordpress MYSQL_DATABASE: wordpress MYSQL_USER: wordpress MYSQL_PASSWORD: wordpress -5. Download WordPress into the current directory: + wordpress: + depends_on: + - db + image: wordpress:latest + links: + - db + ports: + - "8000:80" + restart: always + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_PASSWORD: wordpress - $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - - - This creates a directory called `wordpress` in your project directory. - -6. Create a `wp-config.php` file within the `wordpress` directory. - - A supporting file is needed to get this working. At the top level of the wordpress directory, add a new file called `wp-config.php` as shown. This is the standard WordPress config file with a single change to point the database configuration at the `db` container: - - - -7. Verify the contents and structure of your project directory. - - - ![WordPress files](images/wordpress-files.png) + **NOTE**: The folder `./.data/db` will be automatically created in the project directory + alongside the `docker-compose.yml` which will persist any updates made by wordpress to the + database. ### Build the project -With those four new files in place, run `docker-compose up` from your project directory. This will pull and build the needed images, and then start the web and database containers. +Now, run `docker-compose up -d` from your project directory. This will pull the needed images, and then start the wordpress and database containers. If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` gives you the machine address and you can open `http://MACHINE_VM_IP:8000` in a browser. At this point, WordPress should be running on port `8000` of your Docker Host, and you can complete the "famous five-minute installation" as a WordPress administrator. +**NOTE**: The Wordpress site will not be immediately available on port `8000` because +the containers are still being initialized and may take a couple of minutes before the +first load. + ![Choose language for WordPress install](images/wordpress-lang.png) ![WordPress Welcome](images/wordpress-welcome.png) From 4192a009da5cbae5c811b3b965e4ecb4572c95f6 Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Fri, 8 Apr 2016 16:40:07 -0700 Subject: [PATCH 303/580] added some formatting on the Wordress steps, and made heading levels in these sample app topics consistent Signed-off-by: Victoria Bialas --- docs/django.md | 6 +++--- docs/wordpress.md | 32 +++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/django.md b/docs/django.md index fb1fa214..6a222697 100644 --- a/docs/django.md +++ b/docs/django.md @@ -15,7 +15,7 @@ weight=4 This quick-start guide demonstrates how to use Docker Compose to set up and run a simple Django/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). -## Define the project components +### Define the project components For this project, you need to create a Dockerfile, a Python dependencies file, and a `docker-compose.yml` file. @@ -89,7 +89,7 @@ and a `docker-compose.yml` file. 10. Save and close the `docker-compose.yml` file. -## Create a Django project +### Create a Django project In this step, you create a Django started project by building the image from the build context defined in the previous procedure. @@ -137,7 +137,7 @@ In this step, you create a Django started project by building the image from the -rw-r--r-- 1 user staff 16 Feb 13 23:01 requirements.txt -## Connect the database +### Connect the database In this section, you set up the database connection for Django. diff --git a/docs/wordpress.md b/docs/wordpress.md index c257ad1a..b39a8bbb 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -16,7 +16,7 @@ You can use Docker Compose to easily run WordPress in an isolated environment bu with Docker containers. This quick-start guide demonstrates how to use Compose to set up and run WordPress. Before starting, you'll need to have [Compose installed](install.md). -## Define the project +### Define the project 1. Create an empty project directory. @@ -64,15 +64,37 @@ with Docker containers. This quick-start guide demonstrates how to use Compose t ### Build the project -Now, run `docker-compose up -d` from your project directory. This will pull the needed images, and then start the wordpress and database containers. +Now, run `docker-compose up -d` from your project directory. + +This pulls the needed images, and starts the wordpress and database containers, as shown in the example below. + + $ docker-compose up -d + Creating network "my_wordpress_default" with the default driver + Pulling db (mysql:5.7)... + 5.7: Pulling from library/mysql + efd26ecc9548: Pull complete + a3ed95caeb02: Pull complete + ... + Digest: sha256:34a0aca88e85f2efa5edff1cea77cf5d3147ad93545dbec99cfe705b03c520de + Status: Downloaded newer image for mysql:5.7 + Pulling wordpress (wordpress:latest)... + latest: Pulling from library/wordpress + efd26ecc9548: Already exists + a3ed95caeb02: Pull complete + 589a9d9a7c64: Pull complete + ... + Digest: sha256:ed28506ae44d5def89075fd5c01456610cd6c64006addfe5210b8c675881aff6 + Status: Downloaded newer image for wordpress:latest + Creating my_wordpress_db_1 + Creating my_wordpress_wordpress_1 + +### Bring up WordPress in a web browser If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` gives you the machine address and you can open `http://MACHINE_VM_IP:8000` in a browser. At this point, WordPress should be running on port `8000` of your Docker Host, and you can complete the "famous five-minute installation" as a WordPress administrator. -**NOTE**: The Wordpress site will not be immediately available on port `8000` because -the containers are still being initialized and may take a couple of minutes before the -first load. +**NOTE**: The Wordpress site will not be immediately available on port `8000` because the containers are still being initialized and may take a couple of minutes before the first load. ![Choose language for WordPress install](images/wordpress-lang.png) From 0e3db185cf79e6638c2660be8e052af113ed7337 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 12:37:00 +0100 Subject: [PATCH 304/580] Small refactor to feed_queue() Put the event tuple into the results queue rather than yielding it from the function. Signed-off-by: Aanand Prasad --- compose/parallel.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index e360ca35..ace1f029 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -87,8 +87,7 @@ def parallel_execute_stream(objects, func, get_deps): state = State(objects) while not state.is_done(): - for event in feed_queue(objects, func, get_deps, results, state): - yield event + feed_queue(objects, func, get_deps, results, state) try: event = results.get(timeout=0.1) @@ -126,7 +125,7 @@ def feed_queue(objects, func, get_deps, results, state): if any(dep in state.failed for dep in deps): log.debug('{} has upstream errors - not processing'.format(obj)) - yield (obj, None, UpstreamError()) + results.put((obj, None, UpstreamError())) state.failed.add(obj) elif all( dep not in objects or dep in state.finished From 0671b8b8c3ce1873db87c4233f88e64876d43c6a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 12:49:04 +0100 Subject: [PATCH 305/580] Document parallel helper functions Signed-off-by: Aanand Prasad --- compose/parallel.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index ace1f029..d9c24ab6 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -65,12 +65,19 @@ def _no_deps(x): class State(object): + """ + Holds the state of a partially-complete parallel operation. + + state.started: objects being processed + state.finished: objects which have been processed + state.failed: objects which either failed or whose dependencies failed + """ def __init__(self, objects): self.objects = objects - self.started = set() # objects being processed - self.finished = set() # objects which have been processed - self.failed = set() # objects which either failed or whose dependencies failed + self.started = set() + self.finished = set() + self.failed = set() def is_done(self): return len(self.finished) + len(self.failed) >= len(self.objects) @@ -80,6 +87,21 @@ class State(object): def parallel_execute_stream(objects, func, get_deps): + """ + Runs func on objects in parallel while ensuring that func is + ran on object only after it is ran on all its dependencies. + + Returns an iterator of tuples which look like: + + # if func returned normally when run on object + (object, result, None) + + # if func raised an exception when run on object + (object, None, exception) + + # if func raised an exception when run on one of object's dependencies + (object, None, UpstreamError()) + """ if get_deps is None: get_deps = _no_deps @@ -109,6 +131,10 @@ def parallel_execute_stream(objects, func, get_deps): def queue_producer(obj, func, results): + """ + 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. + """ try: result = func(obj) results.put((obj, result, None)) @@ -117,6 +143,13 @@ def queue_producer(obj, func, results): def feed_queue(objects, func, get_deps, results, state): + """ + Starts producer threads for any objects which are ready to be processed + (i.e. they have no dependencies which haven't been successfully processed). + + Shortcuts any objects whose dependencies have failed and places an + (object, None, UpstreamError()) tuple on the results queue. + """ pending = state.pending() log.debug('Pending: {}'.format(pending)) From 15c5bc2e6c79cdb2edac4f8cab10d7bcbfc175d1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 13:03:35 +0100 Subject: [PATCH 306/580] Rename a couple of functions in parallel.py Signed-off-by: Aanand Prasad --- compose/parallel.py | 8 ++++---- tests/unit/parallel_test.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index d9c24ab6..ee3d5777 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -32,7 +32,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): for obj in objects: writer.initialize(get_name(obj)) - events = parallel_execute_stream(objects, func, get_deps) + events = parallel_execute_iter(objects, func, get_deps) errors = {} results = [] @@ -86,7 +86,7 @@ class State(object): return set(self.objects) - self.started - self.finished - self.failed -def parallel_execute_stream(objects, func, get_deps): +def parallel_execute_iter(objects, func, get_deps): """ Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -130,7 +130,7 @@ def parallel_execute_stream(objects, func, get_deps): yield event -def queue_producer(obj, func, results): +def producer(obj, func, results): """ 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. @@ -165,7 +165,7 @@ def feed_queue(objects, func, get_deps, results, state): for dep in deps ): log.debug('Starting producer thread for {}'.format(obj)) - t = Thread(target=queue_producer, args=(obj, func, results)) + t = Thread(target=producer, args=(obj, func, results)) t.daemon = True t.start() state.started.add(obj) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 9ed1b362..45b0db1d 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -5,7 +5,7 @@ import six from docker.errors import APIError from compose.parallel import parallel_execute -from compose.parallel import parallel_execute_stream +from compose.parallel import parallel_execute_iter from compose.parallel import UpstreamError @@ -81,7 +81,7 @@ def test_parallel_execute_with_upstream_errors(): events = [ (obj, result, type(exception)) for obj, result, exception - in parallel_execute_stream(objects, process, get_deps) + in parallel_execute_iter(objects, process, get_deps) ] assert (cache, None, type(None)) in events From 3722bb38c66b3c3500e86295a43aafe14a050b50 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 14:26:45 +0100 Subject: [PATCH 307/580] Clarify behaviour of rm and down Signed-off-by: Aanand Prasad --- compose/cli/main.py | 35 +++++++++++++++++++++++------------ docs/reference/down.md | 26 ++++++++++++++++++-------- docs/reference/rm.md | 11 ++++++----- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 8348b8c3..839d97e8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -264,18 +264,29 @@ class TopLevelCommand(object): def down(self, options): """ - Stop containers and remove containers, networks, volumes, and images - created by `up`. Only containers and networks are removed by default. + Stops containers and removes containers, networks, volumes, and images + created by `up`. + + By default, the only things removed are: + + - Containers for services defined in the Compose file + - Networks defined in the `networks` section of the Compose file + - The default network, if one is used + + Networks and volumes defined as `external` are never removed. Usage: down [options] Options: - --rmi type Remove images, type may be one of: 'all' to remove - all images, or 'local' to remove only images that - don't have an custom name set by the `image` field - -v, --volumes Remove data volumes - --remove-orphans Remove containers for services not defined in - the Compose file + --rmi type Remove images. Type must be one of: + 'all': Remove all images used by any service. + 'local': Remove only images that don't have a custom tag + set by the `image` field. + -v, --volumes Remove named volumes declared in the `volumes` section + of the Compose file and anonymous volumes + attached to containers. + --remove-orphans Remove containers for services not defined in the + Compose file """ image_type = image_type_from_opt('--rmi', options['--rmi']) self.project.down(image_type, options['--volumes'], options['--remove-orphans']) @@ -496,10 +507,10 @@ class TopLevelCommand(object): def rm(self, options): """ - Remove stopped service containers. + Removes stopped service containers. - By default, volumes attached to containers will not be removed. You can see all - volumes with `docker volume ls`. + By default, anonymous volumes attached to containers will not be removed. You + can override this with `-v`. To list all volumes, use `docker volume ls`. Any data which is not in a volume will be lost. @@ -507,7 +518,7 @@ class TopLevelCommand(object): Options: -f, --force Don't ask to confirm removal - -v Remove volumes associated with containers + -v Remove any anonymous volumes attached to containers -a, --all Also remove one-off containers created by docker-compose run """ diff --git a/docs/reference/down.md b/docs/reference/down.md index e8b1db59..ffe88b4e 100644 --- a/docs/reference/down.md +++ b/docs/reference/down.md @@ -12,17 +12,27 @@ parent = "smn_compose_cli" # down ``` -Stop containers and remove containers, networks, volumes, and images -created by `up`. Only containers and networks are removed by default. - Usage: down [options] Options: - --rmi type Remove images, type may be one of: 'all' to remove - all images, or 'local' to remove only images that - don't have an custom name set by the `image` field - -v, --volumes Remove data volumes - + --rmi type Remove images. Type must be one of: + 'all': Remove all images used by any service. + 'local': Remove only images that don't have a custom tag + set by the `image` field. + -v, --volumes Remove named volumes declared in the `volumes` section + of the Compose file and anonymous volumes + attached to containers. --remove-orphans Remove containers for services not defined in the Compose file ``` + +Stops containers and removes containers, networks, volumes, and images +created by `up`. + +By default, the only things removed are: + +- Containers for services defined in the Compose file +- Networks defined in the `networks` section of the Compose file +- The default network, if one is used + +Networks and volumes defined as `external` are never removed. diff --git a/docs/reference/rm.md b/docs/reference/rm.md index 97698b58..8285a4ae 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -15,14 +15,15 @@ parent = "smn_compose_cli" Usage: rm [options] [SERVICE...] Options: --f, --force Don't ask to confirm removal --v Remove volumes associated with containers --a, --all Also remove one-off containers + -f, --force Don't ask to confirm removal + -v Remove any anonymous volumes attached to containers + -a, --all Also remove one-off containers created by + docker-compose run ``` Removes stopped service containers. -By default, volumes attached to containers will not be removed. You can see all -volumes with `docker volume ls`. +By default, anonymous volumes attached to containers will not be removed. You +can override this with `-v`. To list all volumes, use `docker volume ls`. Any data which is not in a volume will be lost. From 7cfb5e7bc9fb93549de0915f378d6cd831835d52 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 17:05:52 +0100 Subject: [PATCH 308/580] Fix race condition If processing of all objects finishes before the queue is drained, parallel_execute_iter() returns prematurely. Signed-off-by: Aanand Prasad --- compose/parallel.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/parallel.py b/compose/parallel.py index ee3d5777..63417dcb 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -17,6 +17,8 @@ from compose.utils import get_output_stream log = logging.getLogger(__name__) +STOP = object() + def parallel_execute(objects, func, get_name, msg, get_deps=None): """Runs func on objects in parallel while ensuring that func is @@ -108,7 +110,7 @@ def parallel_execute_iter(objects, func, get_deps): results = Queue() state = State(objects) - while not state.is_done(): + while True: feed_queue(objects, func, get_deps, results, state) try: @@ -119,6 +121,9 @@ def parallel_execute_iter(objects, func, get_deps): except thread.error: raise ShutdownException() + if event is STOP: + break + obj, _, exception = event if exception is None: log.debug('Finished processing: {}'.format(obj)) @@ -170,6 +175,9 @@ def feed_queue(objects, func, get_deps, results, state): t.start() state.started.add(obj) + if state.is_done(): + results.put(STOP) + class UpstreamError(Exception): pass From 7781f62ddf54fa635890c1772e1729ff5461fd55 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Apr 2016 12:03:16 +0100 Subject: [PATCH 309/580] Attempt to fix flaky logs test Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 13 +++++++------ tests/fixtures/logs-composefile/docker-compose.yml | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 707c2492..53ff66bb 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1257,13 +1257,14 @@ class CLITestCase(DockerClientTestCase): 'logscomposefile_another_1', 'exited')) - # sleep for a short period to allow the tailing thread to receive the - # event. This is not great, but there isn't an easy way to do this - # without being able to stream stdout from the process. - time.sleep(0.5) - os.kill(proc.pid, signal.SIGINT) - result = wait_on_process(proc, returncode=1) + self.dispatch(['kill', 'simple']) + + result = wait_on_process(proc) + + 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 def test_logs_default(self): self.base_dir = 'tests/fixtures/logs-composefile' diff --git a/tests/fixtures/logs-composefile/docker-compose.yml b/tests/fixtures/logs-composefile/docker-compose.yml index 0af9d805..b719c91e 100644 --- a/tests/fixtures/logs-composefile/docker-compose.yml +++ b/tests/fixtures/logs-composefile/docker-compose.yml @@ -1,6 +1,6 @@ simple: image: busybox:latest - command: sh -c "echo hello && sleep 200" + command: sh -c "echo hello && tail -f /dev/null" another: image: busybox:latest command: sh -c "echo test" From 276738f733c3512b939168c1475a6085a9482c6a Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Tue, 12 Apr 2016 11:47:15 -0400 Subject: [PATCH 310/580] Updated cli_test.py to validate against the updated help command conditions Signed-off-by: Tony Witherspoon --- tests/unit/cli_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 182e79ed..9700d592 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import os import shutil import tempfile +from StringIO import StringIO import docker import py @@ -82,6 +83,12 @@ class CLITestCase(unittest.TestCase): self.assertTrue(project.client) self.assertTrue(project.services) + def test_command_help(self): + with mock.patch('sys.stdout', new=StringIO()) as fake_stdout: + TopLevelCommand.help({'COMMAND': 'up'}) + + assert "Usage: up" in fake_stdout.getvalue() + def test_command_help_nonexistent(self): with pytest.raises(NoSuchCommand): TopLevelCommand.help({'COMMAND': 'nonexistent'}) From ae46bf8907aec818a07167598efef26a778dadaa Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Tue, 12 Apr 2016 12:29:59 -0400 Subject: [PATCH 311/580] Updated StringIO import to support io module Signed-off-by: Tony Witherspoon --- tests/unit/cli_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 9700d592..2c90b29b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import os import shutil import tempfile -from StringIO import StringIO +from io import StringIO import docker import py From 339ebc0483cfc2ec72efba884c0de84088c2f905 Mon Sep 17 00:00:00 2001 From: Danyal Prout Date: Sun, 10 Apr 2016 15:53:42 +0100 Subject: [PATCH 312/580] Fixes #2096: Only show multiple port clash warning if multiple containers are about to be started. Signed-off-by: Danyal Prout --- compose/service.py | 2 +- tests/unit/service_test.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index e0f23888..054082fc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -179,7 +179,7 @@ class Service(object): 'Remove the custom name to scale the service.' % (self.name, self.custom_container_name)) - if self.specifies_host_port(): + if self.specifies_host_port() and desired_num > 1: log.warn('The "%s" service specifies a port on the host. If multiple containers ' 'for this service are created on a single host, the port will clash.' % self.name) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index fe3794da..d3fcb49a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -642,6 +642,26 @@ class ServiceTest(unittest.TestCase): service = Service('foo', project='testing') assert service.image_name == 'testing_foo' + @mock.patch('compose.service.log', autospec=True) + def test_only_log_warning_when_host_ports_clash(self, mock_log): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + name = 'foo' + service = Service( + name, + client=self.mock_client, + ports=["8080:80"]) + + service.scale(0) + self.assertFalse(mock_log.warn.called) + + service.scale(1) + self.assertFalse(mock_log.warn.called) + + service.scale(2) + mock_log.warn.assert_called_once_with( + 'The "{}" service specifies a port on the host. If multiple containers ' + 'for this service are created on a single host, the port will clash.'.format(name)) + def sort_by_name(dictionary_list): return sorted(dictionary_list, key=lambda k: k['name']) From 50287722f2dd9df322122395e76e7778e185cdec Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Apr 2016 12:57:22 -0400 Subject: [PATCH 313/580] Update release notes and set version to 1.8.0dev Signed-off-by: Daniel Nephin --- CHANGELOG.md | 88 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b93087f..8ee45386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,94 @@ Change log ========== +1.7.0 (2016-04-13) +------------------ + +**Breaking Changes** + +- `docker-compose logs` no longer follows log output by default. It now + matches the behaviour of `docker logs` and exits after the current logs + are printed. Use `-f` to get the old default behaviour. + +- Booleans are no longer allows as values for mappings in the Compose file + (for keys `environment`, `labels` and `extra_hosts`). Previously this + was a warning. Boolean values should be quoted so they become string values. + +New Features + +- Compose now looks for a `.env` file in the directory where it's run and + reads any environment variables defined inside, if they're not already + set in the shell environment. This lets you easily set defaults for + variables used in the Compose file, or for any of the `COMPOSE_*` or + `DOCKER_*` variables. + +- Added a `--remove-orphans` flag to both `docker-compose up` and + `docker-compose down` to remove containers for services that were removed + from the Compose file. + +- Added a `--all` flag to `docker-compose rm` to include containers created + by `docker-compose run`. This will become the default behavior in the next + version of Compose. + +- Added support for all the same TLS configuration flags used by the `docker` + client: `--tls`, `--tlscert`, `--tlskey`, etc. + +- Compose files now support the `tmpfs` and `shm_size` options. + +- Added the `--workdir` flag to `docker-compose run` + +- `docker-compose logs` now shows logs for new containers that are created + after it starts. + +- The `COMPOSE_FILE` environment variable can now contain multiple files, + separated by the host system's standard path separator (`:` on Mac/Linux, + `;` on Windows). + +- You can now specify a static IP address when connecting a service to a + network with the `ipv4_address` and `ipv6_address` options. + +- Added `--follow`, `--timestamp`, and `--tail` flags to the + `docker-compose logs` command. + +- `docker-compose up`, and `docker-compose start` will now start containers + in parallel where possible. + +- `docker-compose stop` now stops containers in reverse dependency order + instead of all at once. + +- Added the `--build` flag to `docker-compose up` to force it to build a new + image. It now shows a warning if an image is automatically built when the + flag is not used. + +- Added the `docker-compose exec` command for executing a process in a running + container. + + +Bug Fixes + +- `docker-compose down` now removes containers created by + `docker-compose run`. + +- A more appropriate error is shown when a timeout is hit during `up` when + using a tty. + +- Fixed a bug in `docker-compose down` where it would abort if some resources + had already been removed. + +- Fixed a bug where changes to network aliases would not trigger a service + to be recreated. + +- Fix a bug where a log message was printed about creating a new volume + when it already existed. + +- Fixed a bug where interrupting `up` would not always shut down containers. + +- Fixed a bug where `log_opt` and `log_driver` were not properly carried over + when extending services in the v1 Compose file format. + +- Fixed a bug where empty values for build args would cause file validation + to fail. + 1.6.2 (2016-02-23) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index fedc90ff..1052c067 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.7.0dev' +__version__ = '1.8.0dev' diff --git a/script/run/run.sh b/script/run/run.sh index 212f9b97..98d32c5f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.6.2" +VERSION="1.7.0" IMAGE="docker/compose:$VERSION" From e71c62b8d1ce9202b3df6f156528c403e60efafe Mon Sep 17 00:00:00 2001 From: Callum Rogers Date: Thu, 14 Apr 2016 10:49:10 +0100 Subject: [PATCH 314/580] Readme should use new docker compose format instead of the old one Signed-off-by: Callum Rogers --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f8822151..93550f5a 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,17 @@ they can be run together in an isolated environment: A `docker-compose.yml` looks like this: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis - redis: - image: redis + version: '2' + + services: + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + redis: + image: redis For more information about the Compose file, see the [Compose file reference](https://github.com/docker/compose/blob/release/docs/compose-file.md) From abb5ae7fe4e3693b6099e52d43cf39e57c8e3e42 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Apr 2016 15:45:03 -0400 Subject: [PATCH 315/580] Only disconnect if we don't already have the short id alias. Signed-off-by: Daniel Nephin --- compose/service.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 054082fc..49eee104 100644 --- a/compose/service.py +++ b/compose/service.py @@ -453,20 +453,21 @@ class Service(object): connected_networks = container.get('NetworkSettings.Networks') for network, netdefs in self.networks.items(): - aliases = netdefs.get('aliases', []) - ipv4_address = netdefs.get('ipv4_address', None) - ipv6_address = netdefs.get('ipv6_address', None) if network in connected_networks: - self.client.disconnect_container_from_network( - container.id, network) + if short_id_alias_exists(container, network): + continue + self.client.disconnect_container_from_network( + container.id, + network) + + aliases = netdefs.get('aliases', []) self.client.connect_container_to_network( container.id, network, aliases=list(self._get_aliases(container).union(aliases)), - ipv4_address=ipv4_address, - ipv6_address=ipv6_address, - links=self._get_links(False) - ) + ipv4_address=netdefs.get('ipv4_address', None), + ipv6_address=netdefs.get('ipv6_address', None), + links=self._get_links(False)) def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): for c in self.duplicate_containers(): @@ -796,6 +797,12 @@ class Service(object): log.error(six.text_type(e)) +def short_id_alias_exists(container, network): + aliases = container.get( + 'NetworkSettings.Networks.{net}.Aliases'.format(net=network)) or () + return container.short_id in aliases + + class NetworkMode(object): """A `standard` network mode (ex: host, bridge)""" From e1356e1f6f6240a935c37617f787bded136a2049 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 11 Apr 2016 13:22:37 -0400 Subject: [PATCH 316/580] Set networking_config when creating a container. Signed-off-by: Daniel Nephin --- compose/service.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index 49eee104..e6ea9233 100644 --- a/compose/service.py +++ b/compose/service.py @@ -461,10 +461,9 @@ class Service(object): container.id, network) - aliases = netdefs.get('aliases', []) self.client.connect_container_to_network( container.id, network, - aliases=list(self._get_aliases(container).union(aliases)), + aliases=self._get_aliases(netdefs, container), ipv4_address=netdefs.get('ipv4_address', None), ipv6_address=netdefs.get('ipv6_address', None), links=self._get_links(False)) @@ -534,11 +533,32 @@ class Service(object): numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 - def _get_aliases(self, container): - if container.labels.get(LABEL_ONE_OFF) == "True": + def _get_aliases(self, network, container=None): + if container and container.labels.get(LABEL_ONE_OFF) == "True": return set() - return {self.name, container.short_id} + return list( + {self.name} | + ({container.short_id} if container else set()) | + set(network.get('aliases', ())) + ) + + def build_default_networking_config(self): + if not self.networks: + return {} + + network = self.networks[self.network_mode.id] + endpoint = { + 'Aliases': self._get_aliases(network), + 'IPAMConfig': {}, + } + + if network.get('ipv4_address'): + endpoint['IPAMConfig']['IPv4Address'] = network.get('ipv4_address') + if network.get('ipv6_address'): + endpoint['IPAMConfig']['IPv6Address'] = network.get('ipv6_address') + + return {"EndpointsConfig": {self.network_mode.id: endpoint}} def _get_links(self, link_to_self): links = {} @@ -634,6 +654,10 @@ class Service(object): override_options, one_off=one_off) + networking_config = self.build_default_networking_config() + if networking_config: + container_options['networking_config'] = networking_config + container_options['environment'] = format_environment( container_options['environment']) return container_options From ad306f047969a24ab9fd2d0cf1bdc5ccd01d1bc1 Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Fri, 15 Apr 2016 13:30:13 +0100 Subject: [PATCH 317/580] Fix CLI docstring to reflect Docopt behaviour. Signed-off-by: John Harris --- compose/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 839d97e8..29d808ce 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -142,7 +142,7 @@ class TopLevelCommand(object): """Define and run multi-container applications with Docker. Usage: - docker-compose [-f=...] [options] [COMMAND] [ARGS...] + docker-compose [-f ...] [options] [COMMAND] [ARGS...] docker-compose -h|--help Options: From 4702703615ad1876b7f20e577dbb9cde59d1e329 Mon Sep 17 00:00:00 2001 From: Vladimir Lagunov Date: Fri, 15 Apr 2016 15:11:50 +0300 Subject: [PATCH 318/580] Fix #3248: Accidental config_hash change Signed-off-by: Vladimir Lagunov --- compose/config/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index dc3f56ea..bd6e54fa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -726,7 +726,7 @@ class MergeDict(dict): merged = parse_sequence_func(self.base.get(field, [])) merged.update(parse_sequence_func(self.override.get(field, []))) - self[field] = [item.repr() for item in merged.values()] + self[field] = [item.repr() for item in sorted(merged.values())] def merge_scalar(self, field): if self.needs_merge(field): @@ -928,7 +928,7 @@ def dict_from_path_mappings(path_mappings): def path_mappings_from_dict(d): - return [join_path_mapping(v) for v in d.items()] + return [join_path_mapping(v) for v in sorted(d.items())] def split_path_mapping(volume_path): From 56c6e298199552f630432d6fefd770e35e5d7562 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Apr 2016 15:42:36 -0400 Subject: [PATCH 319/580] Unit test for skipping network disconnect. Signed-off-by: Daniel Nephin --- compose/service.py | 2 +- tests/integration/project_test.py | 20 ++++++++++++++++---- tests/unit/service_test.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index e6ea9233..8b9f64f0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -535,7 +535,7 @@ class Service(object): def _get_aliases(self, network, container=None): if container and container.labels.get(LABEL_ONE_OFF) == "True": - return set() + return [] return list( {self.name} | diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d1732d1e..c413b9aa 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,7 +565,11 @@ class ProjectTest(DockerClientTestCase): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', - 'networks': {'foo': None, 'bar': None, 'baz': None}, + 'networks': { + 'foo': None, + 'bar': None, + 'baz': {'aliases': ['extra']}, + }, }], volumes={}, networks={ @@ -581,15 +585,23 @@ class ProjectTest(DockerClientTestCase): config_data=config_data, ) project.up() - self.assertEqual(len(project.containers()), 1) + + containers = project.containers() + assert len(containers) == 1 + container, = containers for net_name in ['foo', 'bar', 'baz']: full_net_name = 'composetest_{}'.format(net_name) network_data = self.client.inspect_network(full_net_name) - self.assertEqual(network_data['Name'], full_net_name) + assert network_data['Name'] == full_net_name + + aliases_key = 'NetworkSettings.Networks.{net}.Aliases' + assert 'web' in container.get(aliases_key.format(net='composetest_foo')) + assert 'web' in container.get(aliases_key.format(net='composetest_baz')) + assert 'extra' in container.get(aliases_key.format(net='composetest_baz')) foo_data = self.client.inspect_network('composetest_foo') - self.assertEqual(foo_data['Driver'], 'bridge') + assert foo_data['Driver'] == 'bridge' @v2_only() def test_up_with_ipam_config(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d3fcb49a..a259c476 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -663,6 +663,35 @@ class ServiceTest(unittest.TestCase): 'for this service are created on a single host, the port will clash.'.format(name)) +class TestServiceNetwork(object): + + def test_connect_container_to_networks_short_aliase_exists(self): + mock_client = mock.create_autospec(docker.Client) + service = Service( + 'db', + mock_client, + 'myproject', + image='foo', + networks={'project_default': {}}) + container = Container( + None, + { + 'Id': 'abcdef', + 'NetworkSettings': { + 'Networks': { + 'project_default': { + 'Aliases': ['analias', 'abcdef'], + }, + }, + }, + }, + True) + service.connect_container_to_networks(container) + + assert not mock_client.disconnect_container_from_network.call_count + assert not mock_client.connect_container_to_network.call_count + + def sort_by_name(dictionary_list): return sorted(dictionary_list, key=lambda k: k['name']) From 68272b021639490929b0cdcca970ebd902ff5f09 Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Sun, 17 Apr 2016 14:00:07 -0400 Subject: [PATCH 320/580] Config now catches undefined service links Fixes issue #2922 Signed-off-by: John Harris --- compose/config/config.py | 2 ++ compose/config/validation.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index dc3f56ea..3f76277c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -37,6 +37,7 @@ from .validation import validate_against_config_schema from .validation import validate_config_section from .validation import validate_depends_on from .validation import validate_extends_file_path +from .validation import validate_links from .validation import validate_network_mode from .validation import validate_service_constraints from .validation import validate_top_level_object @@ -580,6 +581,7 @@ def validate_service(service_config, service_names, version): validate_ulimits(service_config) validate_network_mode(service_config, service_names) validate_depends_on(service_config, service_names) + validate_links(service_config, service_names) if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( diff --git a/compose/config/validation.py b/compose/config/validation.py index 088bec3f..e4b3a253 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -171,6 +171,14 @@ def validate_network_mode(service_config, service_names): "is undefined.".format(s=service_config, dep=dependency)) +def validate_links(service_config, service_names): + for dependency in service_config.config.get('links', []): + if dependency not in service_names: + raise ConfigurationError( + "Service '{s.name}' has a link to service '{dep}' which is " + "undefined.".format(s=service_config, dep=dependency)) + + def validate_depends_on(service_config, service_names): for dependency in service_config.config.get('depends_on', []): if dependency not in service_names: From 377be5aa1f097166df91c95670f871959654be3a Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Sun, 17 Apr 2016 14:01:06 -0400 Subject: [PATCH 321/580] Adding tests Signed-off-by: John Harris --- tests/unit/config/config_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2bbbe614..8bf41632 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1360,6 +1360,17 @@ class ConfigTest(unittest.TestCase): config.load(config_details) assert "Service 'one' depends on service 'three'" in exc.exconly() + def test_linked_service_is_undefined(self): + with self.assertRaises(ConfigurationError): + config.load( + build_config_details({ + 'version': '2', + 'services': { + 'web': {'image': 'busybox', 'links': ['db']}, + }, + }) + ) + def test_load_dockerfile_without_context(self): config_details = build_config_details({ 'version': '2', From 6d2805917c8e3f90b20781dfe35513d12e819533 Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Sun, 17 Apr 2016 15:25:06 -0400 Subject: [PATCH 322/580] Account for aliased links Fix failing tests Signed-off-by: John Harris --- compose/config/validation.py | 8 ++++---- tests/fixtures/extends/invalid-links.yml | 2 ++ tests/unit/config/config_test.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index e4b3a253..8c89cdf2 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -172,11 +172,11 @@ def validate_network_mode(service_config, service_names): def validate_links(service_config, service_names): - for dependency in service_config.config.get('links', []): - if dependency not in service_names: + for link in service_config.config.get('links', []): + if link.split(':')[0] not in service_names: raise ConfigurationError( - "Service '{s.name}' has a link to service '{dep}' which is " - "undefined.".format(s=service_config, dep=dependency)) + "Service '{s.name}' has a link to service '{link}' which is " + "undefined.".format(s=service_config, link=link)) def validate_depends_on(service_config, service_names): diff --git a/tests/fixtures/extends/invalid-links.yml b/tests/fixtures/extends/invalid-links.yml index edfeb8b2..cea740cb 100644 --- a/tests/fixtures/extends/invalid-links.yml +++ b/tests/fixtures/extends/invalid-links.yml @@ -1,3 +1,5 @@ +mydb: + build: '.' myweb: build: '.' extends: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8bf41632..48830558 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1366,7 +1366,7 @@ class ConfigTest(unittest.TestCase): build_config_details({ 'version': '2', 'services': { - 'web': {'image': 'busybox', 'links': ['db']}, + 'web': {'image': 'busybox', 'links': ['db:db']}, }, }) ) From ba10f1cd55adfbcd228df1b6e1044b5c87ac06c8 Mon Sep 17 00:00:00 2001 From: Patrice FERLET Date: Wed, 20 Apr 2016 13:23:37 +0200 Subject: [PATCH 323/580] Fix the tests from jenkins Acceptance tests didn't set "help" command to return "0" EXIT_CODE. close #3354 related #3263 Signed-off-by: Patrice Ferlet --- tests/acceptance/cli_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 53ff66bb..0b49efa0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -140,8 +140,8 @@ class CLITestCase(DockerClientTestCase): def test_help(self): self.base_dir = 'tests/fixtures/no-composefile' - result = self.dispatch(['help', 'up'], returncode=1) - assert 'Usage: up [options] [SERVICE...]' in result.stderr + result = self.dispatch(['help', 'up'], returncode=0) + assert 'Usage: up [options] [SERVICE...]' in result.stdout # Prevent tearDown from trying to create a project self.base_dir = None From 55fcd1c3e32ccbd71caa14462a6239d4bf7a1685 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 20 Apr 2016 15:58:12 -0700 Subject: [PATCH 324/580] Clarify service networks documentation When jumping straight to this bit of the docs, it's not clear that these are options under a service rather than the top-level `networks` key. Added a service to make this super clear. Signed-off-by: Ben Firshman --- docs/compose-file.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 5aef5aca..fc806a29 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -502,9 +502,11 @@ the special form `service:[service name]`. Networks to join, referencing entries under the [top-level `networks` key](#network-configuration-reference). - networks: - - some-network - - other-network + services: + some-service: + networks: + - some-network + - other-network #### aliases @@ -516,14 +518,16 @@ Since `aliases` is network-scoped, the same service can have different aliases o The general format is shown here. - networks: - some-network: - aliases: - - alias1 - - alias3 - other-network: - aliases: - - alias2 + services: + some-service: + networks: + some-network: + aliases: + - alias1 + - alias3 + other-network: + aliases: + - alias2 In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the `legacy` network. From 27628f8655824a0ba96ef552c1b182aa8f48fa7f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 20 Apr 2016 16:22:24 -0700 Subject: [PATCH 325/580] Make validation error less robotic "ERROR: Validation failed in file './docker-compose.yml', reason(s):" is now: "ERROR: The Compose file './docker-compose.yml' is invalid because:" Signed-off-by: Ben Firshman --- compose/config/validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 8c89cdf2..726750a3 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -416,6 +416,6 @@ def handle_errors(errors, format_error_func, filename): error_msg = '\n'.join(format_error_func(error) for error in errors) raise ConfigurationError( - "Validation failed{file_msg}, reason(s):\n{error_msg}".format( - file_msg=" in file '{}'".format(filename) if filename else "", + "The Compose file{file_msg} is invalid because:\n{error_msg}".format( + file_msg=" '{}'".format(filename) if filename else "", error_msg=error_msg)) From b67f110620bba758ae9b375b9f9743da317cfc45 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 20 Apr 2016 16:35:22 -0700 Subject: [PATCH 326/580] Explain the explanation about file versions This explanation looked like it was part of the error. Added an extra new line and a bit of copy to explain the explanation. Signed-off-by: Ben Firshman --- compose/config/errors.py | 9 +++++---- compose/config/validation.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/compose/config/errors.py b/compose/config/errors.py index d5df7ae5..d14cbbdd 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -3,10 +3,11 @@ from __future__ import unicode_literals VERSION_EXPLANATION = ( - 'Either specify a version of "2" (or "2.0") and place your service ' - 'definitions under the `services` key, or omit the `version` key and place ' - 'your service definitions at the root of the file to use version 1.\n' - 'For more on the Compose file format versions, see ' + 'You might be seeing this error because you\'re using the wrong Compose ' + 'file version. Either specify a version of "2" (or "2.0") and place your ' + 'service definitions under the `services` key, or omit the `version` key ' + 'and place your service definitions at the root of the file to use ' + 'version 1.\nFor more on the Compose file format versions, see ' 'https://docs.docker.com/compose/compose-file/') diff --git a/compose/config/validation.py b/compose/config/validation.py index 726750a3..7452e984 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -219,7 +219,7 @@ def handle_error_for_schema_with_id(error, path): return get_unsupported_config_msg(path, invalid_config_key) if not error.path: - return '{}\n{}'.format(error.message, VERSION_EXPLANATION) + return '{}\n\n{}'.format(error.message, VERSION_EXPLANATION) def handle_generic_error(error, path): From 75bcc382d9965208ecb1e8b7e6caa5cc08916cf6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 19 Apr 2016 17:39:29 -0700 Subject: [PATCH 327/580] Force docker-py 1.8.0 or above Signed-off-by: Joffrey F --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7caae97d..de009146 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py > 1.7.2, < 2', + 'docker-py >= 1.8.0, < 2', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 26fe8213aa3edcb6bfb8ec287538f9d8674ae124 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 26 Apr 2016 11:58:41 -0400 Subject: [PATCH 328/580] Upgade pip to latest Hopefully fixes our builds. Signed-off-by: Daniel Nephin --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index acf9b6ae..63fac3eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,11 +49,11 @@ RUN set -ex; \ # Install pip RUN set -ex; \ - curl -L https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz | tar -xz; \ - cd pip-7.0.1; \ + curl -L https://pypi.python.org/packages/source/p/pip/pip-8.1.1.tar.gz | tar -xz; \ + cd pip-8.1.1; \ python setup.py install; \ cd ..; \ - rm -rf pip-7.0.1 + rm -rf pip-8.1.1 # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen From a4d3dd6197b9e15cf993823d93d321778d2fdcd8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Mar 2016 13:00:55 +0000 Subject: [PATCH 329/580] Remove v2_only decorators on config tests Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 0b49efa0..4d1990be 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -145,15 +145,11 @@ class CLITestCase(DockerClientTestCase): # Prevent tearDown from trying to create a project self.base_dir = None - # TODO: this shouldn't be v2-dependent - @v2_only() def test_config_list_services(self): self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config', '--services']) assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'} - # TODO: this shouldn't be v2-dependent - @v2_only() def test_config_quiet_with_error(self): self.base_dir = None result = self.dispatch([ @@ -162,14 +158,10 @@ class CLITestCase(DockerClientTestCase): ], returncode=1) assert "'notaservice' must be a mapping" in result.stderr - # TODO: this shouldn't be v2-dependent - @v2_only() def test_config_quiet(self): self.base_dir = 'tests/fixtures/v2-full' assert self.dispatch(['config', '-q']).stdout == '' - # TODO: this shouldn't be v2-dependent - @v2_only() def test_config_default(self): self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config']) From 84a3e2fe79552ca94172bd3958776b01eed0e31e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Mar 2016 13:01:35 +0000 Subject: [PATCH 330/580] Check full error message in test_up_with_net_is_invalid Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 4d1990be..2a5a8604 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -675,9 +675,7 @@ class CLITestCase(DockerClientTestCase): ['-f', 'v2-invalid.yml', 'up', '-d'], returncode=1) - # TODO: fix validation error messages for v2 files - # assert "Unsupported config option for service 'web': 'net'" in exc.exconly() - assert "Unsupported config option" in result.stderr + assert "Unsupported config option for services.bar: 'net'" in result.stderr def test_up_with_net_v1(self): self.base_dir = 'tests/fixtures/net-container' From 6064d200f946c8d9738e1b73a03b1f78f947e2ef Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Mar 2016 13:14:33 +0000 Subject: [PATCH 331/580] Fix output of 'config' for v1 files Signed-off-by: Aanand Prasad --- compose/config/serialize.py | 14 ++++++++++-- tests/acceptance/cli_test.py | 25 +++++++++++++++++++++ tests/fixtures/v1-config/docker-compose.yml | 10 +++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/v1-config/docker-compose.yml diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 06e0a027..be6ba720 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -5,6 +5,8 @@ import six import yaml from compose.config import types +from compose.config.config import V1 +from compose.config.config import V2_0 def serialize_config_type(dumper, data): @@ -17,12 +19,20 @@ yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) def serialize_config(config): + services = {service.pop('name'): service for service in config.services} + + if config.version == V1: + for service_dict in services.values(): + if 'network_mode' not in service_dict: + service_dict['network_mode'] = 'bridge' + output = { - 'version': config.version, - 'services': {service.pop('name'): service for service in config.services}, + 'version': V2_0, + 'services': services, 'networks': config.networks, 'volumes': config.volumes, } + return yaml.safe_dump( output, default_flow_style=False, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2a5a8604..f7c958dd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -190,6 +190,31 @@ class CLITestCase(DockerClientTestCase): } assert output == expected + def test_config_v1(self): + self.base_dir = 'tests/fixtures/v1-config' + result = self.dispatch(['config']) + assert yaml.load(result.stdout) == { + 'version': '2.0', + 'services': { + 'net': { + 'image': 'busybox', + 'network_mode': 'bridge', + }, + 'volume': { + 'image': 'busybox', + 'volumes': ['/data:rw'], + 'network_mode': 'bridge', + }, + 'app': { + 'image': 'busybox', + 'volumes_from': ['service:volume:rw'], + 'network_mode': 'service:net', + }, + }, + 'networks': {}, + 'volumes': {}, + } + def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) diff --git a/tests/fixtures/v1-config/docker-compose.yml b/tests/fixtures/v1-config/docker-compose.yml new file mode 100644 index 00000000..8646c4ed --- /dev/null +++ b/tests/fixtures/v1-config/docker-compose.yml @@ -0,0 +1,10 @@ +net: + image: busybox +volume: + image: busybox + volumes: + - /data +app: + image: busybox + net: "container:net" + volumes_from: ["volume"] From 756ef14edc824ce2c52a2eb636c4884c95652e1e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Apr 2016 17:30:04 +0100 Subject: [PATCH 332/580] Fix format of 'restart' option in 'config' output Signed-off-by: Aanand Prasad --- compose/config/serialize.py | 26 +++++++++++++++++----- compose/config/types.py | 9 ++++++++ tests/acceptance/cli_test.py | 27 +++++++++++++++++++++++ tests/fixtures/restart/docker-compose.yml | 14 ++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/restart/docker-compose.yml diff --git a/compose/config/serialize.py b/compose/config/serialize.py index be6ba720..1b498c01 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -19,12 +19,14 @@ yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) def serialize_config(config): - services = {service.pop('name'): service for service in config.services} - - if config.version == V1: - for service_dict in services.values(): - if 'network_mode' not in service_dict: - service_dict['network_mode'] = 'bridge' + denormalized_services = [ + denormalize_service_dict(service_dict, config.version) + for service_dict in config.services + ] + services = { + service_dict.pop('name'): service_dict + for service_dict in denormalized_services + } output = { 'version': V2_0, @@ -38,3 +40,15 @@ def serialize_config(config): default_flow_style=False, indent=2, width=80) + + +def denormalize_service_dict(service_dict, version): + service_dict = service_dict.copy() + + if 'restart' in service_dict: + service_dict['restart'] = types.serialize_restart_spec(service_dict['restart']) + + if version == V1 and 'network_mode' not in service_dict: + service_dict['network_mode'] = 'bridge' + + return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index fc3347c8..e6a3dea0 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,6 +7,8 @@ from __future__ import unicode_literals import os from collections import namedtuple +import six + from compose.config.config import V1 from compose.config.errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM @@ -89,6 +91,13 @@ def parse_restart_spec(restart_config): return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} +def serialize_restart_spec(restart_spec): + parts = [restart_spec['Name']] + if restart_spec['MaximumRetryCount']: + parts.append(six.text_type(restart_spec['MaximumRetryCount'])) + return ':'.join(parts) + + def parse_extra_hosts(extra_hosts_config): if not extra_hosts_config: return {} diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f7c958dd..515acb04 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -190,6 +190,33 @@ class CLITestCase(DockerClientTestCase): } assert output == expected + def test_config_restart(self): + self.base_dir = 'tests/fixtures/restart' + result = self.dispatch(['config']) + assert yaml.load(result.stdout) == { + 'version': '2.0', + 'services': { + 'never': { + 'image': 'busybox', + 'restart': 'no', + }, + 'always': { + 'image': 'busybox', + 'restart': 'always', + }, + 'on-failure': { + 'image': 'busybox', + 'restart': 'on-failure', + }, + 'on-failure-5': { + 'image': 'busybox', + 'restart': 'on-failure:5', + }, + }, + 'networks': {}, + 'volumes': {}, + } + def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) diff --git a/tests/fixtures/restart/docker-compose.yml b/tests/fixtures/restart/docker-compose.yml new file mode 100644 index 00000000..2d10aa39 --- /dev/null +++ b/tests/fixtures/restart/docker-compose.yml @@ -0,0 +1,14 @@ +version: "2" +services: + never: + image: busybox + restart: "no" + always: + image: busybox + restart: always + on-failure: + image: busybox + restart: on-failure + on-failure-5: + image: busybox + restart: "on-failure:5" From d3e645488a87840d1fab9660b98c09d2a8ec676f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 25 Apr 2016 17:58:20 -0700 Subject: [PATCH 333/580] Define WindowsError on non-win32 platforms Signed-off-by: Joffrey F --- compose/cli/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index dd859edc..fff4a543 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -12,6 +12,13 @@ from six.moves import input import compose +# WindowsError is not defined on non-win32 platforms. Avoid runtime errors by +# defining it as OSError (its parent class) if missing. +try: + WindowsError +except NameError: + WindowsError = OSError + def yesno(prompt, default=None): """ From 87ee38ed2c8da3fdee816737b55d7c7eb6e36a26 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Thu, 28 Apr 2016 12:57:02 +0000 Subject: [PATCH 334/580] convert docs Dockerfiles to use docs/base:oss Signed-off-by: Sven Dowideit --- docs/Dockerfile | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index b16d0d2c..86ed32bc 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,18 +1,8 @@ -FROM docs/base:latest +FROM docs/base:oss MAINTAINER Mary Anthony (@moxiegirl) -RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/engine -RUN svn checkout https://github.com/docker/swarm/trunk/docs /docs/content/swarm -RUN svn checkout https://github.com/docker/machine/trunk/docs /docs/content/machine -RUN svn checkout https://github.com/docker/distribution/trunk/docs /docs/content/registry -RUN svn checkout https://github.com/docker/notary/trunk/docs /docs/content/notary -RUN svn checkout https://github.com/docker/kitematic/trunk/docs /docs/content/kitematic -RUN svn checkout https://github.com/docker/toolbox/trunk/docs /docs/content/toolbox -RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content/project - - ENV PROJECT=compose # To get the git info for this repo COPY . /src - +RUN rm -r /docs/content/$PROJECT/ COPY . /docs/content/$PROJECT/ From 2efcec776c430c527d61069f16bea298d9e4fb37 Mon Sep 17 00:00:00 2001 From: Aaron Nall Date: Wed, 27 Apr 2016 22:44:28 +0000 Subject: [PATCH 335/580] Add missing log event filter when using docker-compose logs. Signed-off-by: Aaron Nall --- compose/cli/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index ae787c9b..b86c34f8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -426,7 +426,8 @@ class TopLevelCommand(object): self.project, containers, options['--no-color'], - log_args).run() + log_args, + event_stream=self.project.events(service_names=options['SERVICE'])).run() def pause(self, options): """ From 0b24883cef6ad5737b949815e107a968e96c2a55 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Apr 2016 12:21:47 -0700 Subject: [PATCH 336/580] Support combination of shorthand flag and equal sign for host option Signed-off-by: Joffrey F --- compose/cli/command.py | 5 ++++- tests/acceptance/cli_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index b7160dee..8ac3aff4 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,12 +21,15 @@ log = logging.getLogger(__name__) def project_from_options(project_dir, options): environment = Environment.from_env_file(project_dir) + host = options.get('--host') + if host is not None: + host = host.lstrip('=') return get_project( project_dir, get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), - host=options.get('--host'), + host=host, tls_config=tls_config_from_options(options), environment=environment ) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 515acb04..a02d0e99 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -145,6 +145,13 @@ class CLITestCase(DockerClientTestCase): # Prevent tearDown from trying to create a project self.base_dir = None + def test_shorthand_host_opt(self): + self.dispatch( + ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), + 'up', '-d'], + returncode=0 + ) + def test_config_list_services(self): self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config', '--services']) From 84aa39e978c16877a64f1b097875667ff6eeef95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20R?= Date: Wed, 27 Apr 2016 13:45:59 +0200 Subject: [PATCH 337/580] Clarify env-file doc that .env is read from cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3381 Signed-off-by: André R --- docs/env-file.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/env-file.md b/docs/env-file.md index a285a790..be2625f8 100644 --- a/docs/env-file.md +++ b/docs/env-file.md @@ -13,8 +13,8 @@ weight=10 # Environment file Compose supports declaring default environment variables in an environment -file named `.env` and placed in the same folder as your -[compose file](compose-file.md). +file named `.env` placed in the folder `docker-compose` command is executed from +*(current working directory)*. Compose expects each line in an env file to be in `VAR=VAL` format. Lines beginning with `#` (i.e. comments) are ignored, as are blank lines. From e4bb678875adf1a5aa5fdc1fe542f00c4e279060 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Apr 2016 16:37:26 -0700 Subject: [PATCH 338/580] Require latest docker-py version Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b9b0f403..eb5275f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.8.0 +docker-py==1.8.1 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 diff --git a/setup.py b/setup.py index de009146..0b37c1dd 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.8.0, < 2', + 'docker-py >= 1.8.1, < 2', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From fe17e0f94835aab59f71f33e055f1c52847ce673 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Apr 2016 15:52:25 -0700 Subject: [PATCH 339/580] Skip event objects that don't contain a status field Signed-off-by: Joffrey F --- compose/project.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 0d891e45..64ca7be7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -342,7 +342,10 @@ class Project(object): filters={'label': self.labels()}, decode=True ): - if event['status'] in IMAGE_EVENTS: + # The first part of this condition is a guard against some events + # broadcasted by swarm that don't have a status field. + # See https://github.com/docker/compose/issues/3316 + if 'status' not in event or event['status'] in IMAGE_EVENTS: # We don't receive any image events because labels aren't applied # to images continue From 28fb91b34459dae8e0531370aa005d95321803f1 Mon Sep 17 00:00:00 2001 From: Thom Linton Date: Fri, 29 Apr 2016 16:31:19 -0700 Subject: [PATCH 340/580] Adds additional validation to 'env_vars_from_file'. The 'env_file' directive and feature precludes the use of the name '.env' in the path shared with 'docker-config.yml', regardless of whether or not it is enabled. This change adds an additional validation to allow the use of this path provided it is not a file. Signed-off-by: Thom Linton --- compose/config/environment.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/config/environment.py b/compose/config/environment.py index ad5c0b3d..ff08b771 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -28,6 +28,8 @@ def env_vars_from_file(filename): """ if not os.path.exists(filename): raise ConfigurationError("Couldn't find env file: %s" % filename) + elif not os.path.isfile(filename): + raise ConfigurationError("%s is not a file." % (filename)) env = {} for line in codecs.open(filename, 'r', 'utf-8'): line = line.strip() From 310b3d9441c8a63dc7f2685a1eb2d3e83e1584dc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Apr 2016 19:42:07 -0700 Subject: [PATCH 341/580] Properly handle APIError failures in Project.up Signed-off-by: Joffrey F --- compose/cli/main.py | 3 ++- compose/parallel.py | 2 +- compose/project.py | 11 ++++++++++- tests/integration/project_test.py | 4 +++- tests/unit/parallel_test.py | 3 ++- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index b86c34f8..34e7f35c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,6 +24,7 @@ from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError from ..project import NoSuchService from ..project import OneOffFilter +from ..project import ProjectError from ..service import BuildAction from ..service import BuildError from ..service import ConvergenceStrategy @@ -58,7 +59,7 @@ def main(): except (KeyboardInterrupt, signals.ShutdownException): log.error("Aborting.") sys.exit(1) - except (UserError, NoSuchService, ConfigurationError) as e: + except (UserError, NoSuchService, ConfigurationError, ProjectError) as e: log.error(e.msg) sys.exit(1) except BuildError as e: diff --git a/compose/parallel.py b/compose/parallel.py index 63417dcb..50b2dbea 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -59,7 +59,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): if error_to_reraise: raise error_to_reraise - return results + return results, errors def _no_deps(x): diff --git a/compose/project.py b/compose/project.py index 64ca7be7..d965c4a3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -390,13 +390,18 @@ class Project(object): def get_deps(service): return {self.get_service(dep) for dep in service.get_dependency_names()} - results = parallel.parallel_execute( + results, errors = parallel.parallel_execute( services, do, operator.attrgetter('name'), None, get_deps ) + if errors: + raise ProjectError( + 'Encountered errors while bringing up the project.' + ) + return [ container for svc_containers in results @@ -531,3 +536,7 @@ class NoSuchService(Exception): def __str__(self): return self.msg + + +class ProjectError(Exception): + pass diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index c413b9aa..7ef492a5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project +from compose.project import ProjectError from compose.service import ConvergenceStrategy from tests.integration.testcases import v2_only @@ -752,7 +753,8 @@ class ProjectTest(DockerClientTestCase): config_data=config_data, ) - assert len(project.up()) == 0 + with self.assertRaises(ProjectError): + project.up() @v2_only() def test_project_up_volumes(self): diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 45b0db1d..479c0f1d 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -29,7 +29,7 @@ def get_deps(obj): def test_parallel_execute(): - results = parallel_execute( + results, errors = parallel_execute( objects=[1, 2, 3, 4, 5], func=lambda x: x * 2, get_name=six.text_type, @@ -37,6 +37,7 @@ def test_parallel_execute(): ) assert sorted(results) == [2, 4, 6, 8, 10] + assert errors == {} def test_parallel_execute_with_deps(): From 3b7191f246b5f7cd6b2fbdefa86547492861f025 Mon Sep 17 00:00:00 2001 From: Garrett Seward Date: Wed, 4 May 2016 10:45:04 -0700 Subject: [PATCH 342/580] Small typo Signed-off-by: spectralsun --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index fc806a29..4902e8dd 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -1083,7 +1083,7 @@ It's more complicated if you're using particular configuration features: data: {} By default, Compose creates a volume whose name is prefixed with your - project name. If you want it to just be called `data`, declared it as + project name. If you want it to just be called `data`, declare it as external: volumes: From 4b01f6dcd657636a6d05f453dfd58a6d3826ca5e Mon Sep 17 00:00:00 2001 From: Anton Simernia Date: Mon, 9 May 2016 18:15:32 +0700 Subject: [PATCH 343/580] add msg attribute to ProjectError class Signed-off-by: Anton Simernia --- compose/project.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index d965c4a3..1b7fde23 100644 --- a/compose/project.py +++ b/compose/project.py @@ -539,4 +539,5 @@ class NoSuchService(Exception): class ProjectError(Exception): - pass + def __init__(self, msg): + self.msg = msg From 4bf5271ae2d53f8c6467642b6bd4c3372ed52da8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 May 2016 14:41:40 -0400 Subject: [PATCH 344/580] Skip invalid git tags in versions.py Signed-off-by: Daniel Nephin --- script/test/versions.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/script/test/versions.py b/script/test/versions.py index 98f97ef3..45ead143 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -28,6 +28,7 @@ from __future__ import unicode_literals import argparse import itertools import operator +import sys from collections import namedtuple import requests @@ -103,6 +104,14 @@ def get_default(versions): return version +def get_versions(tags): + for tag in tags: + try: + yield Version.parse(tag['name']) + except ValueError: + print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) + + def get_github_releases(project): """Query the Github API for a list of version tags and return them in sorted order. @@ -112,7 +121,7 @@ def get_github_releases(project): url = '{}/{}/tags'.format(GITHUB_API, project) response = requests.get(url) response.raise_for_status() - versions = [Version.parse(tag['name']) for tag in response.json()] + versions = get_versions(response.json()) return sorted(versions, reverse=True, key=operator.attrgetter('order')) From e5645595e3057f7b6eadcde922dd9ae7e0ff9363 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 May 2016 16:29:27 -0700 Subject: [PATCH 345/580] Fail gracefully when -d is not provided for exec command on Win32 Signed-off-by: Joffrey F --- compose/cli/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 34e7f35c..3ab2f965 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -334,6 +334,13 @@ class TopLevelCommand(object): """ index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) + detach = options['-d'] + + if IS_WINDOWS_PLATFORM and not detach: + raise UserError( + "Interactive mode is not yet supported on Windows.\n" + "Please pass the -d flag when using `docker-compose exec`." + ) try: container = service.get_container(number=index) except ValueError as e: @@ -350,7 +357,7 @@ class TopLevelCommand(object): exec_id = container.create_exec(command, **create_exec_options) - if options['-d']: + if detach: container.start_exec(exec_id, tty=tty) return From 844b7d463f63b4bd3915648b32432c9b3d0243c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 May 2016 14:59:33 -0700 Subject: [PATCH 346/580] Update rm command to always remove one-off containers. Signed-off-by: Joffrey F --- compose/cli/main.py | 12 +++++------- tests/acceptance/cli_test.py | 2 -- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 3ab2f965..afde7150 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -532,17 +532,15 @@ class TopLevelCommand(object): Options: -f, --force Don't ask to confirm removal -v Remove any anonymous volumes attached to containers - -a, --all Also remove one-off containers created by + -a, --all Obsolete. Also remove one-off containers created by docker-compose run """ if options.get('--all'): - one_off = OneOffFilter.include - else: log.warn( - 'Not including one-off containers created by `docker-compose run`.\n' - 'To include them, use `docker-compose rm --all`.\n' - 'This will be the default behavior in the next version of Compose.\n') - one_off = OneOffFilter.exclude + '--all flag is obsolete. This is now the default behavior ' + 'of `docker-compose rm`' + ) + one_off = OneOffFilter.include all_containers = self.project.containers( service_names=options['SERVICE'], stopped=True, one_off=one_off diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a02d0e99..dfd75625 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1192,8 +1192,6 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) - self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) - self.dispatch(['rm', '-f', '-a'], None) self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) service.create_container(one_off=False) From db0a6cf2bbcd7a5a673833b9558f0d142a0f304c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 12 May 2016 17:38:41 -0700 Subject: [PATCH 347/580] Always use the Windows version of splitdrive when parsing volume mappings Signed-off-by: Joffrey F --- compose/config/config.py | 3 ++- tests/unit/config/config_test.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index e52de4bf..6cfce5da 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import functools import logging +import ntpath import operator import os import string @@ -944,7 +945,7 @@ def split_path_mapping(volume_path): if volume_path.startswith('.') or volume_path.startswith('~'): drive, volume_config = '', volume_path else: - drive, volume_config = os.path.splitdrive(volume_path) + drive, volume_config = ntpath.splitdrive(volume_path) if ':' in volume_config: (host, container) = volume_config.split(':', 1) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 48830558..26a1e08a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2658,8 +2658,6 @@ class ExpandPathTest(unittest.TestCase): class VolumePathTest(unittest.TestCase): - - @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') def test_split_path_mapping_with_windows_path(self): host_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config" windows_volume_path = host_path + ":/opt/connect/config:ro" From 0c8aeb9e056caaa1fa2d1cc1133f6bd41505ec3c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 May 2016 07:41:02 -0700 Subject: [PATCH 348/580] Fix bug where confirmation prompt doesn't show due to line buffering Signed-off-by: Aanand Prasad --- compose/cli/utils.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index fff4a543..b58b50ef 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -6,9 +6,9 @@ import os import platform import ssl import subprocess +import sys import docker -from six.moves import input import compose @@ -42,6 +42,16 @@ def yesno(prompt, default=None): return None +def input(prompt): + """ + Version of input (raw_input in Python 2) which forces a flush of sys.stdout + to avoid problems where the prompt fails to appear due to line buffering + """ + sys.stdout.write(prompt) + sys.stdout.flush() + return sys.stdin.readline().rstrip(b'\n') + + def call_silently(*args, **kwargs): """ Like subprocess.call(), but redirects stdout and stderr to /dev/null. From 2b5b665d3ab47ab7d1bbe0049b02af126f2eaa63 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 May 2016 14:53:37 -0700 Subject: [PATCH 349/580] Add test for path mapping with Windows containers Signed-off-by: Joffrey F --- tests/unit/config/config_test.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 26a1e08a..ccb3bcfe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2664,7 +2664,15 @@ class VolumePathTest(unittest.TestCase): expected_mapping = ("/opt/connect/config:ro", host_path) mapping = config.split_path_mapping(windows_volume_path) - self.assertEqual(mapping, expected_mapping) + assert mapping == expected_mapping + + def test_split_path_mapping_with_windows_path_in_container(self): + host_path = 'c:\\Users\\remilia\\data' + container_path = 'c:\\scarletdevil\\data' + expected_mapping = (container_path, host_path) + + mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) + assert mapping == expected_mapping @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') From 33bed5c7066e53cb147afcbef2e9ab78cb0ab1f0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 May 2016 12:02:45 +0100 Subject: [PATCH 350/580] Use latest OpenSSL version (1.0.2h) when building Mac binary on Travis Signed-off-by: Aanand Prasad --- script/setup/osx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 10bbbecc..39941de2 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -14,9 +14,9 @@ desired_python_version="2.7.9" desired_python_brew_version="2.7.9" python_formula="https://raw.githubusercontent.com/Homebrew/homebrew/1681e193e4d91c9620c4901efd4458d9b6fcda8e/Library/Formula/python.rb" -desired_openssl_version="1.0.1j" -desired_openssl_brew_version="1.0.1j_1" -openssl_formula="https://raw.githubusercontent.com/Homebrew/homebrew/62fc2a1a65e83ba9dbb30b2e0a2b7355831c714b/Library/Formula/openssl.rb" +desired_openssl_version="1.0.2h" +desired_openssl_brew_version="1.0.2h" +openssl_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/30d3766453347f6e22b3ed6c74bb926d6def2eb5/Formula/openssl.rb" PATH="/usr/local/bin:$PATH" From 842e372258809b0be035a7857f8577e33850cca7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 May 2016 15:02:29 -0700 Subject: [PATCH 351/580] Eliminate duplicates when merging port mappings from config files Signed-off-by: Joffrey F --- compose/config/config.py | 6 +++++- tests/integration/project_test.py | 36 +++++++++++++++++++++++++++++++ tests/unit/config/config_test.py | 8 +++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 6cfce5da..e1466f06 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -744,6 +744,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('ulimits', parse_ulimits) md.merge_mapping('networks', parse_networks) md.merge_sequence('links', ServiceLink.parse) + md.merge_field('ports', merge_port_mappings, default=[]) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) @@ -752,7 +753,6 @@ def merge_service_dicts(base, override, version): 'depends_on', 'expose', 'external_links', - 'ports', 'volumes_from', ]: md.merge_field(field, operator.add, default=[]) @@ -771,6 +771,10 @@ def merge_service_dicts(base, override, version): return dict(md) +def merge_port_mappings(base, override): + return list(set().union(base, override)) + + def merge_build(output, base, override): def to_dict(service): build_config = service.get('build', {}) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 7ef492a5..6e82e931 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -834,6 +834,42 @@ class ProjectTest(DockerClientTestCase): self.assertTrue(log_config) self.assertEqual(log_config.get('Type'), 'none') + @v2_only() + def test_project_up_port_mappings_with_multiple_files(self): + base_file = config.ConfigFile( + 'base.yml', + { + 'version': V2_0, + 'services': { + 'simple': { + 'image': 'busybox:latest', + 'command': 'top', + 'ports': ['1234:1234'] + }, + }, + + }) + override_file = config.ConfigFile( + 'override.yml', + { + 'version': V2_0, + 'services': { + 'simple': { + 'ports': ['1234:1234'] + } + } + + }) + details = config.ConfigDetails('.', [base_file, override_file]) + + config_data = config.load(details) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + project.up() + containers = project.containers() + self.assertEqual(len(containers), 1) + @v2_only() def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ccb3bcfe..24ece499 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1912,6 +1912,14 @@ class MergePortsTest(unittest.TestCase, MergeListsTest): base_config = ['10:8000', '9000'] override_config = ['20:8000'] + def test_duplicate_port_mappings(self): + service_dict = config.merge_service_dicts( + {self.config_name: self.base_config}, + {self.config_name: self.base_config}, + DEFAULT_VERSION + ) + assert set(service_dict[self.config_name]) == set(self.base_config) + class MergeNetworksTest(unittest.TestCase, MergeListsTest): config_name = 'networks' From c4229b469a7fdf37b84fdd7b911508936f442363 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 May 2016 15:42:37 -0700 Subject: [PATCH 352/580] Improve merging for several service config attributes All uniqueItems lists in the config now receive the same treatment removing duplicates. Signed-off-by: Joffrey F --- compose/config/config.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index e1466f06..97c427b9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import functools import logging import ntpath -import operator import os import string import sys @@ -744,18 +743,15 @@ def merge_service_dicts(base, override, version): md.merge_mapping('ulimits', parse_ulimits) md.merge_mapping('networks', parse_networks) md.merge_sequence('links', ServiceLink.parse) - md.merge_field('ports', merge_port_mappings, default=[]) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) for field in [ - 'depends_on', - 'expose', - 'external_links', - 'volumes_from', + 'ports', 'cap_add', 'cap_drop', 'expose', 'external_links', + 'security_opt', 'volumes_from', 'depends_on', ]: - md.merge_field(field, operator.add, default=[]) + md.merge_field(field, merge_unique_items_lists, default=[]) for field in ['dns', 'dns_search', 'env_file', 'tmpfs']: md.merge_field(field, merge_list_or_string) @@ -771,8 +767,8 @@ def merge_service_dicts(base, override, version): return dict(md) -def merge_port_mappings(base, override): - return list(set().union(base, override)) +def merge_unique_items_lists(base, override): + return sorted(set().union(base, override)) def merge_build(output, base, override): From a34cd5ed543cbc98b703e83c41e13ea1757ad482 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 May 2016 17:26:42 +0100 Subject: [PATCH 353/580] Add "disambiguation" page for environment variables Signed-off-by: Aanand Prasad --- docs/environment-variables.md | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/environment-variables.md diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..a2e74f0a --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,107 @@ + + +# Environment variables in Compose + +There are multiple parts of Compose that deal with environment variables in one sense or another. This page should help you find the information you need. + + +## Substituting environment variables in Compose files + +It's possible to use environment variables in your shell to populate values inside a Compose file: + + web: + image: "webapp:${TAG}" + +For more information, see the [Variable substitution](compose-file.md#variable-substitution) section in the Compose file reference. + + +## Setting environment variables in containers + +You can set environment variables in a service's containers with the ['environment' key](compose-file.md#environment), just like with `docker run -e VARIABLE=VALUE ...`: + + web: + environment: + - DEBUG=1 + + +## Passing environment variables through to containers + +You can pass environment variables from your shell straight through to a service's containers with the ['environment' key](compose-file.md#environment) by not giving them a value, just like with `docker run -e VARIABLE ...`: + + web: + environment: + - DEBUG + +The value of the `DEBUG` variable in the container will be taken from the value for the same variable in the shell in which Compose is run. + + +## The “env_file” configuration option + +You can pass multiple environment variables from an external file through to a service's containers with the ['env_file' option](compose-file.md#env-file), just like with `docker run --env-file=FILE ...`: + + web: + env_file: + - web-variables.env + + +## Setting environment variables with 'docker-compose run' + +Just like with `docker run -e`, you can set environment variables on a one-off container with `docker-compose run -e`: + + $ docker-compose run -e DEBUG=1 web python console.py + +You can also pass a variable through from the shell by not giving it a value: + + $ docker-compose run -e DEBUG web python console.py + +The value of the `DEBUG` variable in the container will be taken from the value for the same variable in the shell in which Compose is run. + + +## The “.env” file + +You can set default values for any environment variables referenced in the Compose file, or used to configure Compose, in an [environment file](env-file.md) named `.env`: + + $ cat .env + TAG=v1.5 + + $ cat docker-compose.yml + version: '2.0' + services: + web: + image: "webapp:${TAG}" + +When you run `docker-compose up`, the `web` service defined above uses the image `webapp:v1.5`. You can verify this with the [config command](reference/config.md), which prints your resolved application config to the terminal: + + $ docker-compose config + version: '2.0' + services: + web: + image: 'webapp:v1.5' + +Values in the shell take precedence over those specified in the `.env` file. If you set `TAG` to a different value in your shell, the substitution in `image` uses that instead: + + $ export TAG=v2.0 + + $ docker-compose config + version: '2.0' + services: + web: + image: 'webapp:v2.0' + +## Configuring Compose using environment variables + +Several environment variables are available for you to configure the Docker Compose command-line behaviour. They begin with `COMPOSE_` or `DOCKER_`, and are documented in [CLI Environment Variables](reference/envvars.md). + + +## Environment variables created by links + +When using the ['links' option](compose-file.md#links) in a [v1 Compose file](compose-file.md#version-1), environment variables will be created for each link. They are documented in the [Link environment variables reference](link-env-deprecated.md). Please note, however, that these variables are deprecated - you should just use the link alias as a hostname instead. From c46737ed026055411e1249efc96053ee6acfe37a Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 26 May 2016 12:44:53 -0700 Subject: [PATCH 354/580] remove command completion for `docker-compose rm --a` As `--all|-a` is deprecated, there's no use to suggest it any more in command completion. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 66747fbd..763cafc4 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -325,7 +325,7 @@ _docker_compose_restart() { _docker_compose_rm() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--all -a --force -f --help -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force -f --help -v" -- "$cur" ) ) ;; *) __docker_compose_services_stopped diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index ec9cb682..0da217dc 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -281,7 +281,6 @@ __docker-compose_subcommand() { (rm) _arguments \ $opts_help \ - '(-a --all)'{-a,--all}"[Also remove one-off containers]" \ '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ '-v[Remove volumes associated with containers]' \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 From e3e8a619cce64a127df7d7962a2694116914b566 Mon Sep 17 00:00:00 2001 From: Andrey Devyatkin Date: Fri, 27 May 2016 07:48:13 +0200 Subject: [PATCH 355/580] Fix #3281: Unexpected result when using build args with default values Fix the issue when build arg is set to None instead of empty string. Usecase: cat docker-compose.yml .... args: - http_proxy - https_proxy - no_proxy If http_proxy, https_proxy, no_proxy environment variables are not defined then http_proxy, https_proxy, no_proxy build args will be set to string None which breaks all downloads With this change build args will not passed to docker engine if they are equal to string None Signed-off-by: Andrey Devyatkin --- compose/service.py | 8 +++++++- tests/unit/service_test.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 8b9f64f0..64a46453 100644 --- a/compose/service.py +++ b/compose/service.py @@ -701,6 +701,12 @@ class Service(object): build_opts = self.options.get('build', {}) path = build_opts.get('context') + # If build argument is not defined and there is no environment variable + # with the same name then build argument value will be None + # Moreover it will be sent to the docker engine as None and then + # interpreted as string None which in many cases will fail the build + # That is why we filter out all pairs with value equal to None + buildargs = {k: v for k, v in build_opts.get('args', {}).items() if v != 'None'} # python2 os.path() doesn't support unicode, so we need to encode it to # a byte string if not six.PY3: @@ -715,7 +721,7 @@ class Service(object): pull=pull, nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), - buildargs=build_opts.get('args', None), + buildargs=buildargs, ) try: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a259c476..ae2cab20 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -445,7 +445,7 @@ class ServiceTest(unittest.TestCase): forcerm=False, nocache=False, rm=True, - buildargs=None, + buildargs={}, ) def test_ensure_image_exists_no_build(self): @@ -481,7 +481,33 @@ class ServiceTest(unittest.TestCase): forcerm=False, nocache=False, rm=True, - buildargs=None, + buildargs={}, + ) + + def test_ensure_filter_out_empty_build_args(self): + args = {u'no_proxy': 'None', u'https_proxy': 'something'} + service = Service('foo', + client=self.mock_client, + build={'context': '.', 'args': args}) + self.mock_client.inspect_image.return_value = {'Id': 'abc123'} + self.mock_client.build.return_value = [ + '{"stream": "Successfully built abcd"}', + ] + + with mock.patch('compose.service.log', autospec=True) as mock_log: + 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, + stream=True, + path='.', + pull=False, + forcerm=False, + nocache=False, + rm=True, + buildargs={u'https_proxy': 'something'}, ) def test_build_does_not_pull(self): From 1298b9aa5d8d9f7b99c2f1130a3d3661bbda2c16 Mon Sep 17 00:00:00 2001 From: Denis Makogon Date: Tue, 24 May 2016 15:16:36 +0300 Subject: [PATCH 356/580] Issue-3503: Improve timestamp validation in tests CLITestCase.test_events_human_readable fails due to wrong assumption that host where tests were launched will have the same date time as Docker daemon. This fix introduces internal method for validating timestamp in Docker logs Signed-off-by: Denys Makogon --- tests/acceptance/cli_test.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dfd75625..4efaf0cf 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1473,6 +1473,17 @@ class CLITestCase(DockerClientTestCase): assert Counter(e['action'] for e in lines) == {'create': 2, 'start': 2} def test_events_human_readable(self): + + def has_timestamp(string): + str_iso_date, str_iso_time, container_info = string.split(' ', 2) + try: + return isinstance(datetime.datetime.strptime( + '%s %s' % (str_iso_date, str_iso_time), + '%Y-%m-%d %H:%M:%S.%f'), + datetime.datetime) + except ValueError: + return False + events_proc = start_process(self.base_dir, ['events']) self.dispatch(['up', '-d', 'simple']) wait_on_condition(ContainerCountCondition(self.project, 1)) @@ -1489,7 +1500,8 @@ class CLITestCase(DockerClientTestCase): assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] - assert lines[0].startswith(datetime.date.today().isoformat()) + + assert has_timestamp(lines[0]) def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') From c148849f0e219ff61a7a29164fd88c113faf7ef3 Mon Sep 17 00:00:00 2001 From: Andrey Devyatkin Date: Fri, 27 May 2016 19:59:27 +0200 Subject: [PATCH 357/580] Fix #3281: Unexpected result when using build args with default values Fix the issue when build arg is set to None instead of empty string. Usecase: cat docker-compose.yml .... args: - http_proxy - https_proxy - no_proxy If http_proxy, https_proxy, no_proxy environment variables are not defined then http_proxy, https_proxy, no_proxy build args will be set to string None which breaks all downloads With this change undefined build args will be set to empty string instead of string None Signed-off-by: Andrey Devyatkin --- compose/service.py | 8 +------- compose/utils.py | 2 +- tests/unit/config/config_test.py | 2 +- tests/unit/service_test.py | 30 ++---------------------------- 4 files changed, 5 insertions(+), 37 deletions(-) diff --git a/compose/service.py b/compose/service.py index 64a46453..8b9f64f0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -701,12 +701,6 @@ class Service(object): build_opts = self.options.get('build', {}) path = build_opts.get('context') - # If build argument is not defined and there is no environment variable - # with the same name then build argument value will be None - # Moreover it will be sent to the docker engine as None and then - # interpreted as string None which in many cases will fail the build - # That is why we filter out all pairs with value equal to None - buildargs = {k: v for k, v in build_opts.get('args', {}).items() if v != 'None'} # python2 os.path() doesn't support unicode, so we need to encode it to # a byte string if not six.PY3: @@ -721,7 +715,7 @@ class Service(object): pull=pull, nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), - buildargs=buildargs, + buildargs=build_opts.get('args', None), ) try: diff --git a/compose/utils.py b/compose/utils.py index 494beea3..1e01fcb6 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -95,4 +95,4 @@ def microseconds_from_time_nano(time_nano): def build_string_dict(source_dict): - return dict((k, str(v)) for k, v in source_dict.items()) + return dict((k, str(v if v else '')) for k, v in source_dict.items()) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 24ece499..3e5a7fac 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -715,7 +715,7 @@ class ConfigTest(unittest.TestCase): ).services[0] assert 'args' in service['build'] assert 'foo' in service['build']['args'] - assert service['build']['args']['foo'] == 'None' + assert service['build']['args']['foo'] == '' def test_load_with_multiple_files_mismatched_networks_format(self): base_file = config.ConfigFile( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index ae2cab20..a259c476 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -445,7 +445,7 @@ class ServiceTest(unittest.TestCase): forcerm=False, nocache=False, rm=True, - buildargs={}, + buildargs=None, ) def test_ensure_image_exists_no_build(self): @@ -481,33 +481,7 @@ class ServiceTest(unittest.TestCase): forcerm=False, nocache=False, rm=True, - buildargs={}, - ) - - def test_ensure_filter_out_empty_build_args(self): - args = {u'no_proxy': 'None', u'https_proxy': 'something'} - service = Service('foo', - client=self.mock_client, - build={'context': '.', 'args': args}) - self.mock_client.inspect_image.return_value = {'Id': 'abc123'} - self.mock_client.build.return_value = [ - '{"stream": "Successfully built abcd"}', - ] - - with mock.patch('compose.service.log', autospec=True) as mock_log: - 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, - stream=True, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs={u'https_proxy': 'something'}, + buildargs=None, ) def test_build_does_not_pull(self): From 90fba58df9caf98b3d1573dbeba34e8d7858d188 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Fri, 27 May 2016 21:29:47 +0000 Subject: [PATCH 358/580] Fix links Signed-off-by: Sven Dowideit --- docs/gettingstarted.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 60482bce..ff944177 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -137,8 +137,8 @@ The `redis` service uses the latest public [Redis](https://registry.hub.docker.c 2. Enter `http://0.0.0.0:5000/` in a browser to see the application running. If you're using Docker on Linux natively, then the web app should now be - listening on port 5000 on your Docker daemon host. If http://0.0.0.0:5000 - doesn't resolve, you can also try http://localhost:5000. + listening on port 5000 on your Docker daemon host. If `http://0.0.0.0:5000` + doesn't resolve, you can also try `http://localhost:5000`. If you're using Docker Machine on a Mac, use `docker-machine ip MACHINE_VM` to get the IP address of your Docker host. Then, `open http://MACHINE_VM_IP:5000` in a From a67ba5536db72203b22fc989b91f54f598e1d1f9 Mon Sep 17 00:00:00 2001 From: Andrey Devyatkin Date: Sat, 28 May 2016 11:39:41 +0200 Subject: [PATCH 359/580] Fix #3281: Unexpected result when using build args with default values Fix the issue when build arg is set to None instead of empty string. Usecase: cat docker-compose.yml .... args: - http_proxy - https_proxy - no_proxy If http_proxy, https_proxy, no_proxy environment variables are not defined then http_proxy, https_proxy, no_proxy build args will be set to string None which breaks all downloads With this change undefined build args will be set to empty string instead of string None Signed-off-by: Andrey Devyatkin --- compose/utils.py | 2 +- tests/unit/config/config_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index 1e01fcb6..925a8e79 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -95,4 +95,4 @@ def microseconds_from_time_nano(time_nano): def build_string_dict(source_dict): - return dict((k, str(v if v else '')) for k, v in source_dict.items()) + return dict((k, str(v if v is not None else '')) for k, v in source_dict.items()) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3e5a7fac..0abb8dae 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -717,6 +717,34 @@ class ConfigTest(unittest.TestCase): assert 'foo' in service['build']['args'] assert service['build']['args']['foo'] == '' + # If build argument is None then it will be converted to the empty + # string. Make sure that int zero kept as it is, i.e. not converted to + # the empty string + def test_build_args_check_zero_preserved(self): + service = config.load( + build_config_details( + { + 'version': '2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'dockerfile': 'Dockerfile-alt', + 'args': { + 'foo': 0 + } + } + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services[0] + assert 'args' in service['build'] + assert 'foo' in service['build']['args'] + assert service['build']['args']['foo'] == '0' + def test_load_with_multiple_files_mismatched_networks_format(self): base_file = config.ConfigFile( 'base.yaml', From dd3590180da36f5359d6463003b49ea2fca90315 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Tue, 31 May 2016 21:18:42 +0000 Subject: [PATCH 360/580] more fixes Signed-off-by: Sven Dowideit --- docs/Dockerfile | 4 ++-- docs/Makefile | 27 +++++---------------------- docs/django.md | 4 ++-- docs/gettingstarted.md | 2 +- docs/link-env-deprecated.md | 6 +++--- docs/overview.md | 4 ++-- docs/production.md | 4 ++-- docs/rails.md | 4 ++-- docs/swarm.md | 4 ++-- 9 files changed, 21 insertions(+), 38 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index 86ed32bc..7b5a3b24 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,8 +1,8 @@ FROM docs/base:oss -MAINTAINER Mary Anthony (@moxiegirl) +MAINTAINER Docker Docs ENV PROJECT=compose # To get the git info for this repo COPY . /src -RUN rm -r /docs/content/$PROJECT/ +RUN rm -rf /docs/content/$PROJECT/ COPY . /docs/content/$PROJECT/ diff --git a/docs/Makefile b/docs/Makefile index b9ef0548..e6629289 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,17 +1,4 @@ -.PHONY: all binary build cross default docs docs-build docs-shell shell test test-unit test-integration test-integration-cli test-docker-py validate - -# env vars passed through directly to Docker's build scripts -# to allow things like `make DOCKER_CLIENTONLY=1 binary` easily -# `docs/sources/contributing/devenvironment.md ` and `project/PACKAGERS.md` have some limited documentation of some of these -DOCKER_ENVS := \ - -e BUILDFLAGS \ - -e DOCKER_CLIENTONLY \ - -e DOCKER_EXECDRIVER \ - -e DOCKER_GRAPHDRIVER \ - -e TESTDIRS \ - -e TESTFLAGS \ - -e TIMEOUT -# note: we _cannot_ add "-e DOCKER_BUILDTAGS" here because even if it's unset in the shell, that would shadow the "ENV DOCKER_BUILDTAGS" set in our Dockerfile, which is very important for our official builds +.PHONY: all default docs docs-build docs-shell shell test # to allow `make DOCSDIR=1 docs-shell` (to create a bind mount in docs) DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR):/docs/content/compose) @@ -25,9 +12,8 @@ HUGO_BASE_URL=$(shell test -z "$(DOCKER_IP)" && echo localhost || echo "$(DOCKER HUGO_BIND_IP=0.0.0.0 GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) -DOCKER_IMAGE := docker$(if $(GIT_BRANCH),:$(GIT_BRANCH)) -DOCKER_DOCS_IMAGE := docs-base$(if $(GIT_BRANCH),:$(GIT_BRANCH)) - +GIT_BRANCH_CLEAN := $(shell echo $(GIT_BRANCH) | sed -e "s/[^[:alnum:]]/-/g") +DOCKER_DOCS_IMAGE := docker-docs$(if $(GIT_BRANCH_CLEAN),:$(GIT_BRANCH_CLEAN)) DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) -e AWS_S3_BUCKET -e NOCACHE @@ -42,14 +28,11 @@ docs: docs-build docs-draft: docs-build $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --buildDrafts="true" --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) - docs-shell: docs-build $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" bash +test: docs-build + $(DOCKER_RUN_DOCS) "$(DOCKER_DOCS_IMAGE)" docs-build: -# ( git remote | grep -v upstream ) || git diff --name-status upstream/release..upstream/docs ./ > ./changed-files -# echo "$(GIT_BRANCH)" > GIT_BRANCH -# echo "$(AWS_S3_BUCKET)" > AWS_S3_BUCKET -# echo "$(GITCOMMIT)" > GITCOMMIT docker build -t "$(DOCKER_DOCS_IMAGE)" . diff --git a/docs/django.md b/docs/django.md index 6a222697..b4bcee97 100644 --- a/docs/django.md +++ b/docs/django.md @@ -29,8 +29,8 @@ and a `docker-compose.yml` file. The Dockerfile defines an application's image content via one or more build commands that configure that image. Once built, you can run the image in a container. For more information on `Dockerfiles`, see the [Docker user - guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) - and the [Dockerfile reference](https://docs.docker.com/engine/reference/builder/). + guide](/engine/userguide/containers/dockerimages.md#building-an-image-from-a-dockerfile) + and the [Dockerfile reference](/engine/reference/builder.md). 3. Add the following content to the `Dockerfile`. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index ff944177..8c706e4f 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -77,7 +77,7 @@ dependencies the Python application requires, including Python itself. * Install the Python dependencies. * Set the default command for the container to `python app.py` - For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + For more information on how to write Dockerfiles, see the [Docker user guide](/engine/userguide/containers/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). 2. Build the image. diff --git a/docs/link-env-deprecated.md b/docs/link-env-deprecated.md index 55ba5f2d..b1f01b3b 100644 --- a/docs/link-env-deprecated.md +++ b/docs/link-env-deprecated.md @@ -16,7 +16,9 @@ weight=89 > > Environment variables will only be populated if you're using the [legacy version 1 Compose file format](compose-file.md#versioning). -Compose uses [Docker links] to expose services' containers to one another. Each linked container injects a set of environment variables, each of which begins with the uppercase name of the container. +Compose uses [Docker links](/engine/userguide/networking/default_network/dockerlinks.md) +to expose services' containers to one another. Each linked container injects a set of +environment variables, each of which begins with the uppercase name of the container. To see what environment variables are available to a service, run `docker-compose run SERVICE env`. @@ -38,8 +40,6 @@ Protocol (tcp or udp), e.g. `DB_PORT_5432_TCP_PROTO=tcp` name\_NAME
Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` -[Docker links]: https://docs.docker.com/engine/userguide/networking/default_network/dockerlinks/ - ## Related Information - [User guide](index.md) diff --git a/docs/overview.md b/docs/overview.md index 03ade356..ef07a45b 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -159,8 +159,8 @@ and destroy isolated testing environments for your test suite. By defining the f Compose has traditionally been focused on development and testing workflows, but with each release we're making progress on more production-oriented features. You can use Compose to deploy to a remote Docker Engine. The Docker Engine may be a single instance provisioned with -[Docker Machine](https://docs.docker.com/machine/) or an entire -[Docker Swarm](https://docs.docker.com/swarm/) cluster. +[Docker Machine](/machine/overview.md) or an entire +[Docker Swarm](/swarm/overview.md) cluster. For details on using production-oriented features, see [compose in production](production.md) in this documentation. diff --git a/docs/production.md b/docs/production.md index 9acf64e5..cfb87293 100644 --- a/docs/production.md +++ b/docs/production.md @@ -65,7 +65,7 @@ recreating any services which `web` depends on. You can use Compose to deploy an app to a remote Docker host by setting the `DOCKER_HOST`, `DOCKER_TLS_VERIFY`, and `DOCKER_CERT_PATH` environment variables appropriately. For tasks like this, -[Docker Machine](/machine/overview) makes managing local and +[Docker Machine](/machine/overview.md) makes managing local and remote Docker hosts very easy, and is recommended even if you're not deploying remotely. @@ -74,7 +74,7 @@ commands will work with no further configuration. ### Running Compose on a Swarm cluster -[Docker Swarm](/swarm/overview), a Docker-native clustering +[Docker Swarm](/swarm/overview.md), a Docker-native clustering system, exposes the same API as a single Docker host, which means you can use Compose against a Swarm instance and run your apps across multiple hosts. diff --git a/docs/rails.md b/docs/rails.md index eef6b2f4..f54d8286 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -32,7 +32,7 @@ Dockerfile consists of: That'll put your application code inside an image that will build a container with Ruby, Bundler and all your dependencies inside it. For more information on -how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](https://docs.docker.com/engine/reference/builder/). +how to write Dockerfiles, see the [Docker user guide](/engine/userguide/containers/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. @@ -152,7 +152,7 @@ Finally, you need to create the database. In another terminal, run: $ docker-compose run web rake db:create -That's it. Your app should now be running on port 3000 on your Docker daemon. If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` returns the Docker host IP address. +That's it. Your app should now be running on port 3000 on your Docker daemon. If you're using [Docker Machine](/machine/overview.md), then `docker-machine ip MACHINE_VM` returns the Docker host IP address. ![Rails example](images/rails-welcome.png) diff --git a/docs/swarm.md b/docs/swarm.md index ece72193..bbab6908 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -11,7 +11,7 @@ parent="workw_compose" # Using Compose with Swarm -Docker Compose and [Docker Swarm](/swarm/overview) aim to have full integration, meaning +Docker Compose and [Docker Swarm](/swarm/overview.md) aim to have full integration, meaning you can point a Compose app at a Swarm cluster and have it all just work as if you were using a single Docker host. @@ -30,7 +30,7 @@ format](compose-file.md#versioning) you are using: or a custom driver which supports multi-host networking. Read [Get started with multi-host networking](https://docs.docker.com/engine/userguide/networking/get-started-overlay/) to see how to -set up a Swarm cluster with [Docker Machine](/machine/overview) and the overlay driver. Once you've got it running, deploying your app to it should be as simple as: +set up a Swarm cluster with [Docker Machine](/machine/overview.md) and the overlay driver. Once you've got it running, deploying your app to it should be as simple as: $ eval "$(docker-machine env --swarm )" $ docker-compose up From ea640f38217e5d3796bbca49a5a1870582139d8d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 May 2016 16:32:54 -0700 Subject: [PATCH 361/580] Remove external_name from serialized config output Signed-off-by: Joffrey F --- compose/config/serialize.py | 6 +++++- tests/acceptance/cli_test.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 1b498c01..52de77b8 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -27,11 +27,15 @@ def serialize_config(config): service_dict.pop('name'): service_dict for service_dict in denormalized_services } + networks = config.networks.copy() + for net_name, net_conf in networks.items(): + if 'external_name' in net_conf: + del net_conf['external_name'] output = { 'version': V2_0, 'services': services, - 'networks': config.networks, + 'networks': networks, 'volumes': config.volumes, } diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a02d0e99..6bb111ef 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -224,6 +224,20 @@ class CLITestCase(DockerClientTestCase): 'volumes': {}, } + def test_config_external_network(self): + self.base_dir = 'tests/fixtures/networks' + result = self.dispatch(['-f', 'external-networks.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'networks' in json_result + assert json_result['networks'] == { + 'networks_foo': { + 'external': True # {'name': 'networks_foo'} + }, + 'bar': { + 'external': {'name': 'networks_bar'} + } + } + def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) From 9ddb7f3c90b64ab801f770b54eab5be569d142f6 Mon Sep 17 00:00:00 2001 From: Adam Chainz Date: Wed, 8 Jun 2016 03:51:03 +0100 Subject: [PATCH 362/580] Convert readthedocs links for their .org -> .io migration for hosted projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As per [their blog post of the 27th April](https://blog.readthedocs.com/securing-subdomains/) ‘Securing subdomains’: > Starting today, Read the Docs will start hosting projects from subdomains on the domain readthedocs.io, instead of on readthedocs.org. This change addresses some security concerns around site cookies while hosting user generated data on the same domain as our dashboard. Test Plan: Manually visited all the links I’ve modified. Signed-off-by: Adam Chainz --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50e58ddc..16bccf98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ that should get you started. This step is optional, but recommended. Pre-commit hooks will run style checks and in some cases fix style issues for you, when you commit code. -Install the git pre-commit hooks using [tox](https://tox.readthedocs.org) by +Install the git pre-commit hooks using [tox](https://tox.readthedocs.io) by running `tox -e pre-commit` or by following the [pre-commit install guide](http://pre-commit.com/#install). From 0287486b14b7b75d0544a029f188a21e972cc8ea Mon Sep 17 00:00:00 2001 From: David Beitey Date: Thu, 9 Jun 2016 16:58:34 +1000 Subject: [PATCH 363/580] Fix minor YAML typo Signed-off-by: David Beitey --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index b55f250a..501269b7 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -796,7 +796,7 @@ called `data` and mount it into the `db` service's containers. You can also specify the name of the volume separately from the name used to refer to it within the Compose file: - volumes + volumes: data: external: name: actual-name-of-volume From 61324ef30839bdcf99e20e0de2a4bb029e189166 Mon Sep 17 00:00:00 2001 From: Sander Maijers Date: Fri, 10 Jun 2016 16:30:46 +0200 Subject: [PATCH 364/580] Fix byte/str typing error Signed-off-by: Sander Maijers --- compose/cli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index b58b50ef..cc2b680d 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -49,7 +49,7 @@ def input(prompt): """ sys.stdout.write(prompt) sys.stdout.flush() - return sys.stdin.readline().rstrip(b'\n') + return sys.stdin.readline().rstrip('\n') def call_silently(*args, **kwargs): From 60f7e021ada69b4bdfce397eb2153c6c35eb2428 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 13 Jun 2016 15:32:10 -0700 Subject: [PATCH 365/580] Fix split_path_mapping behavior when mounting "/" Signed-off-by: Joffrey F --- compose/config/config.py | 7 ++++--- tests/unit/config/config_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 97c427b9..7a2b3d36 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -940,9 +940,10 @@ def split_path_mapping(volume_path): path. Using splitdrive so windows absolute paths won't cause issues with splitting on ':'. """ - # splitdrive has limitations when it comes to relative paths, so when it's - # relative, handle special case to set the drive to '' - if volume_path.startswith('.') or volume_path.startswith('~'): + # splitdrive is very naive, so handle special cases where we can be sure + # the first character is not a drive. + if (volume_path.startswith('.') or volume_path.startswith('~') or + volume_path.startswith('/')): drive, volume_config = '', volume_path else: drive, volume_config = ntpath.splitdrive(volume_path) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 24ece499..89c424a4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2682,6 +2682,13 @@ class VolumePathTest(unittest.TestCase): mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping + def test_split_path_mapping_with_root_mount(self): + host_path = '/' + container_path = '/var/hostroot' + expected_mapping = (container_path, host_path) + mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) + assert mapping == expected_mapping + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class BuildPathTest(unittest.TestCase): From 68d73183ebcc9db5676d15cdf61ee1494242d099 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 13 Jun 2016 23:36:34 -0400 Subject: [PATCH 366/580] Fix a typo in a test's name. --- tests/integration/service_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index df50d513..1b21e1e9 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -593,12 +593,12 @@ class ServiceTest(DockerClientTestCase): service.build() assert service.image() - def test_start_container_stays_unpriviliged(self): + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], False) - def test_start_container_becomes_priviliged(self): + def test_start_container_becomes_privileged(self): service = self.create_service('web', privileged=True) container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], True) From 1ea9dda1d3b1db1d2bcb248b4e4eb57a26a06fd4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 10 May 2016 16:14:54 +0100 Subject: [PATCH 367/580] Implement 'docker-compose push' and 'docker-compose bundle' Signed-off-by: Aanand Prasad --- .dockerignore | 1 + compose/bundle.py | 186 ++++++++++++++++++++++++++++++++++++ compose/cli/command.py | 10 ++ compose/cli/main.py | 60 +++++++++--- compose/config/serialize.py | 8 +- compose/progress_stream.py | 19 ++++ compose/project.py | 4 + compose/service.py | 28 ++++-- 8 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 compose/bundle.py diff --git a/.dockerignore b/.dockerignore index 055ae7ed..e79da862 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ coverage-html docs/_site venv .tox +dist diff --git a/compose/bundle.py b/compose/bundle.py new file mode 100644 index 00000000..a6d0d2d1 --- /dev/null +++ b/compose/bundle.py @@ -0,0 +1,186 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import json +import logging + +import six +from docker.utils.ports import split_port + +from .cli.errors import UserError +from .config.serialize import denormalize_config +from .network import get_network_defs_for_service +from .service import NoSuchImageError +from .service import parse_repository_tag + + +log = logging.getLogger(__name__) + + +SERVICE_KEYS = { + 'command': 'Command', + 'environment': 'Env', + 'working_dir': 'WorkingDir', +} + + +VERSION = '0.1' + + +def serialize_bundle(config, image_digests): + if config.networks: + log.warn("Unsupported top level key 'networks' - ignoring") + + if config.volumes: + log.warn("Unsupported top level key 'volumes' - ignoring") + + return json.dumps( + to_bundle(config, image_digests), + indent=2, + sort_keys=True, + ) + + +def get_image_digests(project): + return { + service.name: get_image_digest(service) + for service in project.services + } + + +def get_image_digest(service): + if 'image' not in service.options: + raise UserError( + "Service '{s.name}' doesn't define an image tag. An image name is " + "required to generate a proper image digest for the bundle. Specify " + "an image repo and tag with the 'image' option.".format(s=service)) + + repo, tag, separator = parse_repository_tag(service.options['image']) + # Compose file already uses a digest, no lookup required + if separator == '@': + return service.options['image'] + + try: + image = service.image() + except NoSuchImageError: + action = 'build' if 'build' in service.options else 'pull' + raise UserError( + "Image not found for service '{service}'. " + "You might need to run `docker-compose {action} {service}`." + .format(service=service.name, action=action)) + + if image['RepoDigests']: + # TODO: pick a digest based on the image tag if there are multiple + # digests + return image['RepoDigests'][0] + + if 'build' not in service.options: + log.warn( + "Compose needs to pull the image for '{s.name}' in order to create " + "a bundle. This may result in a more recent image being used. " + "It is recommended that you use an image tagged with a " + "specific version to minimize the potential " + "differences.".format(s=service)) + digest = service.pull() + else: + try: + digest = service.push() + except: + log.error( + "Failed to push image for service '{s.name}'. Please use an " + "image tag that can be pushed to a Docker " + "registry.".format(s=service)) + raise + + if not digest: + raise ValueError("Failed to get digest for %s" % service.name) + + identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) + + # Pull by digest so that image['RepoDigests'] is populated for next time + # and we don't have to pull/push again + service.client.pull(identifier) + + return identifier + + +def to_bundle(config, image_digests): + config = denormalize_config(config) + + return { + 'version': VERSION, + 'services': { + name: convert_service_to_bundle( + name, + service_dict, + image_digests[name], + ) + for name, service_dict in config['services'].items() + }, + } + + +def convert_service_to_bundle(name, service_dict, image_id): + container_config = {'Image': image_id} + + for key, value in service_dict.items(): + if key in ('build', 'image', 'ports', 'expose', 'networks'): + pass + elif key == 'environment': + container_config['env'] = { + envkey: envvalue for envkey, envvalue in value.items() + if envvalue + } + elif key in SERVICE_KEYS: + container_config[SERVICE_KEYS[key]] = value + else: + log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) + + container_config['Networks'] = make_service_networks(name, service_dict) + + ports = make_port_specs(service_dict) + if ports: + container_config['Ports'] = ports + + return container_config + + +def make_service_networks(name, service_dict): + networks = [] + + for network_name, network_def in get_network_defs_for_service(service_dict).items(): + for key in network_def.keys(): + log.warn( + "Unsupported key '{}' in services.{}.networks.{} - ignoring" + .format(key, name, network_name)) + + networks.append(network_name) + + return networks + + +def make_port_specs(service_dict): + ports = [] + + internal_ports = [ + internal_port + for port_def in service_dict.get('ports', []) + for internal_port in split_port(port_def)[0] + ] + + internal_ports += service_dict.get('expose', []) + + for internal_port in internal_ports: + spec = make_port_spec(internal_port) + if spec not in ports: + ports.append(spec) + + return ports + + +def make_port_spec(value): + components = six.text_type(value).partition('/') + return { + 'Protocol': components[2] or 'tcp', + 'Port': int(components[0]), + } diff --git a/compose/cli/command.py b/compose/cli/command.py index 8ac3aff4..44112fce 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -35,6 +35,16 @@ def project_from_options(project_dir, options): ) +def get_config_from_options(base_dir, options): + environment = Environment.from_env_file(base_dir) + config_path = get_config_path_from_options( + base_dir, options, environment + ) + return config.load( + config.find(base_dir, config_path, environment) + ) + + def get_config_path_from_options(base_dir, options, environment): file_option = options.get('--file') if file_option: diff --git a/compose/cli/main.py b/compose/cli/main.py index afde7150..3e440463 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,10 +14,10 @@ from operator import attrgetter from . import errors from . import signals from .. import __version__ -from ..config import config +from ..bundle import get_image_digests +from ..bundle import serialize_bundle from ..config import ConfigurationError from ..config import parse_environment -from ..config.environment import Environment from ..config.serialize import serialize_config from ..const import DEFAULT_TIMEOUT from ..const import IS_WINDOWS_PLATFORM @@ -30,7 +30,7 @@ from ..service import BuildError from ..service import ConvergenceStrategy from ..service import ImageType from ..service import NeedsBuildError -from .command import get_config_path_from_options +from .command import get_config_from_options from .command import project_from_options from .docopt_command import DocoptDispatcher from .docopt_command import get_handler @@ -98,7 +98,7 @@ def perform_command(options, handler, command_options): handler(command_options) return - if options['COMMAND'] == 'config': + if options['COMMAND'] in ('config', 'bundle'): command = TopLevelCommand(None) handler(command, options, command_options) return @@ -164,6 +164,7 @@ class TopLevelCommand(object): Commands: build Build or rebuild services + bundle Generate a Docker bundle from the Compose file config Validate and view the compose file create Create services down Stop and remove containers, networks, images, and volumes @@ -176,6 +177,7 @@ class TopLevelCommand(object): port Print the public port for a port binding ps List containers pull Pulls service images + push Push service images restart Restart services rm Remove stopped containers run Run a one-off command @@ -212,6 +214,34 @@ class TopLevelCommand(object): pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False))) + def bundle(self, config_options, options): + """ + Generate a Docker bundle from the Compose file. + + Local images will be pushed to a Docker registry, and remote images + will be pulled to fetch an image digest. + + Usage: bundle [options] + + Options: + -o, --output PATH Path to write the bundle file to. + Defaults to ".dsb". + """ + self.project = project_from_options('.', config_options) + compose_config = get_config_from_options(self.project_dir, config_options) + + output = options["--output"] + if not output: + output = "{}.dsb".format(self.project.name) + + with errors.handle_connection_errors(self.project.client): + image_digests = get_image_digests(self.project) + + with open(output, 'w') as f: + f.write(serialize_bundle(compose_config, image_digests)) + + log.info("Wrote bundle to {}".format(output)) + def config(self, config_options, options): """ Validate and view the compose file. @@ -224,13 +254,7 @@ class TopLevelCommand(object): --services Print the service names, one per line. """ - environment = Environment.from_env_file(self.project_dir) - config_path = get_config_path_from_options( - self.project_dir, config_options, environment - ) - compose_config = config.load( - config.find(self.project_dir, config_path, environment) - ) + compose_config = get_config_from_options(self.project_dir, config_options) if options['--quiet']: return @@ -518,6 +542,20 @@ class TopLevelCommand(object): ignore_pull_failures=options.get('--ignore-pull-failures') ) + def push(self, options): + """ + Pushes images for services. + + Usage: push [options] [SERVICE...] + + Options: + --ignore-push-failures Push what it can and ignores images with push failures. + """ + self.project.push( + service_names=options['SERVICE'], + ignore_push_failures=options.get('--ignore-push-failures') + ) + def rm(self, options): """ Removes stopped service containers. diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 52de77b8..b788a55d 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -18,7 +18,7 @@ yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) -def serialize_config(config): +def denormalize_config(config): denormalized_services = [ denormalize_service_dict(service_dict, config.version) for service_dict in config.services @@ -32,15 +32,17 @@ def serialize_config(config): if 'external_name' in net_conf: del net_conf['external_name'] - output = { + return { 'version': V2_0, 'services': services, 'networks': networks, 'volumes': config.volumes, } + +def serialize_config(config): return yaml.safe_dump( - output, + denormalize_config(config), default_flow_style=False, indent=2, width=80) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 1f873d1d..a0f5601f 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -91,3 +91,22 @@ def print_output_event(event, stream, is_terminal): stream.write("%s%s" % (event['stream'], terminator)) else: stream.write("%s%s\n" % (status, terminator)) + + +def get_digest_from_pull(events): + for event in events: + status = event.get('status') + if not status or 'Digest' not in status: + continue + + _, digest = status.split(':', 1) + return digest.strip() + return None + + +def get_digest_from_push(events): + for event in events: + digest = event.get('aux', {}).get('Digest') + if digest: + return digest + return None diff --git a/compose/project.py b/compose/project.py index 1b7fde23..676b6ae8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -440,6 +440,10 @@ class Project(object): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) + def push(self, service_names=None, ignore_push_failures=False): + for service in self.get_services(service_names, include_deps=False): + service.push(ignore_push_failures) + def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude): return list(filter(None, [ Container.from_ps(self.client, container) diff --git a/compose/service.py b/compose/service.py index 8b9f64f0..af572e5b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -15,6 +15,7 @@ from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from . import __version__ +from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment from .config.types import VolumeSpec @@ -806,20 +807,35 @@ class Service(object): repo, tag, separator = parse_repository_tag(self.options['image']) tag = tag or 'latest' log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) - output = self.client.pull( - repo, - tag=tag, - stream=True, - ) + output = self.client.pull(repo, tag=tag, stream=True) try: - stream_output(output, sys.stdout) + return progress_stream.get_digest_from_pull( + stream_output(output, sys.stdout)) except StreamOutputError as e: if not ignore_pull_failures: raise else: log.error(six.text_type(e)) + def push(self, ignore_push_failures=False): + if 'image' not in self.options or 'build' not in self.options: + return + + repo, tag, separator = parse_repository_tag(self.options['image']) + tag = tag or 'latest' + log.info('Pushing %s (%s%s%s)...' % (self.name, repo, separator, tag)) + output = self.client.push(repo, tag=tag, stream=True) + + try: + return progress_stream.get_digest_from_push( + stream_output(output, sys.stdout)) + except StreamOutputError as e: + if not ignore_push_failures: + raise + else: + log.error(six.text_type(e)) + def short_id_alias_exists(container, network): aliases = container.get( From 9b7bd69cfca3f957f11a8f309ca816f13b52c436 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 14 Jun 2016 12:55:29 -0400 Subject: [PATCH 368/580] Support entrypoint, labels, and user in the bundle. Signed-off-by: Daniel Nephin --- .dockerignore | 1 - compose/bundle.py | 64 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index e79da862..055ae7ed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,3 @@ coverage-html docs/_site venv .tox -dist diff --git a/compose/bundle.py b/compose/bundle.py index a6d0d2d1..e93c5bd9 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -5,11 +5,13 @@ import json import logging import six +from docker.utils import split_command from docker.utils.ports import split_port from .cli.errors import UserError from .config.serialize import denormalize_config from .network import get_network_defs_for_service +from .service import format_environment from .service import NoSuchImageError from .service import parse_repository_tag @@ -18,11 +20,22 @@ log = logging.getLogger(__name__) SERVICE_KEYS = { - 'command': 'Command', - 'environment': 'Env', 'working_dir': 'WorkingDir', + 'user': 'User', + 'labels': 'Labels', } +IGNORED_KEYS = {'build'} + +SUPPORTED_KEYS = { + 'image', + 'ports', + 'expose', + 'networks', + 'command', + 'environment', + 'entrypoint', +} | set(SERVICE_KEYS) VERSION = '0.1' @@ -120,22 +133,32 @@ def to_bundle(config, image_digests): } -def convert_service_to_bundle(name, service_dict, image_id): - container_config = {'Image': image_id} +def convert_service_to_bundle(name, service_dict, image_digest): + container_config = {'Image': image_digest} for key, value in service_dict.items(): - if key in ('build', 'image', 'ports', 'expose', 'networks'): - pass - elif key == 'environment': - container_config['env'] = { + if key in IGNORED_KEYS: + continue + + if key not in SUPPORTED_KEYS: + log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) + continue + + if key == 'environment': + container_config['Env'] = format_environment({ envkey: envvalue for envkey, envvalue in value.items() if envvalue - } - elif key in SERVICE_KEYS: - container_config[SERVICE_KEYS[key]] = value - else: - log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) + }) + continue + if key in SERVICE_KEYS: + container_config[SERVICE_KEYS[key]] = value + continue + + set_command_and_args( + container_config, + service_dict.get('entrypoint', []), + service_dict.get('command', [])) container_config['Networks'] = make_service_networks(name, service_dict) ports = make_port_specs(service_dict) @@ -145,6 +168,21 @@ def convert_service_to_bundle(name, service_dict, image_id): return container_config +# See https://github.com/docker/swarmkit/blob//agent/exec/container/container.go#L95 +def set_command_and_args(config, entrypoint, command): + if isinstance(entrypoint, six.string_types): + entrypoint = split_command(entrypoint) + if isinstance(command, six.string_types): + command = split_command(command) + + if entrypoint: + config['Command'] = entrypoint + command + return + + if command: + config['Args'] = command + + def make_service_networks(name, service_dict): networks = [] From ee68a51e281e8a997d97ed1efde0e2d2821f85c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Jun 2016 12:23:04 -0700 Subject: [PATCH 369/580] Skip TLS version test if TLSv1_2 is not available on platform Signed-off-by: Joffrey F --- tests/unit/cli/command_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 28adff3f..50fc84e1 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -55,6 +55,7 @@ class TestGetTlsVersion(object): environment = {} assert get_tls_version(environment) is None + @pytest.mark.skipif(not hasattr(ssl, 'PROTOCOL_TLSv1_2'), reason='TLS v1.2 unsupported') def test_get_tls_version_upgrade(self): environment = {'COMPOSE_TLS_VERSION': 'TLSv1_2'} assert get_tls_version(environment) == ssl.PROTOCOL_TLSv1_2 From a56e44f96ea86e02d5f3e634f1e23ed9f731a608 Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Tue, 14 Jun 2016 12:24:30 -0700 Subject: [PATCH 370/580] fixes broken links in 3 Compose docs files due to topic re-org in PR#23492 Signed-off-by: Victoria Bialas --- docs/django.md | 2 +- docs/gettingstarted.md | 2 +- docs/rails.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/django.md b/docs/django.md index b4bcee97..1cf2a567 100644 --- a/docs/django.md +++ b/docs/django.md @@ -29,7 +29,7 @@ and a `docker-compose.yml` file. The Dockerfile defines an application's image content via one or more build commands that configure that image. Once built, you can run the image in a container. For more information on `Dockerfiles`, see the [Docker user - guide](/engine/userguide/containers/dockerimages.md#building-an-image-from-a-dockerfile) + guide](/engine/tutorials/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). 3. Add the following content to the `Dockerfile`. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 8c706e4f..249bff72 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -77,7 +77,7 @@ dependencies the Python application requires, including Python itself. * Install the Python dependencies. * Set the default command for the container to `python app.py` - For more information on how to write Dockerfiles, see the [Docker user guide](/engine/userguide/containers/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). + For more information on how to write Dockerfiles, see the [Docker user guide](/engine/tutorials/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). 2. Build the image. diff --git a/docs/rails.md b/docs/rails.md index f54d8286..26777687 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -32,7 +32,7 @@ Dockerfile consists of: That'll put your application code inside an image that will build a container with Ruby, Bundler and all your dependencies inside it. For more information on -how to write Dockerfiles, see the [Docker user guide](/engine/userguide/containers/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). +how to write Dockerfiles, see the [Docker user guide](/engine/tutorials/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. From 020d46ff21764a57fa21e4f3ccc1ba09a1345049 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 14 Jun 2016 16:03:56 -0700 Subject: [PATCH 371/580] Warn on missing digests, don't push/pull by default Add a --fetch-digests flag to automatically push/pull Signed-off-by: Aanand Prasad --- compose/bundle.py | 64 ++++++++++++++++++++++++++++++++++++--------- compose/cli/main.py | 31 +++++++++++++++++++--- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index e93c5bd9..965d65c8 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -40,6 +40,22 @@ SUPPORTED_KEYS = { VERSION = '0.1' +class NeedsPush(Exception): + def __init__(self, image_name): + self.image_name = image_name + + +class NeedsPull(Exception): + def __init__(self, image_name): + self.image_name = image_name + + +class MissingDigests(Exception): + def __init__(self, needs_push, needs_pull): + self.needs_push = needs_push + self.needs_pull = needs_pull + + def serialize_bundle(config, image_digests): if config.networks: log.warn("Unsupported top level key 'networks' - ignoring") @@ -54,21 +70,36 @@ def serialize_bundle(config, image_digests): ) -def get_image_digests(project): - return { - service.name: get_image_digest(service) - for service in project.services - } +def get_image_digests(project, allow_fetch=False): + digests = {} + needs_push = set() + needs_pull = set() + + for service in project.services: + try: + digests[service.name] = get_image_digest( + service, + allow_fetch=allow_fetch, + ) + except NeedsPush as e: + needs_push.add(e.image_name) + except NeedsPull as e: + needs_pull.add(e.image_name) + + if needs_push or needs_pull: + raise MissingDigests(needs_push, needs_pull) + + return digests -def get_image_digest(service): +def get_image_digest(service, allow_fetch=False): if 'image' not in service.options: raise UserError( "Service '{s.name}' doesn't define an image tag. An image name is " "required to generate a proper image digest for the bundle. Specify " "an image repo and tag with the 'image' option.".format(s=service)) - repo, tag, separator = parse_repository_tag(service.options['image']) + separator = parse_repository_tag(service.options['image'])[2] # Compose file already uses a digest, no lookup required if separator == '@': return service.options['image'] @@ -87,13 +118,17 @@ def get_image_digest(service): # digests return image['RepoDigests'][0] + if not allow_fetch: + if 'build' in service.options: + raise NeedsPush(service.image_name) + else: + raise NeedsPull(service.image_name) + + return fetch_image_digest(service) + + +def fetch_image_digest(service): if 'build' not in service.options: - log.warn( - "Compose needs to pull the image for '{s.name}' in order to create " - "a bundle. This may result in a more recent image being used. " - "It is recommended that you use an image tagged with a " - "specific version to minimize the potential " - "differences.".format(s=service)) digest = service.pull() else: try: @@ -108,12 +143,15 @@ def get_image_digest(service): if not digest: raise ValueError("Failed to get digest for %s" % service.name) + repo = parse_repository_tag(service.options['image'])[0] identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) # Pull by digest so that image['RepoDigests'] is populated for next time # and we don't have to pull/push again service.client.pull(identifier) + log.info("Stored digest for {}".format(service.image_name)) + return identifier diff --git a/compose/cli/main.py b/compose/cli/main.py index 3e440463..25ee9050 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -15,6 +15,7 @@ from . import errors from . import signals from .. import __version__ from ..bundle import get_image_digests +from ..bundle import MissingDigests from ..bundle import serialize_bundle from ..config import ConfigurationError from ..config import parse_environment @@ -218,12 +219,17 @@ class TopLevelCommand(object): """ Generate a Docker bundle from the Compose file. - Local images will be pushed to a Docker registry, and remote images - will be pulled to fetch an image digest. + Images must have digests stored, which requires interaction with a + Docker registry. If digests aren't stored for all images, you can pass + `--fetch-digests` to automatically fetch them. Images for services + with a `build` key will be pushed. Images for services without a + `build` key will be pulled. Usage: bundle [options] Options: + --fetch-digests Automatically fetch image digests if missing + -o, --output PATH Path to write the bundle file to. Defaults to ".dsb". """ @@ -235,7 +241,26 @@ class TopLevelCommand(object): output = "{}.dsb".format(self.project.name) with errors.handle_connection_errors(self.project.client): - image_digests = get_image_digests(self.project) + try: + image_digests = get_image_digests( + self.project, + allow_fetch=options['--fetch-digests'], + ) + 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: + paras += ["The following images need to be pushed:", list_images(e.needs_push)] + + if e.needs_pull: + paras += ["The following images need to be pulled:", list_images(e.needs_pull)] + + paras.append("If this is OK, run `docker-compose bundle --fetch-digests`.") + + raise UserError("\n\n".join(paras)) with open(output, 'w') as f: f.write(serialize_bundle(compose_config, image_digests)) From 80af26d2bb29443663b87eb7a111f0bba4e352bc Mon Sep 17 00:00:00 2001 From: Anton Backer Date: Mon, 13 Jun 2016 22:45:15 -0400 Subject: [PATCH 372/580] togather -> together Signed-off-by: Anton Backer --- compose/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 25ee9050..96ad847f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -664,7 +664,7 @@ class TopLevelCommand(object): if options['--publish'] and options['--service-ports']: raise UserError( 'Service port mapping and manual port mapping ' - 'can not be used togather' + 'can not be used together' ) if options['COMMAND']: From 3c77db709fe6c86b3da30f84907994af7ab6bc2d Mon Sep 17 00:00:00 2001 From: Jonathan Giannuzzi Date: Mon, 20 Jun 2016 14:48:45 +0200 Subject: [PATCH 373/580] Fix assertion that was always true Signed-off-by: Jonathan Giannuzzi --- tests/integration/service_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index df50d513..1801f5bf 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -397,7 +397,7 @@ class ServiceTest(DockerClientTestCase): assert not mock_log.warn.called assert ( - [mount['Destination'] for mount in new_container.get('Mounts')], + [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] ) assert new_container.get_mount('/data')['Source'] != host_path From e5c5dc09f83fea5fc708a9a4d72cf0bb83da6154 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 22 Jun 2016 17:04:22 +0200 Subject: [PATCH 374/580] bash completion for `docker-compose bundle` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 763cafc4..de25cd57 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -109,6 +109,18 @@ _docker_compose_build() { } +_docker_compose_bundle() { + case "$prev" in + --output|-o) + _filedir + return + ;; + esac + + COMPREPLY=( $( compgen -W "--help --output -o" -- "$cur" ) ) +} + + _docker_compose_config() { COMPREPLY=( $( compgen -W "--help --quiet -q --services" -- "$cur" ) ) } @@ -455,6 +467,7 @@ _docker_compose() { local commands=( build + bundle config create down From f49b624d95c30ce457edff4e32c220cc657dbad1 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 22 Jun 2016 17:19:20 +0200 Subject: [PATCH 375/580] bash completion for `docker-compose push` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 763cafc4..058ede10 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -304,6 +304,18 @@ _docker_compose_pull() { } +_docker_compose_push() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help --ignore-push-failures" -- "$cur" ) ) + ;; + *) + __docker_compose_services_all + ;; + esac +} + + _docker_compose_restart() { case "$prev" in --timeout|-t) @@ -467,6 +479,7 @@ _docker_compose() { port ps pull + push restart rm run From f77dbc06cc6f5794e828b949b5df16d79ee7c143 Mon Sep 17 00:00:00 2001 From: James Ottaway Date: Fri, 24 Jun 2016 10:51:20 +1000 Subject: [PATCH 376/580] Document `tmpfs` being v2 only Signed-off-by: James Ottaway --- docs/compose-file.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index b55f250a..682dacd3 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -228,6 +228,8 @@ Custom DNS search domains. Can be a single value or a list. ### tmpfs +> [Version 2 file format](#version-2) only. + Mount a temporary file system inside the container. Can be a single value or a list. tmpfs: /run From aa7f522ab0b1e85b236b782f9e82d7f70a29b3af Mon Sep 17 00:00:00 2001 From: Ke Xu Date: Mon, 27 Jun 2016 17:48:14 +0900 Subject: [PATCH 377/580] add zsh completion support for bundle Signed-off-by: Ke Xu --- contrib/completion/zsh/_docker-compose | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 0da217dc..539482e5 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -197,6 +197,11 @@ __docker-compose_subcommand() { '--pull[Always attempt to pull a newer version of the image.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; + (bundle) + _arguments \ + $opts_help \ + '(--output -o)'{--output,-o}'[Path to write the bundle file to. Defaults to ".dsb".]:file:_files' && ret=0 + ;; (config) _arguments \ $opts_help \ From a0a90b2352d8592796097c549635a56b1bba4aa5 Mon Sep 17 00:00:00 2001 From: Ke Xu Date: Mon, 27 Jun 2016 18:00:52 +0900 Subject: [PATCH 378/580] add zsh completion for push Signed-off-by: Ke Xu --- contrib/completion/zsh/_docker-compose | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 0da217dc..42876ec9 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -278,6 +278,12 @@ __docker-compose_subcommand() { '--ignore-pull-failures[Pull what it can and ignores images with pull failures.]' \ '*:services:__docker-compose_services_from_image' && ret=0 ;; + (push) + _arguments \ + $opts_help \ + '--ignore-push-failures[Push what it can and ignores images with push failures.]' \ + '*:services:__docker-compose_services' && ret=0 + ;; (rm) _arguments \ $opts_help \ From cbb44b1a1462cefe3a1e02c75e36d527af3ff245 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Sat, 26 Mar 2016 21:41:32 -0700 Subject: [PATCH 379/580] fix broken zsh autocomplete for version 2 docker-compose files This has the added benefit of making autocompletion work when the docker-compose config file is in a parent directory. Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 0da217dc..f75d412d 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -19,26 +19,13 @@ # * @felixr docker zsh completion script : https://github.com/felixr/docker-zsh-completion # ------------------------------------------------------------------------- -# For compatibility reasons, Compose and therefore its completion supports several -# stack compositon files as listed here, in descending priority. -# Support for these filenames might be dropped in some future version. -__docker-compose_compose_file() { - local file - for file in docker-compose.y{,a}ml ; do - [ -e $file ] && { - echo $file - return - } - done - echo docker-compose.yml -} - # Extracts all service names from docker-compose.yml. ___docker-compose_all_services_in_compose_file() { local already_selected local -a services already_selected=$(echo $words | tr " " "|") - awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-$(__docker-compose_compose_file)}" 2>/dev/null | grep -Ev "$already_selected" + docker-compose config --services 2>/dev/null \ + | grep -Ev "$already_selected" } # All services, even those without an existing container @@ -57,7 +44,12 @@ ___docker-compose_services_with_key() { local -a buildable already_selected=$(echo $words | tr " " "|") # flatten sections to one line, then filter lines containing the key and return section name. - awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' "${compose_file:-$(__docker-compose_compose_file)}" 2>/dev/null | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' 2>/dev/null | grep -Ev "$already_selected" + docker-compose config 2>/dev/null \ + | sed -n -e '/^services:/,/^[^ ]/p' \ + | sed -n 's/^ //p' \ + | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ + | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' \ + | grep -Ev "$already_selected" } # All services that are defined by a Dockerfile reference From b3d9652cc303e20a991267cc7cea4b553739df17 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Tue, 26 Apr 2016 07:45:55 -0700 Subject: [PATCH 380/580] zsh autocomplete: add missing docker-compose base flags Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f75d412d..1286b21c 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -358,10 +358,17 @@ _docker-compose() { _arguments -C \ '(- :)'{-h,--help}'[Get help]' \ - '--verbose[Show more output]' \ - '(- :)'{-v,--version}'[Print version and exit]' \ '(-f --file)'{-f,--file}'[Specify an alternate docker-compose file (default: docker-compose.yml)]:file:_files -g "*.yml"' \ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ + '--verbose[Show more output]' \ + '(- :)'{-v,--version}'[Print version and exit]' \ + '(-H --host)'{-H,--host}'[Daemon socket to connect to]:host:' \ + '--tls[Use TLS; implied by --tlsverify]' \ + '--tlscacert=[Trust certs signed only by this CA]:ca path:' \ + '--tlscert=[Path to TLS certificate file]:client cert path:' \ + '--tlskey=[Path to TLS key file]:tls key path:' \ + '--tlsverify[Use TLS and verify the remote]' \ + "--skip-hostname-check[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)]" \ '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 From 73a1b60ced0ae7974aa86484032c7122e0aa708f Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Tue, 26 Apr 2016 07:47:40 -0700 Subject: [PATCH 381/580] zsh autocomplete: add missing 'remove-orphans' flag for 'up' and 'down' Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 1286b21c..b5e44762 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -207,7 +207,8 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ "--rmi[Remove images, type may be one of: 'all' to remove all images, or 'local' to remove only images that don't have an custom name set by the 'image' field]:type:(all local)" \ - '(-v --volumes)'{-v,--volumes}"[Remove data volumes]" && ret=0 + '(-v --volumes)'{-v,--volumes}"[Remove data volumes]" \ + '--remove-orphans[Remove containers for services not defined in the Compose file]' && ret=0 ;; (events) _arguments \ @@ -329,6 +330,7 @@ __docker-compose_subcommand() { "--no-build[Don't build an image, even if it's missing]" \ "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + "--remove-orphans[Remove containers for services not defined in the Compose file]" \ '*:services:__docker-compose_services_all' && ret=0 ;; (version) From eb10f41d13affd1f901fb37230a6868ef58f3610 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Tue, 26 Apr 2016 07:49:48 -0700 Subject: [PATCH 382/580] zsh autocomplete: fix incorrect flag exclusions for 'create' and 'up' Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index b5e44762..2b82d4eb 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -198,9 +198,9 @@ __docker-compose_subcommand() { (create) _arguments \ $opts_help \ - "(--no-recreate --no-build)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ - "(--force-recreate)--no-build[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ - "(--force-recreate)--no-recreate[Don't build an image, even if it's missing]" \ + "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ + "(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ + "--no-build[Don't build an image, even if it's missing.]" \ '*:services:__docker-compose_services_all' && ret=0 ;; (down) @@ -325,9 +325,9 @@ __docker-compose_subcommand() { '--build[Build images before starting containers.]' \ '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ - "--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ - "--no-recreate[If containers already exist, don't recreate them.]" \ - "--no-build[Don't build an image, even if it's missing]" \ + "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ + "(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ + "--no-build[Don't build an image, even if it's missing.]" \ "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ "--remove-orphans[Remove containers for services not defined in the Compose file]" \ From 8d2fbe3a555c21c427ffd31b5a85fa77b926853f Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Tue, 26 Apr 2016 07:53:50 -0700 Subject: [PATCH 383/580] zsh autocomplete: add 'build' flag for 'create' and 'up' Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 2b82d4eb..b6cdb0a6 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -200,7 +200,8 @@ __docker-compose_subcommand() { $opts_help \ "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ "(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ - "--no-build[Don't build an image, even if it's missing.]" \ + "(--build)--no-build[Don't build an image, even if it's missing.]" \ + "(--no-build)--build[Build images before creating containers.]" \ '*:services:__docker-compose_services_all' && ret=0 ;; (down) @@ -322,12 +323,12 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names.]' \ - '--build[Build images before starting containers.]' \ '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ "(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ - "--no-build[Don't build an image, even if it's missing.]" \ + "(--build)--no-build[Don't build an image, even if it's missing.]" \ + "(--no-build)--build[Build images before starting containers.]" \ "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ "--remove-orphans[Remove containers for services not defined in the Compose file]" \ From 1b5a94f4e413bd59312795302602ca98da69ce31 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Tue, 26 Apr 2016 07:56:51 -0700 Subject: [PATCH 384/580] zsh autocomplete: bring flag help texts up to date Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index b6cdb0a6..54bea10e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -185,7 +185,7 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '--force-rm[Always remove intermediate containers.]' \ - '--no-cache[Do not use cache when building the image]' \ + '--no-cache[Do not use cache when building the image.]' \ '--pull[Always attempt to pull a newer version of the image.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; @@ -207,14 +207,14 @@ __docker-compose_subcommand() { (down) _arguments \ $opts_help \ - "--rmi[Remove images, type may be one of: 'all' to remove all images, or 'local' to remove only images that don't have an custom name set by the 'image' field]:type:(all local)" \ - '(-v --volumes)'{-v,--volumes}"[Remove data volumes]" \ + "--rmi[Remove images. Type must be one of: 'all': Remove all images used by any service. 'local': Remove only images that don't have a custom tag set by the \`image\` field.]:type:(all local)" \ + '(-v --volumes)'{-v,--volumes}"[Remove named volumes declared in the \`volumes\` section of the Compose file and anonymous volumes attached to containers.]" \ '--remove-orphans[Remove containers for services not defined in the Compose file]' && ret=0 ;; (events) _arguments \ $opts_help \ - '--json[Output events as a stream of json objects.]' \ + '--json[Output events as a stream of json objects]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (exec) @@ -224,7 +224,7 @@ __docker-compose_subcommand() { '--privileged[Give extended privileges to the process.]' \ '--user=[Run the command as this user.]:username:_users' \ '-T[Disable pseudo-tty allocation. By default `docker-compose exec` allocates a TTY.]' \ - '--index=[Index of the container if there are multiple instances of a service (default: 1)]:index: ' \ + '--index=[Index of the container if there are multiple instances of a service \[default: 1\]]:index: ' \ '(-):running services:__docker-compose_runningservices' \ '(-):command: _command_names -e' \ '*::arguments: _normal' && ret=0 @@ -255,8 +255,8 @@ __docker-compose_subcommand() { (port) _arguments \ $opts_help \ - '--protocol=-[tcp or udap (defaults to tcp)]:protocol:(tcp udp)' \ - '--index=-[index of the container if there are mutiple instances of a service (defaults to 1)]:index: ' \ + '--protocol=[tcp or udp \[default: tcp\]]:protocol:(tcp udp)' \ + '--index=[index of the container if there are multiple instances of a service \[default: 1\]]:index: ' \ '1:running services:__docker-compose_runningservices' \ '2:port:_ports' && ret=0 ;; @@ -276,7 +276,7 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ - '-v[Remove volumes associated with containers]' \ + '-v[Remove any anonymous volumes attached to containers]' \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; (run) @@ -285,14 +285,14 @@ __docker-compose_subcommand() { '-d[Detached mode: Run container in the background, print new container name.]' \ '*-e[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \ '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \ - '--name[Assign a name to the container]:name: ' \ + '--name=[Assign a name to the container]:name: ' \ "--no-deps[Don't start linked services.]" \ - '(-p --publish)'{-p,--publish=-}"[Run command with manually mapped container's port(s) to the host.]" \ + '(-p --publish)'{-p,--publish=}"[Publish a container's port(s) to the host]" \ '--rm[Remove container after run. Ignored in detached mode.]' \ "--service-ports[Run command with the service's ports enabled and mapped to the host.]" \ '-T[Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY.]' \ - '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ - '(-w --workdir)'{-w=,--workdir=}'[Working directory inside the container]:workdir: ' \ + '(-u --user)'{-u,--user=}'[Run as specified username or uid]:username or uid:_users' \ + '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \ '(-):services:__docker-compose_services' \ '(-):command: _command_names -e' \ '*::arguments: _normal' && ret=0 @@ -322,7 +322,7 @@ __docker-compose_subcommand() { (up) _arguments \ $opts_help \ - '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names.]' \ + '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names. Incompatible with --abort-on-container-exit.]' \ '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ @@ -330,7 +330,7 @@ __docker-compose_subcommand() { "(--build)--no-build[Don't build an image, even if it's missing.]" \ "(--no-build)--build[Build images before starting containers.]" \ "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \ - '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + '(-t --timeout)'{-t,--timeout}"[Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10)]:seconds: " \ "--remove-orphans[Remove containers for services not defined in the Compose file]" \ '*:services:__docker-compose_services_all' && ret=0 ;; From 97ba14c82adecef3a9538e434d85c1e213ab2e38 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Sat, 26 Mar 2016 23:17:00 -0700 Subject: [PATCH 385/580] zsh autocomplete: use two underscores for all function names Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 54bea10e..e6990b77 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -20,7 +20,7 @@ # ------------------------------------------------------------------------- # Extracts all service names from docker-compose.yml. -___docker-compose_all_services_in_compose_file() { +__docker-compose_all_services_in_compose_file() { local already_selected local -a services already_selected=$(echo $words | tr " " "|") @@ -32,14 +32,14 @@ ___docker-compose_all_services_in_compose_file() { __docker-compose_services_all() { [[ $PREFIX = -* ]] && return 1 integer ret=1 - services=$(___docker-compose_all_services_in_compose_file) + services=$(__docker-compose_all_services_in_compose_file) _alternative "args:services:($services)" && ret=0 return ret } # All services that have an entry with the given key in their docker-compose.yml section -___docker-compose_services_with_key() { +__docker-compose_services_with_key() { local already_selected local -a buildable already_selected=$(echo $words | tr " " "|") @@ -56,7 +56,7 @@ ___docker-compose_services_with_key() { __docker-compose_services_from_build() { [[ $PREFIX = -* ]] && return 1 integer ret=1 - buildable=$(___docker-compose_services_with_key build) + buildable=$(__docker-compose_services_with_key build) _alternative "args:buildable services:($buildable)" && ret=0 return ret @@ -66,7 +66,7 @@ __docker-compose_services_from_build() { __docker-compose_services_from_image() { [[ $PREFIX = -* ]] && return 1 integer ret=1 - pullable=$(___docker-compose_services_with_key image) + pullable=$(__docker-compose_services_with_key image) _alternative "args:pullable services:($pullable)" && ret=0 return ret From d990f7899c921260876d93ec42444bc8fcb08e37 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Sun, 27 Mar 2016 02:33:16 -0700 Subject: [PATCH 386/580] zsh autocomplete: pass all relevant flags to docker-compose/docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For autocomplete to work properly, we need to pass along some flags when calling docker (--host, --tls, …) and docker-compose (--file, --tls, …). Previously flags would only be passed to docker-compose, and the only flags passed were --file and --project-name. This commit makes sure that all relevant flags are passed to both docker-compose and docker. Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 50 ++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index e6990b77..eeed07a6 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -19,12 +19,16 @@ # * @felixr docker zsh completion script : https://github.com/felixr/docker-zsh-completion # ------------------------------------------------------------------------- +__docker-compose_q() { + docker-compose 2>/dev/null $compose_options "$@" +} + # Extracts all service names from docker-compose.yml. __docker-compose_all_services_in_compose_file() { local already_selected local -a services already_selected=$(echo $words | tr " " "|") - docker-compose config --services 2>/dev/null \ + __docker-compose_q config --services \ | grep -Ev "$already_selected" } @@ -44,7 +48,7 @@ __docker-compose_services_with_key() { local -a buildable already_selected=$(echo $words | tr " " "|") # flatten sections to one line, then filter lines containing the key and return section name. - docker-compose config 2>/dev/null \ + __docker-compose_q config \ | sed -n -e '/^services:/,/^[^ ]/p' \ | sed -n 's/^ //p' \ | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ @@ -88,7 +92,7 @@ __docker-compose_get_services() { shift [[ $kind =~ (stopped|all) ]] && args=($args -a) - lines=(${(f)"$(_call_program commands docker ps $args)"}) + lines=(${(f)"$(_call_program commands docker $docker_options ps $args)"}) services=(${(f)"$(_call_program commands docker-compose 2>/dev/null $compose_options ps -q)"}) # Parse header line to find columns @@ -375,9 +379,43 @@ _docker-compose() { '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 - local compose_file=${opt_args[-f]}${opt_args[--file]} - local compose_project=${opt_args[-p]}${opt_args[--project-name]} - local compose_options="${compose_file:+--file $compose_file} ${compose_project:+--project-name $compose_project}" + local -a relevant_compose_flags relevant_docker_flags compose_options docker_options + + relevant_compose_flags=( + "--file" "-f" + "--host" "-H" + "--project-name" "-p" + "--tls" + "--tlscacert" + "--tlscert" + "--tlskey" + "--tlsverify" + "--skip-hostname-check" + ) + + relevant_docker_flags=( + "--host" "-H" + "--tls" + "--tlscacert" + "--tlscert" + "--tlskey" + "--tlsverify" + ) + + for k in "${(@k)opt_args}"; do + if [[ -n "${relevant_docker_flags[(r)$k]}" ]]; then + docker_options+=$k + if [[ -n "$opt_args[$k]" ]]; then + docker_options+=$opt_args[$k] + fi + fi + if [[ -n "${relevant_compose_flags[(r)$k]}" ]]; then + compose_options+=$k + if [[ -n "$opt_args[$k]" ]]; then + compose_options+=$opt_args[$k] + fi + fi + done case $state in (command) From 048408af4835134734bcb565a8c07991a8818530 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Sun, 27 Mar 2016 23:21:58 -0700 Subject: [PATCH 387/580] zsh autocomplete: fix missing services issue for build/pull commands Previously, the autocomplete for the build/pull commands would only add services for which build/image were the _first_ keys, respectively, in the docker-compose file. This commit fixes this, so the appropriate services are listed regardless of the order in which they appear Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index eeed07a6..7fc692cf 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -52,7 +52,8 @@ __docker-compose_services_with_key() { | sed -n -e '/^services:/,/^[^ ]/p' \ | sed -n 's/^ //p' \ | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ - | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' \ + | grep " \+$1:" \ + | sed "s/:.*//g" \ | grep -Ev "$already_selected" } From 612d263d7481edfdcfe7c82835177e17284adb55 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Tue, 29 Mar 2016 21:31:06 -0700 Subject: [PATCH 388/580] zsh autocomplete: fix issue when filtering on already selected services Previously, the filtering on already selected services would break when one service was a substring of another. This commit fixes that. Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 7fc692cf..27b17b07 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -29,7 +29,7 @@ __docker-compose_all_services_in_compose_file() { local -a services already_selected=$(echo $words | tr " " "|") __docker-compose_q config --services \ - | grep -Ev "$already_selected" + | grep -Ev "^(${already_selected})$" } # All services, even those without an existing container @@ -54,7 +54,7 @@ __docker-compose_services_with_key() { | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ | grep " \+$1:" \ | sed "s/:.*//g" \ - | grep -Ev "$already_selected" + | grep -Ev "^(${already_selected})$" } # All services that are defined by a Dockerfile reference From 0058b4ba0ce8f76341bedc0988be09cde6ee8441 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Thu, 28 Apr 2016 19:24:44 -0700 Subject: [PATCH 389/580] zsh autocomplete: replace use of sed with cut Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 27b17b07..c7df4b44 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -53,7 +53,7 @@ __docker-compose_services_with_key() { | sed -n 's/^ //p' \ | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ | grep " \+$1:" \ - | sed "s/:.*//g" \ + | cut -d: -f1 \ | grep -Ev "^(${already_selected})$" } From b3d4e9c9d7c0a9a85aa2e1958a78ee6910d98e08 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Thu, 28 Apr 2016 19:47:41 -0700 Subject: [PATCH 390/580] zsh autocomplete: break out duplicated flag messages into variables Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 40 ++++++++++++++++---------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index c7df4b44..77348d5c 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -182,7 +182,17 @@ __docker-compose_commands() { } __docker-compose_subcommand() { - local opts_help='(: -)--help[Print usage]' + local opts_help opts_force_recreate opts_no_recreate opts_no_build opts_remove_orphans opts_timeout opts_no_color opts_no_deps + + opts_help='(: -)--help[Print usage]' + opts_force_recreate="(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" + opts_no_recreate="(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" + opts_no_build="(--build)--no-build[Don't build an image, even if it's missing.]" + opts_remove_orphans="--remove-orphans[Remove containers for services not defined in the Compose file]" + opts_timeout=('(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: ") + opts_no_color='--no-color[Produce monochrome output.]' + opts_no_deps="--no-deps[Don't start linked services.]" + integer ret=1 case "$words[1]" in @@ -203,9 +213,9 @@ __docker-compose_subcommand() { (create) _arguments \ $opts_help \ - "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ - "(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ - "(--build)--no-build[Don't build an image, even if it's missing.]" \ + $opts_force_recreate \ + $opts_no_recreate \ + $opts_no_build \ "(--no-build)--build[Build images before creating containers.]" \ '*:services:__docker-compose_services_all' && ret=0 ;; @@ -214,7 +224,7 @@ __docker-compose_subcommand() { $opts_help \ "--rmi[Remove images. Type must be one of: 'all': Remove all images used by any service. 'local': Remove only images that don't have a custom tag set by the \`image\` field.]:type:(all local)" \ '(-v --volumes)'{-v,--volumes}"[Remove named volumes declared in the \`volumes\` section of the Compose file and anonymous volumes attached to containers.]" \ - '--remove-orphans[Remove containers for services not defined in the Compose file]' && ret=0 + $opts_remove_orphans && ret=0 ;; (events) _arguments \ @@ -247,7 +257,7 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(-f --follow)'{-f,--follow}'[Follow log output]' \ - '--no-color[Produce monochrome output.]' \ + $opts_no_color \ '--tail=[Number of lines to show from the end of the logs for each container.]:number of lines: ' \ '(-t --timestamps)'{-t,--timestamps}'[Show timestamps]' \ '*:services:__docker-compose_services_all' && ret=0 @@ -291,7 +301,7 @@ __docker-compose_subcommand() { '*-e[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \ '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \ '--name=[Assign a name to the container]:name: ' \ - "--no-deps[Don't start linked services.]" \ + $opts_no_deps \ '(-p --publish)'{-p,--publish=}"[Publish a container's port(s) to the host]" \ '--rm[Remove container after run. Ignored in detached mode.]' \ "--service-ports[Run command with the service's ports enabled and mapped to the host.]" \ @@ -305,7 +315,7 @@ __docker-compose_subcommand() { (scale) _arguments \ $opts_help \ - '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + $opts_timeout \ '*:running services:__docker-compose_runningservices' && ret=0 ;; (start) @@ -316,7 +326,7 @@ __docker-compose_subcommand() { (stop|restart) _arguments \ $opts_help \ - '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + $opts_timeout \ '*:running services:__docker-compose_runningservices' && ret=0 ;; (unpause) @@ -328,15 +338,15 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names. Incompatible with --abort-on-container-exit.]' \ - '--no-color[Produce monochrome output.]' \ - "--no-deps[Don't start linked services.]" \ - "(--no-recreate)--force-recreate[Recreate containers even if their configuration and image haven't changed. Incompatible with --no-recreate.]" \ - "(--force-recreate)--no-recreate[If containers already exist, don't recreate them. Incompatible with --force-recreate.]" \ - "(--build)--no-build[Don't build an image, even if it's missing.]" \ + $opts_no_color \ + $opts_no_deps \ + $opts_force_recreate \ + $opts_no_recreate \ + $opts_no_build \ "(--no-build)--build[Build images before starting containers.]" \ "(-d)--abort-on-container-exit[Stops all containers if any container was stopped. Incompatible with -d.]" \ '(-t --timeout)'{-t,--timeout}"[Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10)]:seconds: " \ - "--remove-orphans[Remove containers for services not defined in the Compose file]" \ + $opts_remove_orphans \ '*:services:__docker-compose_services_all' && ret=0 ;; (version) From c3247e7af8edcca113018b9e39af0282a649d6f7 Mon Sep 17 00:00:00 2001 From: Andre Eriksson Date: Mon, 27 Jun 2016 10:57:35 -0700 Subject: [PATCH 391/580] zsh autocomplete: update misleading comment Signed-off-by: Andre Eriksson --- contrib/completion/zsh/_docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 77348d5c..a59abe29 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -23,7 +23,7 @@ __docker-compose_q() { docker-compose 2>/dev/null $compose_options "$@" } -# Extracts all service names from docker-compose.yml. +# All services defined in docker-compose.yml __docker-compose_all_services_in_compose_file() { local already_selected local -a services From 058a7659ba2590dbc067fe76bb6044e0d4f9b192 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jun 2016 14:53:45 -0700 Subject: [PATCH 392/580] Update bundle extension It's now .dab, for Distributed Application Bundle Signed-off-by: Aanand Prasad --- compose/cli/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 25ee9050..ae0175d5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -217,7 +217,7 @@ class TopLevelCommand(object): def bundle(self, config_options, options): """ - Generate a Docker bundle from the Compose file. + Generate a Distributed Application Bundle (DAB) from the Compose file. Images must have digests stored, which requires interaction with a Docker registry. If digests aren't stored for all images, you can pass @@ -231,14 +231,14 @@ class TopLevelCommand(object): --fetch-digests Automatically fetch image digests if missing -o, --output PATH Path to write the bundle file to. - Defaults to ".dsb". + Defaults to ".dab". """ self.project = project_from_options('.', config_options) compose_config = get_config_from_options(self.project_dir, config_options) output = options["--output"] if not output: - output = "{}.dsb".format(self.project.name) + output = "{}.dab".format(self.project.name) with errors.handle_connection_errors(self.project.client): try: From 8e0458205241f654bb1d75de9babd9880afa7206 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Jun 2016 16:21:19 -0700 Subject: [PATCH 393/580] Fix tests to accommodate short-id container alias Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 068d0efc..85d5776b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1135,7 +1135,10 @@ class CLITestCase(DockerClientTestCase): ] for _, config in networks.items(): - assert not config['Aliases'] + # TODO: once we drop support for API <1.24, this can be changed to: + # assert config['Aliases'] == [container.short_id] + aliases = set(config['Aliases']) - set([container.short_id]) + assert not aliases @v2_only() def test_run_detached_connects_to_network(self): @@ -1152,7 +1155,10 @@ class CLITestCase(DockerClientTestCase): ] for _, config in networks.items(): - assert not config['Aliases'] + # TODO: once we drop support for API <1.24, this can be changed to: + # assert config['Aliases'] == [container.short_id] + aliases = set(config['Aliases']) - set([container.short_id]) + assert not aliases assert self.lookup(container, 'app') assert self.lookup(container, 'db') From 95207561bb510a7165fbcb1e9250e6d1baea4c09 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 14 Jun 2016 16:55:48 -0400 Subject: [PATCH 394/580] Add some unit tests for new bundle and push commands. Signed-off-by: Daniel Nephin --- compose/bundle.py | 38 +++-- tests/unit/bundle_test.py | 232 +++++++++++++++++++++++++++++ tests/unit/progress_stream_test.py | 20 +++ 3 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 tests/unit/bundle_test.py diff --git a/compose/bundle.py b/compose/bundle.py index 965d65c8..8a1e859e 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -57,17 +57,7 @@ class MissingDigests(Exception): def serialize_bundle(config, image_digests): - if config.networks: - log.warn("Unsupported top level key 'networks' - ignoring") - - if config.volumes: - log.warn("Unsupported top level key 'volumes' - ignoring") - - return json.dumps( - to_bundle(config, image_digests), - indent=2, - sort_keys=True, - ) + return json.dumps(to_bundle(config, image_digests), indent=2, sort_keys=True) def get_image_digests(project, allow_fetch=False): @@ -99,7 +89,7 @@ def get_image_digest(service, allow_fetch=False): "required to generate a proper image digest for the bundle. Specify " "an image repo and tag with the 'image' option.".format(s=service)) - separator = parse_repository_tag(service.options['image'])[2] + _, _, separator = parse_repository_tag(service.options['image']) # Compose file already uses a digest, no lookup required if separator == '@': return service.options['image'] @@ -143,24 +133,32 @@ def fetch_image_digest(service): if not digest: raise ValueError("Failed to get digest for %s" % service.name) - repo = parse_repository_tag(service.options['image'])[0] + repo, _, _ = parse_repository_tag(service.options['image']) identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) - # Pull by digest so that image['RepoDigests'] is populated for next time - # and we don't have to pull/push again - service.client.pull(identifier) - - log.info("Stored digest for {}".format(service.image_name)) + # only do this is RepoTags isn't already populated + image = service.image() + if not image['RepoDigests']: + # Pull by digest so that image['RepoDigests'] is populated for next time + # and we don't have to pull/push again + service.client.pull(identifier) + log.info("Stored digest for {}".format(service.image_name)) return identifier def to_bundle(config, image_digests): + if config.networks: + log.warn("Unsupported top level key 'networks' - ignoring") + + if config.volumes: + log.warn("Unsupported top level key 'volumes' - ignoring") + config = denormalize_config(config) return { - 'version': VERSION, - 'services': { + 'Version': VERSION, + 'Services': { name: convert_service_to_bundle( name, service_dict, diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py new file mode 100644 index 00000000..ff4c0dce --- /dev/null +++ b/tests/unit/bundle_test.py @@ -0,0 +1,232 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import docker +import mock +import pytest + +from compose import bundle +from compose import service +from compose.cli.errors import UserError +from compose.config.config import Config + + +@pytest.fixture +def mock_service(): + return mock.create_autospec( + service.Service, + client=mock.create_autospec(docker.Client), + options={}) + + +def test_get_image_digest_exists(mock_service): + mock_service.options['image'] = 'abcd' + mock_service.image.return_value = {'RepoDigests': ['digest1']} + digest = bundle.get_image_digest(mock_service) + assert digest == 'digest1' + + +def test_get_image_digest_image_uses_digest(mock_service): + mock_service.options['image'] = image_id = 'redis@sha256:digest' + + digest = bundle.get_image_digest(mock_service) + assert digest == image_id + assert not mock_service.image.called + + +def test_get_image_digest_no_image(mock_service): + with pytest.raises(UserError) as exc: + bundle.get_image_digest(service.Service(name='theservice')) + + assert "doesn't define an image tag" in exc.exconly() + + +def test_fetch_image_digest_for_image_with_saved_digest(mock_service): + mock_service.options['image'] = image_id = 'abcd' + mock_service.pull.return_value = expected = 'sha256:thedigest' + mock_service.image.return_value = {'RepoDigests': ['digest1']} + + digest = bundle.fetch_image_digest(mock_service) + assert digest == image_id + '@' + expected + + mock_service.pull.assert_called_once_with() + assert not mock_service.push.called + assert not mock_service.client.pull.called + + +def test_fetch_image_digest_for_image(mock_service): + mock_service.options['image'] = image_id = 'abcd' + mock_service.pull.return_value = expected = 'sha256:thedigest' + mock_service.image.return_value = {'RepoDigests': []} + + digest = bundle.fetch_image_digest(mock_service) + assert digest == image_id + '@' + expected + + mock_service.pull.assert_called_once_with() + assert not mock_service.push.called + mock_service.client.pull.assert_called_once_with(digest) + + +def test_fetch_image_digest_for_build(mock_service): + mock_service.options['build'] = '.' + mock_service.options['image'] = image_id = 'abcd' + mock_service.push.return_value = expected = 'sha256:thedigest' + mock_service.image.return_value = {'RepoDigests': ['digest1']} + + digest = bundle.fetch_image_digest(mock_service) + assert digest == image_id + '@' + expected + + mock_service.push.assert_called_once_with() + assert not mock_service.pull.called + assert not mock_service.client.pull.called + + +def test_to_bundle(): + image_digests = {'a': 'aaaa', 'b': 'bbbb'} + services = [ + {'name': 'a', 'build': '.', }, + {'name': 'b', 'build': './b'}, + ] + config = Config( + version=2, + services=services, + volumes={'special': {}}, + networks={'extra': {}}) + + with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + output = bundle.to_bundle(config, image_digests) + + assert mock_log.mock_calls == [ + mock.call("Unsupported top level key 'networks' - ignoring"), + mock.call("Unsupported top level key 'volumes' - ignoring"), + ] + + assert output == { + 'Version': '0.1', + 'Services': { + 'a': {'Image': 'aaaa', 'Networks': ['default']}, + 'b': {'Image': 'bbbb', 'Networks': ['default']}, + } + } + + +def test_convert_service_to_bundle(): + name = 'theservice' + image_digest = 'thedigest' + service_dict = { + 'ports': ['80'], + 'expose': ['1234'], + 'networks': {'extra': {}}, + 'command': 'foo', + 'entrypoint': 'entry', + 'environment': {'BAZ': 'ENV'}, + 'build': '.', + 'working_dir': '/tmp', + 'user': 'root', + 'labels': {'FOO': 'LABEL'}, + 'privileged': True, + } + + with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + config = bundle.convert_service_to_bundle(name, service_dict, image_digest) + + mock_log.assert_called_once_with( + "Unsupported key 'privileged' in services.theservice - ignoring") + + assert config == { + 'Image': image_digest, + 'Ports': [ + {'Protocol': 'tcp', 'Port': 80}, + {'Protocol': 'tcp', 'Port': 1234}, + ], + 'Networks': ['extra'], + 'Command': ['entry', 'foo'], + 'Env': ['BAZ=ENV'], + 'WorkingDir': '/tmp', + 'User': 'root', + 'Labels': {'FOO': 'LABEL'}, + } + + +def test_set_command_and_args_none(): + config = {} + bundle.set_command_and_args(config, [], []) + assert config == {} + + +def test_set_command_and_args_from_command(): + config = {} + bundle.set_command_and_args(config, [], "echo ok") + assert config == {'Args': ['echo', 'ok']} + + +def test_set_command_and_args_from_entrypoint(): + config = {} + bundle.set_command_and_args(config, "echo entry", []) + assert config == {'Command': ['echo', 'entry']} + + +def test_set_command_and_args_from_both(): + config = {} + bundle.set_command_and_args(config, "echo entry", ["extra", "arg"]) + assert config == {'Command': ['echo', 'entry', "extra", "arg"]} + + +def test_make_service_networks_default(): + name = 'theservice' + service_dict = {} + + with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + networks = bundle.make_service_networks(name, service_dict) + + assert not mock_log.called + assert networks == ['default'] + + +def test_make_service_networks(): + name = 'theservice' + service_dict = { + 'networks': { + 'foo': { + 'aliases': ['one', 'two'], + }, + 'bar': {} + }, + } + + with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + networks = bundle.make_service_networks(name, service_dict) + + mock_log.assert_called_once_with( + "Unsupported key 'aliases' in services.theservice.networks.foo - ignoring") + assert sorted(networks) == sorted(service_dict['networks']) + + +def test_make_port_specs(): + service_dict = { + 'expose': ['80', '500/udp'], + 'ports': [ + '400:80', + '222', + '127.0.0.1:8001:8001', + '127.0.0.1:5000-5001:3000-3001'], + } + port_specs = bundle.make_port_specs(service_dict) + assert port_specs == [ + {'Protocol': 'tcp', 'Port': 80}, + {'Protocol': 'tcp', 'Port': 222}, + {'Protocol': 'tcp', 'Port': 8001}, + {'Protocol': 'tcp', 'Port': 3000}, + {'Protocol': 'tcp', 'Port': 3001}, + {'Protocol': 'udp', 'Port': 500}, + ] + + +def test_make_port_spec_with_protocol(): + port_spec = bundle.make_port_spec("5000/udp") + assert port_spec == {'Protocol': 'udp', 'Port': 5000} + + +def test_make_port_spec_default_protocol(): + port_spec = bundle.make_port_spec("50000") + assert port_spec == {'Protocol': 'tcp', 'Port': 50000} diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index b01be11a..c0cb906d 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -65,3 +65,23 @@ class ProgressStreamTestCase(unittest.TestCase): events = progress_stream.stream_output(events, output) self.assertTrue(len(output.getvalue()) > 0) + + +def test_get_digest_from_push(): + digest = "sha256:abcd" + events = [ + {"status": "..."}, + {"status": "..."}, + {"progressDetail": {}, "aux": {"Digest": digest}}, + ] + assert progress_stream.get_digest_from_push(events) == digest + + +def test_get_digest_from_pull(): + digest = "sha256:abcd" + events = [ + {"status": "..."}, + {"status": "..."}, + {"status": "Digest: %s" % digest}, + ] + assert progress_stream.get_digest_from_pull(events) == digest From 5640bd42a83e7d30a2f52e919d6e4e691095b9ed Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 16 Jun 2016 15:15:17 -0400 Subject: [PATCH 395/580] Add an acceptance test for bundle. Signed-off-by: Daniel Nephin --- compose/bundle.py | 2 +- tests/acceptance/cli_test.py | 27 +++++++++++++++++++ .../bundle-with-digests/docker-compose.yml | 9 +++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/bundle-with-digests/docker-compose.yml diff --git a/compose/bundle.py b/compose/bundle.py index 8a1e859e..44f6954b 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -136,7 +136,7 @@ def fetch_image_digest(service): repo, _, _ = parse_repository_tag(service.options['image']) identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) - # only do this is RepoTags isn't already populated + # only do this if RepoDigests isn't already populated image = service.image() if not image['RepoDigests']: # Pull by digest so that image['RepoDigests'] is populated for next time diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 068d0efc..7aef7b52 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -12,6 +12,7 @@ from collections import Counter from collections import namedtuple from operator import attrgetter +import py import yaml from docker import errors @@ -378,6 +379,32 @@ class CLITestCase(DockerClientTestCase): ] assert not containers + def test_bundle_with_digests(self): + self.base_dir = 'tests/fixtures/bundle-with-digests/' + tmpdir = py.test.ensuretemp('cli_test_bundle') + self.addCleanup(tmpdir.remove) + filename = str(tmpdir.join('example.dab')) + + self.dispatch(['bundle', '--output', filename]) + with open(filename, 'r') as fh: + bundle = json.load(fh) + + assert bundle == { + 'Version': '0.1', + 'Services': { + 'web': { + 'Image': ('dockercloud/hello-world@sha256:fe79a2cfbd17eefc3' + '44fb8419420808df95a1e22d93b7f621a7399fd1e9dca1d'), + 'Networks': ['default'], + }, + 'redis': { + 'Image': ('redis@sha256:a84cb8f53a70e19f61ff2e1d5e73fb7ae62d' + '374b2b7392de1e7d77be26ef8f7b'), + 'Networks': ['default'], + } + }, + } + def test_create(self): self.dispatch(['create']) service = self.project.get_service('simple') diff --git a/tests/fixtures/bundle-with-digests/docker-compose.yml b/tests/fixtures/bundle-with-digests/docker-compose.yml new file mode 100644 index 00000000..b7013512 --- /dev/null +++ b/tests/fixtures/bundle-with-digests/docker-compose.yml @@ -0,0 +1,9 @@ + +version: '2.0' + +services: + web: + image: dockercloud/hello-world@sha256:fe79a2cfbd17eefc344fb8419420808df95a1e22d93b7f621a7399fd1e9dca1d + + redis: + image: redis@sha256:a84cb8f53a70e19f61ff2e1d5e73fb7ae62d374b2b7392de1e7d77be26ef8f7b From a822406eb0179ec1f6fe09637dfc3b20662a7085 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 16 Jun 2016 15:10:56 -0700 Subject: [PATCH 396/580] Update docker-py version in requirements Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index eb5275f4..160bd0ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.8.1 +docker-py==1.9.0rc2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 diff --git a/setup.py b/setup.py index 0b37c1dd..3696adc6 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.8.1, < 2', + 'docker-py == 1.9.0rc2', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From edd28f09d1bfe16b969ae6719b4a7e66e12c82f7 Mon Sep 17 00:00:00 2001 From: Ke Xu Date: Wed, 29 Jun 2016 11:01:50 +0900 Subject: [PATCH 397/580] change dsb to dab Signed-off-by: Ke Xu --- contrib/completion/zsh/_docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 539482e5..2995932d 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -200,7 +200,7 @@ __docker-compose_subcommand() { (bundle) _arguments \ $opts_help \ - '(--output -o)'{--output,-o}'[Path to write the bundle file to. Defaults to ".dsb".]:file:_files' && ret=0 + '(--output -o)'{--output,-o}'[Path to write the bundle file to. Defaults to ".dab".]:file:_files' && ret=0 ;; (config) _arguments \ From 2b6ea847b91da78023b94b832eaffc65170ae74d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 29 Jun 2016 10:25:47 -0400 Subject: [PATCH 398/580] Update requirements.txt Signed-off-by: Daniel Nephin --- requirements.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eb5275f4..d4748aa1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,12 @@ PyYAML==3.11 +backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 docker-py==1.8.1 dockerpty==0.4.1 docopt==0.6.1 -enum34==1.0.4 +enum34==1.0.4; python_version < '3.4' +functools32==3.2.3.post2; python_version < '3.2' +ipaddress==1.0.16 jsonschema==2.5.1 requests==2.7.0 six==1.7.3 From 622de27c1e1cbb5f25140cf6d4bc8bb0625d9a3b Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 29 Jun 2016 08:45:36 -0700 Subject: [PATCH 399/580] bash completion for `docker-compose bundle --fetch-digests` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 0adfdca8..0201bcb2 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -117,7 +117,7 @@ _docker_compose_bundle() { ;; esac - COMPREPLY=( $( compgen -W "--help --output -o" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--fetch-digests --help --output -o" -- "$cur" ) ) } From a62739b9068ac4f83580021794c736c98b3415f8 Mon Sep 17 00:00:00 2001 From: Chris Clark Date: Thu, 30 Jun 2016 15:30:22 -0700 Subject: [PATCH 400/580] Signed-off-by: Chris Clark The postgres image expects a specific volume path. The docs had a typo in that path. This can cause a lot of agony. This is not hypothetical agony, but very real agony that was very recently experienced :) --- docs/compose-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 8c10b5f3..464cf271 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -789,7 +789,7 @@ called `data` and mount it into the `db` service's containers. db: image: postgres volumes: - - data:/var/lib/postgres/data + - data:/var/lib/postgresql/data volumes: data: From 5d244ef6d89f79bb9323b2ecf74a7895d7a46b8d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Apr 2016 14:45:51 -0700 Subject: [PATCH 401/580] Unset env vars behavior in 'run' mirroring engine Unset env vars passed to `run` via command line options take the value of the system's var with the same name. Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +++- compose/config/environment.py | 12 ++++++++++++ tests/acceptance/cli_test.py | 8 ++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index ae0175d5..5d2b4fa2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -891,7 +891,9 @@ def build_container_options(options, detach, command): } if options['-e']: - container_options['environment'] = parse_environment(options['-e']) + container_options['environment'] = Environment.from_command_line( + parse_environment(options['-e']) + ) if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') diff --git a/compose/config/environment.py b/compose/config/environment.py index ff08b771..5d6b5af6 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -60,6 +60,18 @@ class Environment(dict): instance.update(os.environ) return instance + @classmethod + def from_command_line(cls, parsed_env_opts): + result = cls() + for k, v in parsed_env_opts.items(): + # Values from the command line take priority, unless they're unset + # in which case they take the value from the system's environment + if v is None and k in os.environ: + result[k] = os.environ[k] + else: + result[k] = v + return result + def __getitem__(self, key): try: return super(Environment, self).__getitem__(key) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7b1785ee..43fe5650 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1216,6 +1216,14 @@ class CLITestCase(DockerClientTestCase): 'simplecomposefile_simple_run_1', 'exited')) + @mock.patch.dict(os.environ) + def test_run_env_values_from_system(self): + os.environ['FOO'] = 'bar' + os.environ['BAR'] = 'baz' + result = self.dispatch(['run', '-e', 'FOO', 'simple', 'env'], None) + assert 'FOO=bar' in result.stdout + assert 'BAR=baz' not in result.stdout + def test_rm(self): service = self.project.get_service('simple') service.create_container() From 10ae81f8cf94b71bdea03bcb622d972baf39f011 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Jun 2016 15:18:05 -0700 Subject: [PATCH 402/580] Post-merge fix - restore Environment import in main.py Signed-off-by: Aanand Prasad --- compose/cli/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5d2b4fa2..f4c17167 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ from ..bundle import MissingDigests from ..bundle import serialize_bundle from ..config import ConfigurationError from ..config import parse_environment +from ..config.environment import Environment from ..config.serialize import serialize_config from ..const import DEFAULT_TIMEOUT from ..const import IS_WINDOWS_PLATFORM From 50d5aab8adbb4463aec49e31d0b90080de3733e3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Jun 2016 16:26:12 -0700 Subject: [PATCH 403/580] Fix test: check container's Env array instead of the output of 'env' Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43fe5650..64662654 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1220,9 +1220,13 @@ class CLITestCase(DockerClientTestCase): def test_run_env_values_from_system(self): os.environ['FOO'] = 'bar' os.environ['BAR'] = 'baz' - result = self.dispatch(['run', '-e', 'FOO', 'simple', 'env'], None) - assert 'FOO=bar' in result.stdout - assert 'BAR=baz' not in result.stdout + + self.dispatch(['run', '-e', 'FOO', 'simple', 'true'], None) + + container = self.project.containers(one_off=OneOffFilter.only, stopped=True)[0] + environment = container.get('Config.Env') + assert 'FOO=bar' in environment + assert 'BAR=baz' not in environment def test_rm(self): service = self.project.get_service('simple') From 3d0a1de0237024ae8ac49c052edf70d811b069da Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 30 Jun 2016 18:40:39 -0400 Subject: [PATCH 404/580] Upgrade pip on osx Signed-off-by: Daniel Nephin --- script/travis/ci | 2 +- script/travis/install | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/script/travis/ci b/script/travis/ci index 4cce1bc8..cd4fcc6d 100755 --- a/script/travis/ci +++ b/script/travis/ci @@ -6,5 +6,5 @@ if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then tox -e py27,py34 -- tests/unit else # TODO: we could also install py34 and test against it - python -m tox -e py27 -- tests/unit + tox -e py27 -- tests/unit fi diff --git a/script/travis/install b/script/travis/install index a23667bf..d4b34786 100755 --- a/script/travis/install +++ b/script/travis/install @@ -5,5 +5,6 @@ set -ex if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then pip install tox==2.1.1 else - pip install --user tox==2.1.1 + sudo pip install --upgrade pip tox==2.1.1 virtualenv + pip --version fi From 931b01acf93f44b89999f4239e91c3bc8d15dba0 Mon Sep 17 00:00:00 2001 From: allencloud Date: Sat, 2 Jul 2016 23:27:10 +0800 Subject: [PATCH 405/580] make-output-consistent-typo Signed-off-by: allencloud --- compose/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index f4c17167..ff6dba11 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -178,7 +178,7 @@ class TopLevelCommand(object): pause Pause services port Print the public port for a port binding ps List containers - pull Pulls service images + pull Pull service images push Push service images restart Restart services rm Remove stopped containers From 6fe5d2b54351143ee4eab090ccd58c5067985078 Mon Sep 17 00:00:00 2001 From: George Lester Date: Tue, 5 Jul 2016 23:43:25 -0700 Subject: [PATCH 406/580] Implemented oom_score_adj Signed-off-by: George Lester --- compose/config/config.py | 1 + compose/config/config_schema_v2.0.json | 1 + compose/service.py | 2 ++ docs/compose-file.md | 2 +- tests/integration/service_test.py | 5 +++++ tests/unit/config/config_test.py | 20 ++++++++++++++++++++ 6 files changed, 30 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7a2b3d36..d3ab1d4b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -70,6 +70,7 @@ DOCKER_CONFIG_KEYS = [ 'mem_limit', 'memswap_limit', 'net', + 'oom_score_adj' 'pid', 'ports', 'privileged', diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index e84d1317..c08fa4d7 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -166,6 +166,7 @@ } ] }, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "pid": {"type": ["string", "null"]}, "ports": { diff --git a/compose/service.py b/compose/service.py index af572e5b..73381466 100644 --- a/compose/service.py +++ b/compose/service.py @@ -53,6 +53,7 @@ DOCKER_START_KEYS = [ 'log_opt', 'mem_limit', 'memswap_limit', + 'oom_score_adj', 'pid', 'privileged', 'restart', @@ -695,6 +696,7 @@ class Service(object): cpu_quota=options.get('cpu_quota'), shm_size=options.get('shm_size'), tmpfs=options.get('tmpfs'), + oom_score_adj=options.get('oom_score_adj') ) def build(self, no_cache=False, pull=False, force_rm=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index 464cf271..f7b5a931 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -715,7 +715,7 @@ then read-write will be used. > - container_name > - container_name:rw -### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, shm\_size, stdin\_open, tty, user, working\_dir +### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, oom_score_adj, privileged, read\_only, restart, shm\_size, stdin\_open, tty, user, working\_dir Each of these is a single value, analogous to its [docker run](https://docs.docker.com/engine/reference/run/) counterpart. diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 1801f5bf..02f9cc0b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -854,6 +854,11 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always') + def test_oom_score_adj_value(self): + service = self.create_service('web', oom_score_adj=500) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.OomScoreAdj'), 500) + def test_restart_on_failure_value(self): service = self.create_service('web', restart={ 'Name': 'on-failure', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2dad224b..1be8aefa 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1243,6 +1243,26 @@ class ConfigTest(unittest.TestCase): } ] + def test_oom_score_adj_option(self): + + actual = config.load(build_config_details({ + 'version': '2', + 'services': { + 'web': { + 'image': 'alpine', + 'oom_score_adj': 500 + } + } + })) + + assert actual.services == [ + { + 'name': 'web', + 'image': 'alpine', + 'oom_score_adj': 500 + } + ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): base = { 'volumes': ['.:/app'], From 5dabc81c16d69aad6ecb114a119add5bc77bd9bf Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 5 Jul 2016 17:29:28 +0100 Subject: [PATCH 407/580] Suggest to run Docker for Mac if it isn't running Instead of suggesting docker-machine. Signed-off-by: Ben Firshman --- compose/cli/errors.py | 30 ++++++++++++++++++++---------- compose/cli/utils.py | 4 ++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 2c68d36d..89a7a949 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -15,6 +15,7 @@ from requests.packages.urllib3.exceptions import ReadTimeoutError from ..const import API_VERSION_TO_ENGINE_VERSION from ..const import HTTP_TIMEOUT from .utils import call_silently +from .utils import is_docker_for_mac_installed from .utils import is_mac from .utils import is_ubuntu @@ -48,16 +49,7 @@ def handle_connection_errors(client): if e.args and isinstance(e.args[0], ReadTimeoutError): log_timeout_error() raise ConnectionError() - - if call_silently(['which', 'docker']) != 0: - if is_mac(): - exit_with_error(docker_not_found_mac) - if is_ubuntu(): - exit_with_error(docker_not_found_ubuntu) - exit_with_error(docker_not_found_generic) - if call_silently(['which', 'docker-machine']) == 0: - exit_with_error(conn_error_docker_machine) - exit_with_error(conn_error_generic.format(url=client.base_url)) + exit_with_error(get_conn_error_message(client.base_url)) except APIError as e: log_api_error(e, client.api_version) raise ConnectionError() @@ -97,6 +89,20 @@ def exit_with_error(msg): raise ConnectionError() +def get_conn_error_message(url): + if call_silently(['which', 'docker']) != 0: + if is_mac(): + return docker_not_found_mac + if is_ubuntu(): + return docker_not_found_ubuntu + return docker_not_found_generic + if is_docker_for_mac_installed(): + return conn_error_docker_for_mac + if call_silently(['which', 'docker-machine']) == 0: + return conn_error_docker_machine + return conn_error_generic.format(url=url) + + docker_not_found_mac = """ Couldn't connect to Docker daemon. You might need to install Docker: @@ -122,6 +128,10 @@ conn_error_docker_machine = """ Couldn't connect to Docker daemon - you might need to run `docker-machine start default`. """ +conn_error_docker_for_mac = """ + Couldn't connect to Docker daemon. You might need to start Docker for Mac. +""" + conn_error_generic = """ Couldn't connect to Docker daemon at {url} - is it running? diff --git a/compose/cli/utils.py b/compose/cli/utils.py index cc2b680d..bf5df80c 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -103,3 +103,7 @@ def get_build_version(): with open(filename) as fh: return fh.read().strip() + + +def is_docker_for_mac_installed(): + return is_mac() and os.path.isdir('/Applications/Docker.app') From 949b88fff935dce586f9226976db105a50c17253 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 09:56:25 -0700 Subject: [PATCH 408/580] Fix alias tests on 1.11 The fix in 8e0458205241f654bb1d75de9babd9880afa7206 caused a regression when testing against 1.11. Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 64662654..a8fd3249 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1164,7 +1164,7 @@ class CLITestCase(DockerClientTestCase): for _, config in networks.items(): # TODO: once we drop support for API <1.24, this can be changed to: # assert config['Aliases'] == [container.short_id] - aliases = set(config['Aliases']) - set([container.short_id]) + aliases = set(config['Aliases'] or []) - set([container.short_id]) assert not aliases @v2_only() @@ -1184,7 +1184,7 @@ class CLITestCase(DockerClientTestCase): for _, config in networks.items(): # TODO: once we drop support for API <1.24, this can be changed to: # assert config['Aliases'] == [container.short_id] - aliases = set(config['Aliases']) - set([container.short_id]) + aliases = set(config['Aliases'] or []) - set([container.short_id]) assert not aliases assert self.lookup(container, 'app') From 08127625a0de1a50bf4e1a1f9861c669fe778be4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 18:01:27 -0700 Subject: [PATCH 409/580] Pin base image to alpine:3.4 in Dockerfile.run Signed-off-by: Aanand Prasad --- Dockerfile.run | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.run b/Dockerfile.run index 792077ad..4e76d64f 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,5 +1,5 @@ -FROM alpine:edge +FROM alpine:3.4 RUN apk -U add \ python \ py-pip From 49d4fd27952433feb20bc22117aba4766c15c1c1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 11:41:11 -0700 Subject: [PATCH 410/580] Update install.md and CHANGELOG.md for 1.7.1 Signed-off-by: Aanand Prasad --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ docs/install.md | 6 +++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee45386..0064a5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,47 @@ Change log ========== +1.7.1 (2016-05-04) +----------------- + +Bug Fixes + +- Fixed a bug where the output of `docker-compose config` for v1 files + would be an invalid configuration file. + +- Fixed a bug where `docker-compose config` would not check the validity + of links. + +- Fixed an issue where `docker-compose help` would not output a list of + available commands and generic options as expected. + +- Fixed an issue where filtering by service when using `docker-compose logs` + would not apply for newly created services. + +- Fixed a bug where unchanged services would sometimes be recreated in + in the up phase when using Compose with Python 3. + +- Fixed an issue where API errors encountered during the up phase would + not be recognized as a failure state by Compose. + +- Fixed a bug where Compose would raise a NameError because of an undefined + exception name on non-Windows platforms. + +- Fixed a bug where the wrong version of `docker-py` would sometimes be + installed alongside Compose. + +- Fixed a bug where the host value output by `docker-machine config default` + would not be recognized as valid options by the `docker-compose` + command line. + +- Fixed an issue where Compose would sometimes exit unexpectedly while + reading events broadcasted by a Swarm cluster. + +- Corrected a statement in the docs about the location of the `.env` file, + which is indeed read from the current directory, instead of in the same + location as the Compose file. + + 1.7.0 (2016-04-13) ------------------ diff --git a/docs/install.md b/docs/install.md index 95416e7a..76e4a868 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.6.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.7.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.6.2 + docker-compose version: 1.7.1 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.6.2/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.7.1/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds From 576a2ee7aef42203fd66064a8e40d991e611f90b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 11:58:10 -0700 Subject: [PATCH 411/580] Stop checking the deprecated DOCKER_CLIENT_TIMEOUT variable Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 4 ---- compose/const.py | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 3e0873c4..bed6be79 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -45,10 +45,6 @@ def docker_client(environment, version=None, tls_config=None, host=None, Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. """ - if 'DOCKER_CLIENT_TIMEOUT' in environment: - log.warn("The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. " - "Please use COMPOSE_HTTP_TIMEOUT instead.") - try: kwargs = kwargs_from_env(environment=environment, ssl_version=tls_version) except TLSParameterError: diff --git a/compose/const.py b/compose/const.py index 9e00d96e..b930e0bf 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,11 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals -import os import sys DEFAULT_TIMEOUT = 10 -HTTP_TIMEOUT = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) +HTTP_TIMEOUT = 60 IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', 'tag', 'untag'] IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' From 4207d43b85c1c6b77bad82349b17fd060fa2abf4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 12:08:47 -0700 Subject: [PATCH 412/580] Fix timeout value in error message Signed-off-by: Aanand Prasad --- compose/cli/errors.py | 7 +++---- tests/unit/cli/docker_client_test.py | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 89a7a949..5af3ede9 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -13,7 +13,6 @@ from requests.exceptions import SSLError from requests.packages.urllib3.exceptions import ReadTimeoutError from ..const import API_VERSION_TO_ENGINE_VERSION -from ..const import HTTP_TIMEOUT from .utils import call_silently from .utils import is_docker_for_mac_installed from .utils import is_mac @@ -47,7 +46,7 @@ def handle_connection_errors(client): raise ConnectionError() except RequestsConnectionError as e: if e.args and isinstance(e.args[0], ReadTimeoutError): - log_timeout_error() + log_timeout_error(client.timeout) raise ConnectionError() exit_with_error(get_conn_error_message(client.base_url)) except APIError as e: @@ -58,13 +57,13 @@ def handle_connection_errors(client): raise ConnectionError() -def log_timeout_error(): +def log_timeout_error(timeout): log.error( "An HTTP request took too long to complete. Retry with --verbose to " "obtain debug information.\n" "If you encounter this issue regularly because of slow network " "conditions, consider setting COMPOSE_HTTP_TIMEOUT to a higher " - "value (current value: %s)." % HTTP_TIMEOUT) + "value (current value: %s)." % timeout) def log_api_error(e, client_version): diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 5334a944..74669d4a 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -6,6 +6,7 @@ import os import docker import pytest +from compose.cli import errors from compose.cli.docker_client import docker_client from compose.cli.docker_client import tls_config_from_options from tests import mock @@ -19,11 +20,25 @@ class DockerClientTestCase(unittest.TestCase): del os.environ['HOME'] docker_client(os.environ) + @mock.patch.dict(os.environ) def test_docker_client_with_custom_timeout(self): - timeout = 300 - with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300): - client = docker_client(os.environ) - self.assertEqual(client.timeout, int(timeout)) + os.environ['COMPOSE_HTTP_TIMEOUT'] = '123' + client = docker_client(os.environ) + assert client.timeout == 123 + + @mock.patch.dict(os.environ) + def test_custom_timeout_error(self): + os.environ['COMPOSE_HTTP_TIMEOUT'] = '123' + client = docker_client(os.environ) + + with mock.patch('compose.cli.errors.log') as fake_log: + with pytest.raises(errors.ConnectionError): + with errors.handle_connection_errors(client): + raise errors.RequestsConnectionError( + errors.ReadTimeoutError(None, None, None)) + + assert fake_log.error.call_count == 1 + assert '123' in fake_log.error.call_args[0][0] class TLSConfigTestCase(unittest.TestCase): From fea970dff3df60c7579eb06959444160a570927e Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Thu, 21 Apr 2016 14:03:02 +0200 Subject: [PATCH 413/580] service: detailed error messages for create and start Fixes: #3355 Signed-off-by: Tomas Tomecek --- compose/cli/main.py | 4 +++- compose/errors.py | 7 +++++++ compose/parallel.py | 4 ++++ compose/service.py | 12 ++++++++++-- tests/integration/service_test.py | 5 ++++- 5 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 compose/errors.py diff --git a/compose/cli/main.py b/compose/cli/main.py index c924d89d..ed15d6a5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -32,6 +32,7 @@ from ..service import BuildError from ..service import ConvergenceStrategy from ..service import ImageType from ..service import NeedsBuildError +from ..service import OperationFailedError from .command import get_config_from_options from .command import project_from_options from .docopt_command import DocoptDispatcher @@ -61,7 +62,8 @@ def main(): except (KeyboardInterrupt, signals.ShutdownException): log.error("Aborting.") sys.exit(1) - except (UserError, NoSuchService, ConfigurationError, ProjectError) as e: + except (UserError, NoSuchService, ConfigurationError, + ProjectError, OperationFailedError) as e: log.error(e.msg) sys.exit(1) except BuildError as e: diff --git a/compose/errors.py b/compose/errors.py new file mode 100644 index 00000000..9f68760d --- /dev/null +++ b/compose/errors.py @@ -0,0 +1,7 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + + +class OperationFailedError(Exception): + def __init__(self, reason): + self.msg = reason diff --git a/compose/parallel.py b/compose/parallel.py index 50b2dbea..7ac66b37 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -12,6 +12,7 @@ from six.moves.queue import Empty from six.moves.queue import Queue from compose.cli.signals import ShutdownException +from compose.errors import OperationFailedError from compose.utils import get_output_stream @@ -47,6 +48,9 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation writer.write(get_name(obj), 'error') + elif isinstance(exception, OperationFailedError): + errors[get_name(obj)] = exception.msg + writer.write(get_name(obj), 'error') elif isinstance(exception, UpstreamError): writer.write(get_name(obj), 'error') else: diff --git a/compose/service.py b/compose/service.py index 73381466..60343542 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,6 +27,7 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_VERSION from .container import Container +from .errors import OperationFailedError from .parallel import parallel_execute from .parallel import parallel_start from .progress_stream import stream_output @@ -278,7 +279,11 @@ class Service(object): if 'name' in container_options and not quiet: log.info("Creating %s" % container_options['name']) - return Container.create(self.client, **container_options) + try: + return Container.create(self.client, **container_options) + except APIError as ex: + raise OperationFailedError("Cannot create container for service %s: %s" % + (self.name, ex.explanation)) def ensure_image_exists(self, do_build=BuildAction.none): if self.can_be_built() and do_build == BuildAction.force: @@ -448,7 +453,10 @@ class Service(object): def start_container(self, container): self.connect_container_to_networks(container) - container.start() + try: + container.start() + except APIError as ex: + raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) return container def connect_container_to_networks(self, container): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7d2f03d3..97ad7476 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -738,7 +738,10 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.assertIn("ERROR: for composetest_web_2 Boom", mock_stderr.getvalue()) + self.assertIn( + "ERROR: for composetest_web_2 Cannot create container for service web: Boom", + mock_stderr.getvalue() + ) def test_scale_with_unexpected_exception(self): """Test that when scaling if the API returns an error, that is not of type From 83f35e132b37bf20baec264e49905c3ecc944ace Mon Sep 17 00:00:00 2001 From: Jonathan Giannuzzi Date: Mon, 11 Jul 2016 11:34:01 +0200 Subject: [PATCH 414/580] Add support for creating internal networks Signed-off-by: Jonathan Giannuzzi --- compose/config/config_schema_v2.0.json | 3 ++- compose/network.py | 5 +++- docs/compose-file.md | 6 ++++- tests/acceptance/cli_test.py | 18 +++++++++++++ tests/fixtures/networks/network-internal.yml | 13 ++++++++++ tests/integration/project_test.py | 27 ++++++++++++++++++++ tests/unit/config/config_test.py | 8 ++++++ 7 files changed, 77 insertions(+), 3 deletions(-) create mode 100755 tests/fixtures/networks/network-internal.yml diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index c08fa4d7..ac46944c 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -246,7 +246,8 @@ "name": {"type": "string"} }, "additionalProperties": false - } + }, + "internal": {"type": "boolean"} }, "additionalProperties": false }, diff --git a/compose/network.py b/compose/network.py index affba7c2..8962a892 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None): + ipam=None, external_name=None, internal=False): self.client = client self.project = project self.name = name @@ -23,6 +23,7 @@ class Network(object): self.driver_opts = driver_opts self.ipam = create_ipam_config_from_dict(ipam) self.external_name = external_name + self.internal = internal def ensure(self): if self.external_name: @@ -68,6 +69,7 @@ class Network(object): driver=self.driver, options=self.driver_opts, ipam=self.ipam, + internal=self.internal, ) def remove(self): @@ -115,6 +117,7 @@ def build_networks(name, config_data, client): driver_opts=data.get('driver_opts'), ipam=data.get('ipam'), external_name=data.get('external_name'), + internal=data.get('internal'), ) for network_name, data in network_config.items() } diff --git a/docs/compose-file.md b/docs/compose-file.md index f7b5a931..59fcf331 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -859,6 +859,10 @@ A full example: host2: 172.28.1.6 host3: 172.28.1.7 +### internal + +By default, Docker also connects a bridge network to it to provide external connectivity. If you want to create an externally isolated overlay network, you can set this option to `true`. + ### external If set to `true`, specifies that this network has been created outside of @@ -866,7 +870,7 @@ Compose. `docker-compose up` will not attempt to create it, and will raise an error if it doesn't exist. `external` cannot be used in conjunction with other network configuration keys -(`driver`, `driver_opts`, `ipam`). +(`driver`, `driver_opts`, `ipam`, `internal`). In the example below, `proxy` is the gateway to the outside world. Instead of attemping to create a network called `[projectname]_outside`, Compose will diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a8fd3249..dad23bec 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -576,6 +576,24 @@ class CLITestCase(DockerClientTestCase): assert 'forward_facing' in front_aliases assert 'ahead' in front_aliases + @v2_only() + def test_up_with_network_internal(self): + self.require_api_version('1.23') + filename = 'network-internal.yml' + self.base_dir = 'tests/fixtures/networks' + self.dispatch(['-f', filename, 'up', '-d'], None) + internal_net = '{}_internal'.format(self.project.name) + + networks = [ + n for n in self.client.networks() + if n['Name'].startswith('{}_'.format(self.project.name)) + ] + + # One network was created: internal + assert sorted(n['Name'] for n in networks) == [internal_net] + + assert networks[0]['Internal'] is True + @v2_only() def test_up_with_network_static_addresses(self): filename = 'network-static-addresses.yml' diff --git a/tests/fixtures/networks/network-internal.yml b/tests/fixtures/networks/network-internal.yml new file mode 100755 index 00000000..1fa339b1 --- /dev/null +++ b/tests/fixtures/networks/network-internal.yml @@ -0,0 +1,13 @@ +version: "2" + +services: + web: + image: busybox + command: top + networks: + - internal + +networks: + internal: + driver: bridge + internal: True diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6e82e931..80915c1a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -756,6 +756,33 @@ class ProjectTest(DockerClientTestCase): with self.assertRaises(ProjectError): project.up() + @v2_only() + def test_project_up_with_network_internal(self): + self.require_api_version('1.23') + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': {'internal': None}, + }], + volumes={}, + networks={ + 'internal': {'driver': 'bridge', 'internal': True}, + }, + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + project.up() + + network = self.client.networks(names=['composetest_internal'])[0] + + assert network['Internal'] is True + @v2_only() def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1be8aefa..d88c1d47 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -101,6 +101,10 @@ class ConfigTest(unittest.TestCase): {'subnet': '172.28.0.0/16'} ] } + }, + 'internal': { + 'driver': 'bridge', + 'internal': True } } }, 'working_dir', 'filename.yml') @@ -140,6 +144,10 @@ class ConfigTest(unittest.TestCase): {'subnet': '172.28.0.0/16'} ] } + }, + 'internal': { + 'driver': 'bridge', + 'internal': True } }) From 593d1aeb09a10f4c28f848c7d8d7fc62ef09f6ae Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Jul 2016 16:13:16 -0400 Subject: [PATCH 415/580] Fix bugs with entrypoint/command in docker-compose run - When no command is passed but `--entrypoint` is, set Cmd to `[]` - When command is a single empty string, set Cmd to `[""]` Signed-off-by: Aanand Prasad --- compose/cli/main.py | 4 +- tests/acceptance/cli_test.py | 59 +++++++++++++++---- .../docker-compose.yml | 2 - .../entrypoint-composefile/docker-compose.yml | 6 ++ .../Dockerfile | 3 +- .../entrypoint-dockerfile/docker-compose.yml | 4 ++ 6 files changed, 63 insertions(+), 15 deletions(-) delete mode 100644 tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml create mode 100644 tests/fixtures/entrypoint-composefile/docker-compose.yml rename tests/fixtures/{dockerfile_with_entrypoint => entrypoint-dockerfile}/Dockerfile (57%) create mode 100644 tests/fixtures/entrypoint-dockerfile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index c924d89d..e33bbe6e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -668,8 +668,10 @@ class TopLevelCommand(object): 'can not be used together' ) - if options['COMMAND']: + if options['COMMAND'] is not None: command = [options['COMMAND']] + options['ARGS'] + elif options['--entrypoint'] is not None: + command = [] else: command = service.options.get('command') diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dad23bec..a0d1702c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import datetime import json import os -import shlex import signal import subprocess import time @@ -983,16 +982,54 @@ class CLITestCase(DockerClientTestCase): [u'/bin/true'], ) - def test_run_service_with_entrypoint_overridden(self): - self.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' - name = 'service' - self.dispatch(['run', '--entrypoint', '/bin/echo', name, 'helloworld']) - service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - self.assertEqual( - shlex.split(container.human_readable_command), - [u'/bin/echo', u'helloworld'], - ) + def test_run_service_with_dockerfile_entrypoint(self): + self.base_dir = 'tests/fixtures/entrypoint-dockerfile' + self.dispatch(['run', 'test']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['printf'] + assert container.get('Config.Cmd') == ['default', 'args'] + + def test_run_service_with_dockerfile_entrypoint_overridden(self): + self.base_dir = 'tests/fixtures/entrypoint-dockerfile' + self.dispatch(['run', '--entrypoint', 'echo', 'test']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['echo'] + assert not container.get('Config.Cmd') + + def test_run_service_with_dockerfile_entrypoint_and_command_overridden(self): + self.base_dir = 'tests/fixtures/entrypoint-dockerfile' + self.dispatch(['run', '--entrypoint', 'echo', 'test', 'foo']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['echo'] + assert container.get('Config.Cmd') == ['foo'] + + def test_run_service_with_compose_file_entrypoint(self): + self.base_dir = 'tests/fixtures/entrypoint-composefile' + self.dispatch(['run', 'test']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['printf'] + assert container.get('Config.Cmd') == ['default', 'args'] + + def test_run_service_with_compose_file_entrypoint_overridden(self): + self.base_dir = 'tests/fixtures/entrypoint-composefile' + self.dispatch(['run', '--entrypoint', 'echo', 'test']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['echo'] + assert not container.get('Config.Cmd') + + def test_run_service_with_compose_file_entrypoint_and_command_overridden(self): + self.base_dir = 'tests/fixtures/entrypoint-composefile' + self.dispatch(['run', '--entrypoint', 'echo', 'test', 'foo']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['echo'] + assert container.get('Config.Cmd') == ['foo'] + + def test_run_service_with_compose_file_entrypoint_and_empty_string_command(self): + self.base_dir = 'tests/fixtures/entrypoint-composefile' + self.dispatch(['run', '--entrypoint', 'echo', 'test', '']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') == ['echo'] + assert container.get('Config.Cmd') == [''] def test_run_service_with_user_overridden(self): self.base_dir = 'tests/fixtures/user-composefile' diff --git a/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml deleted file mode 100644 index 78631502..00000000 --- a/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml +++ /dev/null @@ -1,2 +0,0 @@ -service: - build: . diff --git a/tests/fixtures/entrypoint-composefile/docker-compose.yml b/tests/fixtures/entrypoint-composefile/docker-compose.yml new file mode 100644 index 00000000..e9880973 --- /dev/null +++ b/tests/fixtures/entrypoint-composefile/docker-compose.yml @@ -0,0 +1,6 @@ +version: "2" +services: + test: + image: busybox + entrypoint: printf + command: default args diff --git a/tests/fixtures/dockerfile_with_entrypoint/Dockerfile b/tests/fixtures/entrypoint-dockerfile/Dockerfile similarity index 57% rename from tests/fixtures/dockerfile_with_entrypoint/Dockerfile rename to tests/fixtures/entrypoint-dockerfile/Dockerfile index e7454e59..49f4416c 100644 --- a/tests/fixtures/dockerfile_with_entrypoint/Dockerfile +++ b/tests/fixtures/entrypoint-dockerfile/Dockerfile @@ -1,3 +1,4 @@ FROM busybox:latest LABEL com.docker.compose.test_image=true -ENTRYPOINT echo "From prebuilt entrypoint" +ENTRYPOINT ["printf"] +CMD ["default", "args"] diff --git a/tests/fixtures/entrypoint-dockerfile/docker-compose.yml b/tests/fixtures/entrypoint-dockerfile/docker-compose.yml new file mode 100644 index 00000000..8318e61f --- /dev/null +++ b/tests/fixtures/entrypoint-dockerfile/docker-compose.yml @@ -0,0 +1,4 @@ +version: "2" +services: + test: + build: . From 907b0690e6f9f1882297d02eb77266910217af11 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 20 Jul 2016 12:23:51 +0100 Subject: [PATCH 416/580] Clarify environment and env_file docs Add note to say that environment variables will not be automatically made available at build time, and point to the `args` documentation. Signed-off-by: Aanand Prasad --- docs/compose-file.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index 59fcf331..d286257d 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -276,6 +276,11 @@ beginning with `#` (i.e. comments) are ignored, as are blank lines. # Set Rails/Rack environment RACK_ENV=development +> **Note:** If your service specifies a [build](#build) option, variables +> defined in environment files will _not_ be automatically visible during the +> build. Use the [args](#args) sub-option of `build` to define build-time +> environment variables. + ### environment Add environment variables. You can use either an array or a dictionary. Any @@ -295,6 +300,11 @@ machine Compose is running on, which can be helpful for secret or host-specific - SHOW=true - SESSION_SECRET +> **Note:** If your service specifies a [build](#build) option, variables +> defined in `environment` will _not_ be automatically visible during the +> build. Use the [args](#args) sub-option of `build` to define build-time +> environment variables. + ### expose Expose ports without publishing them to the host machine - they'll only be From 9ab1d55d06b3f8b8480c64a5c958c45a1361f286 Mon Sep 17 00:00:00 2001 From: Jarrod Pooler Date: Fri, 8 Jul 2016 15:56:15 -0400 Subject: [PATCH 417/580] Updating arg docs in the proper place Signed-off-by: Jarrod Pooler --- docs/compose-file.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index d286257d..fce3f1bc 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -119,6 +119,29 @@ Add build arguments. You can use either an array or a dictionary. Any boolean values; true, false, yes, no, need to be enclosed in quotes to ensure they are not converted to True or False by the YML parser. +First, specify the arguments in your Dockerfile: + + ARG buildno + ARG password + + RUN echo "Build number: $buildno" + RUN script-requiring-password.sh "$password" + +Then specify the arguments under the `build` key. You can pass either a mapping +or a list: + + build: + context: . + args: + buildno: 1 + password: secret + + build: + context: . + args: + - buildno=1 + - password=secret + Build arguments with only a key are resolved to their environment value on the machine Compose is running on. From 425303992c8a953440a16ebce4997cbf225b4d1a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 20 Jul 2016 12:47:47 +0100 Subject: [PATCH 418/580] Reorder/clarify args docs Signed-off-by: Aanand Prasad --- docs/compose-file.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index fce3f1bc..d6d0cadc 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -115,9 +115,8 @@ specified. > [Version 2 file format](#version-2) only. -Add build arguments. You can use either an array or a dictionary. Any -boolean values; true, false, yes, no, need to be enclosed in quotes to ensure -they are not converted to True or False by the YML parser. +Add build arguments, which are environment variables accessible only during the +build process. First, specify the arguments in your Dockerfile: @@ -142,18 +141,15 @@ or a list: - buildno=1 - password=secret -Build arguments with only a key are resolved to their environment value on the -machine Compose is running on. +You can omit the value when specifying a build argument, in which case its value +at build time is the value in the environment where Compose is running. - build: - args: - buildno: 1 - user: someuser + args: + - buildno + - password - build: - args: - - buildno=1 - - user=someuser +> **Note**: YAML boolean values (`true`, `false`, `yes`, `no`, `on`, `off`) must +> be enclosed in quotes, so that the parser interprets them as strings. ### cap_add, cap_drop From 6649e9aba3293272b99ce68232cac4bdff0dc5f3 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Wed, 20 Jul 2016 12:45:02 +0100 Subject: [PATCH 419/580] tearDown the project override at the end of each test case self._project.client is a docker.client.Client, so creating a new self._project leaks (via the embedded connection pool) a bunch of Unix socket file descriptors for each test which overrides self.project using this mechanism. In my tests I observed the test harness using 800-900 file descriptor, which is OK on Linux with the default limit of 1024 but breaks on OSX (e.g. with Docker4Mac) where the default limit is only 256. The failure can be provoked on Linux too with `ulimit -n 256`. With this fix I have observed the process ending with ~100 file descriptors open, including 83 Unix sockets, so I think there is likely at least one more leak lurking. Signed-off-by: Ian Campbell --- tests/acceptance/cli_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dad23bec..84d401e3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -114,6 +114,8 @@ class CLITestCase(DockerClientTestCase): for n in networks: if n['Name'].startswith('{}_'.format(self.project.name)): self.client.remove_network(n['Name']) + if hasattr(self, '_project'): + del self._project super(CLITestCase, self).tearDown() From 0483bcb472e2b765fdff4fca1545b32704f93557 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Wed, 20 Jul 2016 15:51:22 +0100 Subject: [PATCH 420/580] delete DockerClientTestCase.client class attribute on tearDownClass This is a docker.client.Client and therefore contains a connection pool, so each subclass of DockerClientTestCase can end up holding on to up to 10 Unix socket file descriptors after the tests contained in the sub-class are complete. Before this by the end of a test run I was seeing ~100 open file descriptors, ~80 of which were Unix domain sockets. By cleaning these up only 15 Unix sockets remain at the end (out of ~25 fds, the rest of which are the Python interpretter, opened libraries etc). Signed-off-by: Ian Campbell --- tests/integration/testcases.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 8d69d531..3e33a6c0 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -63,6 +63,10 @@ class DockerClientTestCase(unittest.TestCase): cls.client = docker_client(Environment(), version) + @classmethod + def tearDownClass(cls): + del cls.client + def tearDown(self): for c in self.client.containers( all=True, From 5cdf30fc12a84bbb88df390a376d3ba75066b01f Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Thu, 21 Jul 2016 12:18:33 +0100 Subject: [PATCH 421/580] Teardown project and db in ResilienceTest These hold a reference to a docker.client.Client object and therefore a connection pool which leaves fds open once the test has completed. Signed-off-by: Ian Campbell --- tests/integration/resilience_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index b544783a..2a2d1b56 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -20,6 +20,11 @@ class ResilienceTest(DockerClientTestCase): self.db.start_container(container) self.host_path = container.get_mount('/var/db')['Source'] + def tearDown(self): + del self.project + del self.db + super(ResilienceTest, self).tearDown() + def test_successful_recreate(self): self.project.up(strategy=ConvergenceStrategy.always) container = self.db.containers()[0] From 3124fec01a5c89b52dfcbbf837058e9e5635e631 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Thu, 21 Jul 2016 12:21:43 +0100 Subject: [PATCH 422/580] tearDown tmp_volumes array itself in VolumeTest Each volume in the array holds a reference to a docker.client.Client object and therefore a connection pool which leaves fds open once the test has completed. Signed-off-by: Ian Campbell --- tests/integration/volume_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 706179ed..04922ccd 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -17,6 +17,7 @@ class VolumeTest(DockerClientTestCase): self.client.remove_volume(volume.full_name) except DockerException: pass + del self.tmp_volumes def create_volume(self, name, driver=None, opts=None, external=None): if external and isinstance(external, bool): From d6f70dddc7376e2193ee27778f184022dec4014e Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Thu, 21 Jul 2016 12:24:24 +0100 Subject: [PATCH 423/580] Call the superclass tearDown in VolumeTest Currently it doesn't actually seem to make any practical difference that this is missing, but it seems like good practice to do so anyway, to be robust against future test case changes which might require cleanup done in the super class. Signed-off-by: Ian Campbell --- tests/integration/volume_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 04922ccd..a75250ac 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -18,6 +18,7 @@ class VolumeTest(DockerClientTestCase): except DockerException: pass del self.tmp_volumes + super(VolumeTest, self).tearDown() def create_volume(self, name, driver=None, opts=None, external=None): if external and isinstance(external, bool): From 07e2426d89750d8eddabe9537d527ac46197e753 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 13:38:04 +0100 Subject: [PATCH 424/580] Remove doc on experimental networking support Signed-off-by: Aanand Prasad --- experimental/compose_swarm_networking.md | 182 +---------------------- 1 file changed, 2 insertions(+), 180 deletions(-) diff --git a/experimental/compose_swarm_networking.md b/experimental/compose_swarm_networking.md index b1fb25dc..905f52f8 100644 --- a/experimental/compose_swarm_networking.md +++ b/experimental/compose_swarm_networking.md @@ -1,183 +1,5 @@ # Experimental: Compose, Swarm and Multi-Host Networking -The [experimental build of Docker](https://github.com/docker/docker/tree/master/experimental) has an entirely new networking system, which enables secure communication between containers on multiple hosts. In combination with Docker Swarm and Docker Compose, you can now run multi-container apps on multi-host clusters with the same tooling and configuration format you use to develop them locally. +Compose now supports multi-host networking as standard. Read more here: -> Note: This functionality is in the experimental stage, and contains some hacks and workarounds which will be removed as it matures. - -## Prerequisites - -Before you start, you’ll need to install the experimental build of Docker, and the latest versions of Machine and Compose. - -- To install the experimental Docker build on a Linux machine, follow the instructions [here](https://github.com/docker/docker/tree/master/experimental#install-docker-experimental). - -- To install the experimental Docker build on a Mac, run these commands: - - $ curl -L https://experimental.docker.com/builds/Darwin/x86_64/docker-latest > /usr/local/bin/docker - $ chmod +x /usr/local/bin/docker - -- To install Machine, follow the instructions [here](https://docs.docker.com/machine/install-machine/). - -- To install Compose, follow the instructions [here](https://docs.docker.com/compose/install/). - -You’ll also need a [Docker Hub](https://hub.docker.com/account/signup/) account and a [Digital Ocean](https://www.digitalocean.com/) account. - -## Set up a swarm with multi-host networking - -Set the `DIGITALOCEAN_ACCESS_TOKEN` environment variable to a valid Digital Ocean API token, which you can generate in the [API panel](https://cloud.digitalocean.com/settings/applications). - - DIGITALOCEAN_ACCESS_TOKEN=abc12345 - -Start a consul server: - - docker-machine create -d digitalocean --engine-install-url https://experimental.docker.com consul - docker $(docker-machine config consul) run -d -p 8500:8500 -h consul progrium/consul -server -bootstrap - -(In a real world setting you’d set up a distributed consul, but that’s beyond the scope of this guide!) - -Create a Swarm token: - - SWARM_TOKEN=$(docker run swarm create) - -Create a Swarm master: - - docker-machine create -d digitalocean --swarm --swarm-master --swarm-discovery=token://$SWARM_TOKEN --engine-install-url="https://experimental.docker.com" --digitalocean-image "ubuntu-14-10-x64" --engine-opt=default-network=overlay:multihost --engine-label=com.docker.network.driver.overlay.bind_interface=eth0 --engine-opt=kv-store=consul:$(docker-machine ip consul):8500 swarm-0 - -Create a Swarm node: - - docker-machine create -d digitalocean --swarm --swarm-discovery=token://$SWARM_TOKEN --engine-install-url="https://experimental.docker.com" --digitalocean-image "ubuntu-14-10-x64" --engine-opt=default-network=overlay:multihost --engine-label=com.docker.network.driver.overlay.bind_interface=eth0 --engine-opt=kv-store=consul:$(docker-machine ip consul):8500 --engine-label com.docker.network.driver.overlay.neighbor_ip=$(docker-machine ip swarm-0) swarm-1 - -You can create more Swarm nodes if you want - it’s best to give them sensible names (swarm-2, swarm-3, etc). - -Finally, point Docker at your swarm: - - eval "$(docker-machine env --swarm swarm-0)" - -## Run containers and get them communicating - -Now that you’ve got a swarm up and running, you can create containers on it just like a single Docker instance: - - $ docker run busybox echo hello world - hello world - -If you run `docker ps -a`, you can see what node that container was started on by looking at its name (here it’s swarm-3): - - $ docker ps -a - CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES - 41f59749737b busybox "echo hello world" 15 seconds ago Exited (0) 13 seconds ago swarm-3/trusting_leakey - -As you start more containers, they’ll be placed on different nodes across the cluster, thanks to Swarm’s default “spread” scheduling strategy. - -Every container started on this swarm will use the “overlay:multihost” network by default, meaning they can all intercommunicate. Each container gets an IP address on that network, and an `/etc/hosts` file which will be updated on-the-fly with every other container’s IP address and name. That means that if you have a running container named ‘foo’, other containers can access it at the hostname ‘foo’. - -Let’s verify that multi-host networking is functioning. Start a long-running container: - - $ docker run -d --name long-running busybox top - - -If you start a new container and inspect its /etc/hosts file, you’ll see the long-running container in there: - - $ docker run busybox cat /etc/hosts - ... - 172.21.0.6 long-running - -Verify that connectivity works between containers: - - $ docker run busybox ping long-running - PING long-running (172.21.0.6): 56 data bytes - 64 bytes from 172.21.0.6: seq=0 ttl=64 time=7.975 ms - 64 bytes from 172.21.0.6: seq=1 ttl=64 time=1.378 ms - 64 bytes from 172.21.0.6: seq=2 ttl=64 time=1.348 ms - ^C - --- long-running ping statistics --- - 3 packets transmitted, 3 packets received, 0% packet loss - round-trip min/avg/max = 1.140/2.099/7.975 ms - -## Run a Compose application - -Here’s an example of a simple Python + Redis app using multi-host networking on a swarm. - -Create a directory for the app: - - $ mkdir composetest - $ cd composetest - -Inside this directory, create 2 files. - -First, create `app.py` - a simple web app that uses the Flask framework and increments a value in Redis: - - from flask import Flask - from redis import Redis - import os - app = Flask(__name__) - redis = Redis(host='composetest_redis_1', port=6379) - - @app.route('/') - def hello(): - redis.incr('hits') - return 'Hello World! I have been seen %s times.' % redis.get('hits') - - if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) - -Note that we’re connecting to a host called `composetest_redis_1` - this is the name of the Redis container that Compose will start. - -Second, create a Dockerfile for the app container: - - FROM python:2.7 - RUN pip install flask redis - ADD . /code - WORKDIR /code - CMD ["python", "app.py"] - -Build the Docker image and push it to the Hub (you’ll need a Hub account). Replace `` with your Docker Hub username: - - $ docker build -t /counter . - $ docker push /counter - -Next, create a `docker-compose.yml`, which defines the configuration for the web and redis containers. Once again, replace `` with your Hub username: - - web: - image: /counter - ports: - - "80:5000" - redis: - image: redis - -Now start the app: - - $ docker-compose up -d - Pulling web (username/counter:latest)... - swarm-0: Pulling username/counter:latest... : downloaded - swarm-2: Pulling username/counter:latest... : downloaded - swarm-1: Pulling username/counter:latest... : downloaded - swarm-3: Pulling username/counter:latest... : downloaded - swarm-4: Pulling username/counter:latest... : downloaded - Creating composetest_web_1... - Pulling redis (redis:latest)... - swarm-2: Pulling redis:latest... : downloaded - swarm-1: Pulling redis:latest... : downloaded - swarm-3: Pulling redis:latest... : downloaded - swarm-4: Pulling redis:latest... : downloaded - swarm-0: Pulling redis:latest... : downloaded - Creating composetest_redis_1... - -Swarm has created containers for both web and redis, and placed them on different nodes, which you can check with `docker ps`: - - $ docker ps - CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES - 92faad2135c9 redis "/entrypoint.sh redi 43 seconds ago Up 42 seconds swarm-2/composetest_redis_1 - adb809e5cdac username/counter "/bin/sh -c 'python 55 seconds ago Up 54 seconds 45.67.8.9:80->5000/tcp swarm-1/composetest_web_1 - -You can also see that the web container has exposed port 80 on its swarm node. If you curl that IP, you’ll get a response from the container: - - $ curl http://45.67.8.9 - Hello World! I have been seen 1 times. - -If you hit it repeatedly, the counter will increment, demonstrating that the web and redis container are communicating: - - $ curl http://45.67.8.9 - Hello World! I have been seen 2 times. - $ curl http://45.67.8.9 - Hello World! I have been seen 3 times. - $ curl http://45.67.8.9 - Hello World! I have been seen 4 times. +https://docs.docker.com/compose/networking From 2c9e46f60fbe8ddcb4562c1e118f190654d01522 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 1 Jul 2016 13:54:27 -0700 Subject: [PATCH 425/580] Show a warning when engine is in swarm mode Signed-off-by: Aanand Prasad --- compose/project.py | 16 ++++++++++++++++ tests/unit/project_test.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/compose/project.py b/compose/project.py index 676b6ae8..256fb9c0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -369,6 +369,8 @@ class Project(object): detached=False, remove_orphans=False): + warn_for_swarm_mode(self.client) + self.initialize() self.find_orphan_containers(remove_orphans) @@ -533,6 +535,20 @@ def get_volumes_from(project, service_dict): return [build_volume_from(vf) for vf in volumes_from] +def warn_for_swarm_mode(client): + info = client.info() + if info.get('Swarm', {}).get('LocalNodeState') == 'active': + log.warn( + "The Docker Engine you're using is running in swarm mode.\n\n" + "Compose does not use swarm mode to deploy services to multiple nodes in a swarm. " + "All containers will be scheduled on the current node.\n\n" + "To deploy your application across the swarm, " + "use the bundle feature of the Docker experimental build.\n\n" + "More info:\n" + "https://github.com/docker/docker/tree/master/experimental\n" + ) + + class NoSuchService(Exception): def __init__(self, name): self.name = name diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b6a52e08..9569adc9 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -510,3 +510,35 @@ class ProjectTest(unittest.TestCase): project.down(ImageType.all, True) self.mock_client.remove_image.assert_called_once_with("busybox:latest") + + def test_warning_in_swarm_mode(self): + self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'active'}} + project = Project('composetest', [], self.mock_client) + + with mock.patch('compose.project.log') as fake_log: + project.up() + assert fake_log.warn.call_count == 1 + + def test_no_warning_on_stop(self): + self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'active'}} + project = Project('composetest', [], self.mock_client) + + with mock.patch('compose.project.log') as fake_log: + project.stop() + assert fake_log.warn.call_count == 0 + + def test_no_warning_in_normal_mode(self): + self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'inactive'}} + project = Project('composetest', [], self.mock_client) + + with mock.patch('compose.project.log') as fake_log: + project.up() + assert fake_log.warn.call_count == 0 + + def test_no_warning_with_no_swarm_info(self): + self.mock_client.info.return_value = {} + project = Project('composetest', [], self.mock_client) + + with mock.patch('compose.project.log') as fake_log: + project.up() + assert fake_log.warn.call_count == 0 From 583bbb463561807c2983669fbae4c89b21081632 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 13:46:50 +0100 Subject: [PATCH 426/580] Copy experimental bundle docs into Compose docs so URL is stable Signed-off-by: Aanand Prasad --- compose/project.py | 2 +- docs/bundles.md | 199 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 docs/bundles.md diff --git a/compose/project.py b/compose/project.py index 256fb9c0..f85e285f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -545,7 +545,7 @@ def warn_for_swarm_mode(client): "To deploy your application across the swarm, " "use the bundle feature of the Docker experimental build.\n\n" "More info:\n" - "https://github.com/docker/docker/tree/master/experimental\n" + "https://docs.docker.com/compose/bundles\n" ) diff --git a/docs/bundles.md b/docs/bundles.md new file mode 100644 index 00000000..0958e1ef --- /dev/null +++ b/docs/bundles.md @@ -0,0 +1,199 @@ + + + +# Docker Stacks and Distributed Application Bundles (experimental) + +> **Note**: This is a copy of the [Docker Stacks and Distributed Application +> Bundles](https://github.com/docker/docker/blob/v1.12.0-rc4/experimental/docker-stacks-and-bundles.md) +> document in the [docker/docker repo](https://github.com/docker/docker). + +## Overview + +Docker Stacks and Distributed Application Bundles are experimental features +introduced in Docker 1.12 and Docker Compose 1.8, alongside the concept of +swarm mode, and Nodes and Services in the Engine API. + +A Dockerfile can be built into an image, and containers can be created from +that image. Similarly, a docker-compose.yml can be built into a **distributed +application bundle**, and **stacks** can be created from that bundle. In that +sense, the bundle is a multi-services distributable image format. + +As of Docker 1.12 and Compose 1.8, the features are experimental. Neither +Docker Engine nor the Docker Registry support distribution of bundles. + +## Producing a bundle + +The easiest way to produce a bundle is to generate it using `docker-compose` +from an existing `docker-compose.yml`. Of course, that's just *one* possible way +to proceed, in the same way that `docker build` isn't the only way to produce a +Docker image. + +From `docker-compose`: + +```bash +$ docker-compose bundle +WARNING: Unsupported key 'network_mode' in services.nsqd - ignoring +WARNING: Unsupported key 'links' in services.nsqd - ignoring +WARNING: Unsupported key 'volumes' in services.nsqd - ignoring +[...] +Wrote bundle to vossibility-stack.dab +``` + +## Creating a stack from a bundle + +A stack is created using the `docker deploy` command: + +```bash +# docker deploy --help + +Usage: docker deploy [OPTIONS] STACK + +Create and update a stack + +Options: + --file string Path to a Distributed Application Bundle file (Default: STACK.dab) + --help Print usage + --with-registry-auth Send registry authentication details to Swarm agents +``` + +Let's deploy the stack created before: + +```bash +# docker deploy vossibility-stack +Loading bundle from vossibility-stack.dab +Creating service vossibility-stack_elasticsearch +Creating service vossibility-stack_kibana +Creating service vossibility-stack_logstash +Creating service vossibility-stack_lookupd +Creating service vossibility-stack_nsqd +Creating service vossibility-stack_vossibility-collector +``` + +We can verify that services were correctly created: + +```bash +# docker service ls +ID NAME REPLICAS IMAGE +COMMAND +29bv0vnlm903 vossibility-stack_lookupd 1 nsqio/nsq@sha256:eeba05599f31eba418e96e71e0984c3dc96963ceb66924dd37a47bf7ce18a662 /nsqlookupd +4awt47624qwh vossibility-stack_nsqd 1 nsqio/nsq@sha256:eeba05599f31eba418e96e71e0984c3dc96963ceb66924dd37a47bf7ce18a662 /nsqd --data-path=/data --lookupd-tcp-address=lookupd:4160 +4tjx9biia6fs vossibility-stack_elasticsearch 1 elasticsearch@sha256:12ac7c6af55d001f71800b83ba91a04f716e58d82e748fa6e5a7359eed2301aa +7563uuzr9eys vossibility-stack_kibana 1 kibana@sha256:6995a2d25709a62694a937b8a529ff36da92ebee74bafd7bf00e6caf6db2eb03 +9gc5m4met4he vossibility-stack_logstash 1 logstash@sha256:2dc8bddd1bb4a5a34e8ebaf73749f6413c101b2edef6617f2f7713926d2141fe logstash -f /etc/logstash/conf.d/logstash.conf +axqh55ipl40h vossibility-stack_vossibility-collector 1 icecrime/vossibility-collector@sha256:f03f2977203ba6253988c18d04061c5ec7aab46bca9dfd89a9a1fa4500989fba --config /config/config.toml --debug +``` + +## Managing stacks + +Stacks are managed using the `docker stack` command: + +```bash +# docker stack --help + +Usage: docker stack COMMAND + +Manage Docker stacks + +Options: + --help Print usage + +Commands: + config Print the stack configuration + deploy Create and update a stack + rm Remove the stack + services List the services in the stack + tasks List the tasks in the stack + +Run 'docker stack COMMAND --help' for more information on a command. +``` + +## Bundle file format + +Distributed application bundles are described in a JSON format. When bundles +are persisted as files, the file extension is `.dab` (Docker 1.12RC2 tools use +`.dsb` for the file extension—this will be updated in the next release client). + +A bundle has two top-level fields: `version` and `services`. The version used +by Docker 1.12 tools is `0.1`. + +`services` in the bundle are the services that comprise the app. They +correspond to the new `Service` object introduced in the 1.12 Docker Engine API. + +A service has the following fields: + +

+
+ Image (required) string +
+
+ The image that the service will run. Docker images should be referenced + with full content hash to fully specify the deployment artifact for the + service. Example: + postgres@sha256:f76245b04ddbcebab5bb6c28e76947f49222c99fec4aadb0bb + 1c24821a 9e83ef +
+
+ Command []string +
+
+ Command to run in service containers. +
+
+ Args []string +
+
+ Arguments passed to the service containers. +
+
+ Env []string +
+
+ Environment variables. +
+
+ Labels map[string]string +
+
+ Labels used for setting meta data on services. +
+
+ Ports []Port +
+
+ Service ports (composed of Port (int) and + Protocol (string). A service description can + only specify the container port to be exposed. These ports can be + mapped on runtime hosts at the operator's discretion. +
+ +
+ WorkingDir string +
+
+ Working directory inside the service containers. +
+ +
+ User string +
+
+ Username or UID (format: <name|uid>[:<group|gid>]). +
+ +
+ Networks []string +
+
+ Networks that the service containers should be connected to. An entity + deploying a bundle should create networks as needed. +
+
From 887ed8d1b650ac18fac3f58213cd7cf897f5d885 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 16:48:28 +0100 Subject: [PATCH 427/580] Rename --fetch-digests to --push-images and remove auto-pull Signed-off-by: Aanand Prasad --- compose/bundle.py | 43 +++++++++++++++++------------------- compose/cli/main.py | 35 +++++++++++++++++++++-------- tests/unit/bundle_test.py | 46 ++++++++++++++------------------------- 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index 44f6954b..afbdabfa 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -60,7 +60,7 @@ def serialize_bundle(config, image_digests): return json.dumps(to_bundle(config, image_digests), indent=2, sort_keys=True) -def get_image_digests(project, allow_fetch=False): +def get_image_digests(project, allow_push=False): digests = {} needs_push = set() needs_pull = set() @@ -69,7 +69,7 @@ def get_image_digests(project, allow_fetch=False): try: digests[service.name] = get_image_digest( service, - allow_fetch=allow_fetch, + allow_push=allow_push, ) except NeedsPush as e: needs_push.add(e.image_name) @@ -82,7 +82,7 @@ def get_image_digests(project, allow_fetch=False): return digests -def get_image_digest(service, allow_fetch=False): +def get_image_digest(service, allow_push=False): if 'image' not in service.options: raise UserError( "Service '{s.name}' doesn't define an image tag. An image name is " @@ -108,27 +108,24 @@ def get_image_digest(service, allow_fetch=False): # digests return image['RepoDigests'][0] - if not allow_fetch: - if 'build' in service.options: - raise NeedsPush(service.image_name) - else: - raise NeedsPull(service.image_name) - - return fetch_image_digest(service) - - -def fetch_image_digest(service): if 'build' not in service.options: - digest = service.pull() - else: - try: - digest = service.push() - except: - log.error( - "Failed to push image for service '{s.name}'. Please use an " - "image tag that can be pushed to a Docker " - "registry.".format(s=service)) - raise + raise NeedsPull(service.image_name) + + if not allow_push: + raise NeedsPush(service.image_name) + + return push_image(service) + + +def push_image(service): + try: + digest = service.push() + except: + log.error( + "Failed to push image for service '{s.name}'. Please use an " + "image tag that can be pushed to a Docker " + "registry.".format(s=service)) + raise if not digest: raise ValueError("Failed to get digest for %s" % service.name) diff --git a/compose/cli/main.py b/compose/cli/main.py index 3f153d0d..db06a5e1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -223,15 +223,16 @@ class TopLevelCommand(object): Generate a Distributed Application Bundle (DAB) from the Compose file. Images must have digests stored, which requires interaction with a - Docker registry. If digests aren't stored for all images, you can pass - `--fetch-digests` to automatically fetch them. Images for services - with a `build` key will be pushed. Images for services without a - `build` key will be pulled. + Docker registry. If digests aren't stored for all images, you can fetch + them with `docker-compose pull` or `docker-compose push`. To push images + automatically when bundling, pass `--push-images`. Only services with + a `build` option specified will have their images pushed. Usage: bundle [options] Options: - --fetch-digests Automatically fetch image digests if missing + --push-images Automatically push images for any services + which have a `build` option specified. -o, --output PATH Path to write the bundle file to. Defaults to ".dab". @@ -247,7 +248,7 @@ class TopLevelCommand(object): try: image_digests = get_image_digests( self.project, - allow_fetch=options['--fetch-digests'], + allow_push=options['--push-images'], ) except MissingDigests as e: def list_images(images): @@ -256,12 +257,28 @@ class TopLevelCommand(object): paras = ["Some images are missing digests."] if e.needs_push: - paras += ["The following images need to be pushed:", list_images(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: - paras += ["The following images need to be pulled:", list_images(e.needs_pull)] + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) - paras.append("If this is OK, run `docker-compose bundle --fetch-digests`.") + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] raise UserError("\n\n".join(paras)) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index ff4c0dce..223b3b07 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -41,44 +41,30 @@ def test_get_image_digest_no_image(mock_service): assert "doesn't define an image tag" in exc.exconly() -def test_fetch_image_digest_for_image_with_saved_digest(mock_service): - mock_service.options['image'] = image_id = 'abcd' - mock_service.pull.return_value = expected = 'sha256:thedigest' - mock_service.image.return_value = {'RepoDigests': ['digest1']} - - digest = bundle.fetch_image_digest(mock_service) - assert digest == image_id + '@' + expected - - mock_service.pull.assert_called_once_with() - assert not mock_service.push.called - assert not mock_service.client.pull.called - - -def test_fetch_image_digest_for_image(mock_service): - mock_service.options['image'] = image_id = 'abcd' - mock_service.pull.return_value = expected = 'sha256:thedigest' - mock_service.image.return_value = {'RepoDigests': []} - - digest = bundle.fetch_image_digest(mock_service) - assert digest == image_id + '@' + expected - - mock_service.pull.assert_called_once_with() - assert not mock_service.push.called - mock_service.client.pull.assert_called_once_with(digest) - - -def test_fetch_image_digest_for_build(mock_service): +def test_push_image_with_saved_digest(mock_service): mock_service.options['build'] = '.' mock_service.options['image'] = image_id = 'abcd' mock_service.push.return_value = expected = 'sha256:thedigest' mock_service.image.return_value = {'RepoDigests': ['digest1']} - digest = bundle.fetch_image_digest(mock_service) + digest = bundle.push_image(mock_service) assert digest == image_id + '@' + expected mock_service.push.assert_called_once_with() - assert not mock_service.pull.called - assert not mock_service.client.pull.called + assert not mock_service.client.push.called + + +def test_push_image(mock_service): + mock_service.options['build'] = '.' + mock_service.options['image'] = image_id = 'abcd' + mock_service.push.return_value = expected = 'sha256:thedigest' + mock_service.image.return_value = {'RepoDigests': []} + + digest = bundle.push_image(mock_service) + assert digest == image_id + '@' + expected + + mock_service.push.assert_called_once_with() + mock_service.client.pull.assert_called_once_with(digest) def test_to_bundle(): From 8924f6c05ccd69468777dfabf23cbe9e21b0ed4a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jul 2016 11:51:16 +0100 Subject: [PATCH 428/580] Fix example image hash Signed-off-by: Aanand Prasad --- docs/bundles.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/bundles.md b/docs/bundles.md index 0958e1ef..a56adb02 100644 --- a/docs/bundles.md +++ b/docs/bundles.md @@ -138,8 +138,7 @@ A service has the following fields: The image that the service will run. Docker images should be referenced with full content hash to fully specify the deployment artifact for the service. Example: - postgres@sha256:f76245b04ddbcebab5bb6c28e76947f49222c99fec4aadb0bb - 1c24821a 9e83ef + postgres@sha256:e0a230a9f5b4e1b8b03bb3e8cf7322b0e42b7838c5c87f4545edb48f5eb8f077
Command []string From 28e6508f4a781bcc1b12841e9ed9e26f7ff1ba55 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jul 2016 11:51:30 +0100 Subject: [PATCH 429/580] Add note about missing volume mount support Signed-off-by: Aanand Prasad --- docs/bundles.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/bundles.md b/docs/bundles.md index a56adb02..19322824 100644 --- a/docs/bundles.md +++ b/docs/bundles.md @@ -196,3 +196,6 @@ A service has the following fields: deploying a bundle should create networks as needed. + +> **Note:** Some configuration options are not yet supported in the DAB format, +> including volume mounts. From 8ffbe8e0834697640239134575b460ea931b417d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jul 2016 11:59:04 +0100 Subject: [PATCH 430/580] Remove note about .dsb Signed-off-by: Aanand Prasad --- docs/bundles.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/bundles.md b/docs/bundles.md index 19322824..5ca2c1ec 100644 --- a/docs/bundles.md +++ b/docs/bundles.md @@ -119,8 +119,7 @@ Run 'docker stack COMMAND --help' for more information on a command. ## Bundle file format Distributed application bundles are described in a JSON format. When bundles -are persisted as files, the file extension is `.dab` (Docker 1.12RC2 tools use -`.dsb` for the file extension—this will be updated in the next release client). +are persisted as files, the file extension is `.dab`. A bundle has two top-level fields: `version` and `services`. The version used by Docker 1.12 tools is `0.1`. From 2fec6966d4c7cf72693fff73c06a52fbe743a042 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 24 Jun 2016 11:10:18 -0400 Subject: [PATCH 431/580] Add reference docs for push and bundle. Signed-off-by: Daniel Nephin --- docs/reference/bundle.md | 31 +++++++++++++++++++++++++++++++ docs/reference/push.md | 21 +++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/reference/bundle.md create mode 100644 docs/reference/push.md diff --git a/docs/reference/bundle.md b/docs/reference/bundle.md new file mode 100644 index 00000000..fca93a8a --- /dev/null +++ b/docs/reference/bundle.md @@ -0,0 +1,31 @@ + + +# bundle + +``` +Usage: bundle [options] + +Options: + --push-images Automatically push images for any services + which have a `build` option specified. + + -o, --output PATH Path to write the bundle file to. + Defaults to ".dab". +``` + +Generate a Distributed Application Bundle (DAB) from the Compose file. + +Images must have digests stored, which requires interaction with a +Docker registry. If digests aren't stored for all images, you can fetch +them with `docker-compose pull` or `docker-compose push`. To push images +automatically when bundling, pass `--push-images`. Only services with +a `build` option specified will have their images pushed. diff --git a/docs/reference/push.md b/docs/reference/push.md new file mode 100644 index 00000000..bdc3112e --- /dev/null +++ b/docs/reference/push.md @@ -0,0 +1,21 @@ + + +# push + +``` +Usage: push [options] [SERVICE...] + +Options: + --ignore-push-failures Push what it can and ignores images with push failures. +``` + +Pushes images for services. From 7f3375c2ce79a21a3665ccea51564a0c36b01a45 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Jul 2016 13:07:38 -0700 Subject: [PATCH 432/580] Update docker-py requirement to the latest release Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 60260e1c..831ed65a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.9.0rc2 +docker-py==1.9.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' diff --git a/setup.py b/setup.py index 3696adc6..5cb52dae 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py == 1.9.0rc2', + 'docker-py >= 1.9.0, < 2.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 1877a41b92eb887ace32579815278f607e95759a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 24 Jul 2016 18:57:36 +0100 Subject: [PATCH 433/580] Add user agent to API calls Signed-off-by: Ben Firshman --- compose/cli/docker_client.py | 3 +++ compose/cli/utils.py | 15 +++++++++++++++ tests/unit/cli/docker_client_test.py | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index bed6be79..ce191fbf 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -10,6 +10,7 @@ from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT from .errors import UserError +from .utils import generate_user_agent log = logging.getLogger(__name__) @@ -67,4 +68,6 @@ def docker_client(environment, version=None, tls_config=None, host=None, else: kwargs['timeout'] = HTTP_TIMEOUT + kwargs['user_agent'] = generate_user_agent() + return Client(**kwargs) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index bf5df80c..f60f61cd 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -107,3 +107,18 @@ def get_build_version(): def is_docker_for_mac_installed(): return is_mac() and os.path.isdir('/Applications/Docker.app') + + +def generate_user_agent(): + parts = [ + "docker-compose/{}".format(compose.__version__), + "docker-py/{}".format(docker.__version__), + ] + try: + p_system = platform.system() + p_release = platform.release() + except IOError: + pass + else: + parts.append("{}/{}".format(p_system, p_release)) + return " ".join(parts) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 74669d4a..fc914791 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -2,10 +2,12 @@ from __future__ import absolute_import from __future__ import unicode_literals import os +import platform import docker import pytest +import compose from compose.cli import errors from compose.cli.docker_client import docker_client from compose.cli.docker_client import tls_config_from_options @@ -40,6 +42,16 @@ class DockerClientTestCase(unittest.TestCase): assert fake_log.error.call_count == 1 assert '123' in fake_log.error.call_args[0][0] + def test_user_agent(self): + client = docker_client(os.environ) + expected = "docker-compose/{0} docker-py/{1} {2}/{3}".format( + compose.__version__, + docker.__version__, + platform.system(), + platform.release() + ) + self.assertEqual(client.headers['User-Agent'], expected) + class TLSConfigTestCase(unittest.TestCase): ca_cert = 'tests/fixtures/tls/ca.pem' From 6633f1962cba18e597f218eaa937e8b3d54dbf80 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 16:00:53 +0100 Subject: [PATCH 434/580] Shell completion for --push-images Signed-off-by: Aanand Prasad --- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 0201bcb2..991f6572 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -117,7 +117,7 @@ _docker_compose_bundle() { ;; esac - COMPREPLY=( $( compgen -W "--fetch-digests --help --output -o" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--push-images --help --output -o" -- "$cur" ) ) } diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 2947cef3..928e28de 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -207,6 +207,7 @@ __docker-compose_subcommand() { (bundle) _arguments \ $opts_help \ + '--push-images[Automatically push images for any services which have a `build` option specified.]' \ '(--output -o)'{--output,-o}'[Path to write the bundle file to. Defaults to ".dab".]:file:_files' && ret=0 ;; (config) From ec825af3d336a55297250b3a2af63b48fabed177 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 18:26:40 +0100 Subject: [PATCH 435/580] Fix error message for unrecognised TLS version Signed-off-by: Aanand Prasad --- compose/cli/command.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 09a9ced8..2c70d31a 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -65,8 +65,9 @@ def get_tls_version(environment): tls_attr_name = "PROTOCOL_{}".format(compose_tls_version) if not hasattr(ssl, tls_attr_name): log.warn( - 'The {} protocol is unavailable. You may need to update your ' + 'The "{}" protocol is unavailable. You may need to update your ' 'version of Python or OpenSSL. Falling back to TLSv1 (default).' + .format(compose_tls_version) ) return None From 22c0779a498ee701c22b857669d3f43a0d404f27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Jun 2016 11:33:45 -0700 Subject: [PATCH 436/580] Bump 1.8.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run/run.sh | 2 +- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0064a5cc..39ac8698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,56 @@ Change log ========== +1.8.0 (2016-06-14) +----------------- + +New Features + +- Added `docker-compose bundle`, a command that builds a bundle file + to be consumed by the new *Docker Stack* commands in Docker 1.12. + This command automatically pushes and pulls images as needed. + +- Added `docker-compose push`, a command that pushes service images + to a registry. + +- As announced in 1.7.0, `docker-compose rm` now removes containers + created by `docker-compose run` by default. + +- Compose now supports specifying a custom TLS version for + interaction with the Docker Engine using the `COMPOSE_TLS_VERSION` + environment variable. + +Bug Fixes + +- Fixed a bug where Compose would erroneously try to read `.env` + at the project's root when it is a directory. + +- Improved config merging when multiple compose files are involved + for several service sub-keys. + +- Fixed a bug where volume mappings containing Windows drives would + sometimes be parsed incorrectly. + +- Fixed a bug in Windows environment where volume mappings of the + host's root directory would be parsed incorrectly. + +- Fixed a bug where `docker-compose config` would ouput an invalid + Compose file if external networks were specified. + +- Fixed an issue where unset buildargs would be assigned a string + containing `'None'` instead of the expected empty value. + +- Fixed a bug where yes/no prompts on Windows would not show before + receiving input. + +- Fixed a bug where trying to `docker-compose exec` on Windows + without the `-d` option would exit with a stacktrace. This will + still fail for the time being, but should do so gracefully. + +- Fixed a bug where errors during `docker-compose up` would show + an unrelated stacktrace at the end of the process. + + 1.7.1 (2016-05-04) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index 1052c067..1dd11e79 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.8.0dev' +__version__ = '1.8.0-rc1' diff --git a/docs/install.md b/docs/install.md index 76e4a868..5191a4b5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.7.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.8.0-rc1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.7.1 + docker-compose version: 1.8.0-rc1 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.7.1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.8.0-rc1/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run/run.sh b/script/run/run.sh index 98d32c5f..f9199ce1 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.7.0" +VERSION="1.8.0-rc1" IMAGE="docker/compose:$VERSION" From 60622026fa54453d6c49c7a1bbfd3ed93692e0c5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 16:08:07 -0700 Subject: [PATCH 437/580] Bump 1.8.0-rc2 Signed-off-by: Aanand Prasad --- CHANGELOG.md | 8 +++++--- compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run/run.sh | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ac8698..afa35820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Change log 1.8.0 (2016-06-14) ----------------- +**Breaking Changes** + +- As announced in 1.7.0, `docker-compose rm` now removes containers + created by `docker-compose run` by default. + New Features - Added `docker-compose bundle`, a command that builds a bundle file @@ -13,9 +18,6 @@ New Features - Added `docker-compose push`, a command that pushes service images to a registry. -- As announced in 1.7.0, `docker-compose rm` now removes containers - created by `docker-compose run` by default. - - Compose now supports specifying a custom TLS version for interaction with the Docker Engine using the `COMPOSE_TLS_VERSION` environment variable. diff --git a/compose/__init__.py b/compose/__init__.py index 1dd11e79..bf8a6f30 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.8.0-rc1' +__version__ = '1.8.0-rc2' diff --git a/docs/install.md b/docs/install.md index 5191a4b5..d1a11ab5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.8.0-rc1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.8.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.8.0-rc1 + docker-compose version: 1.8.0-rc2 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.8.0-rc1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.8.0-rc2/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run/run.sh b/script/run/run.sh index f9199ce1..caf6ed11 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0-rc1" +VERSION="1.8.0-rc2" IMAGE="docker/compose:$VERSION" From 7fafd72c1e3497ad3e8265ffbac06dfce449d762 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 15:55:47 +0100 Subject: [PATCH 438/580] Bump 1.8.0-rc3 Signed-off-by: Aanand Prasad --- CHANGELOG.md | 12 +++++++++++- compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run/run.sh | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa35820..8ec7d5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,15 @@ Change log - As announced in 1.7.0, `docker-compose rm` now removes containers created by `docker-compose run` by default. +- Setting `entrypoint` on a service now empties out any default + command that was set on the image (i.e. any `CMD` instruction in the + Dockerfile used to build it). This makes it consistent with + the `--entrypoint` flag to `docker run`. + New Features - Added `docker-compose bundle`, a command that builds a bundle file to be consumed by the new *Docker Stack* commands in Docker 1.12. - This command automatically pushes and pulls images as needed. - Added `docker-compose push`, a command that pushes service images to a registry. @@ -27,6 +31,9 @@ Bug Fixes - Fixed a bug where Compose would erroneously try to read `.env` at the project's root when it is a directory. +- `docker-compose run -e VAR` now passes `VAR` through from the shell + to the container, as with `docker run -e VAR`. + - Improved config merging when multiple compose files are involved for several service sub-keys. @@ -52,6 +59,9 @@ Bug Fixes - Fixed a bug where errors during `docker-compose up` would show an unrelated stacktrace at the end of the process. +- `docker-compose create` and `docker-compose start` show more + descriptive error messages when something goes wrong. + 1.7.1 (2016-05-04) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index bf8a6f30..f9f0e6a6 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.8.0-rc2' +__version__ = '1.8.0-rc3' diff --git a/docs/install.md b/docs/install.md index d1a11ab5..2099b71c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.8.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.8.0-rc3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.8.0-rc2 + docker-compose version: 1.8.0-rc3 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.8.0-rc2/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.8.0-rc3/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run/run.sh b/script/run/run.sh index caf6ed11..c2c01db6 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0-rc2" +VERSION="1.8.0-rc3" IMAGE="docker/compose:$VERSION" From 1110af1bae8382ebce0a553da46cf8f95e46ed72 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Jul 2016 11:55:49 -0700 Subject: [PATCH 439/580] Bump 1.8.0 Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run/run.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/compose/__init__.py b/compose/__init__.py index f9f0e6a6..c550f990 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.8.0-rc3' +__version__ = '1.8.0' diff --git a/docs/install.md b/docs/install.md index 2099b71c..bb7f07b3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.8.0-rc3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.8.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.8.0-rc3 + docker-compose version: 1.8.0 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.8.0-rc3/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.8.0/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run/run.sh b/script/run/run.sh index c2c01db6..6205747a 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0-rc3" +VERSION="1.8.0" IMAGE="docker/compose:$VERSION" From 6ab0607e6182f7c4dec55b6318ab07af746e7c89 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Jul 2016 13:30:52 -0700 Subject: [PATCH 440/580] Switch back to dev version Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/__init__.py b/compose/__init__.py index c550f990..6e610652 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.8.0' +__version__ = '1.9.0dev' From 5aeeecb6f2044860f09be0bada7f4d75f489cb47 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jul 2016 17:12:40 +0100 Subject: [PATCH 441/580] Fix stacktrace when handling timeout error Signed-off-by: Aanand Prasad --- compose/cli/errors.py | 2 +- tests/unit/cli/docker_client_test.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 5af3ede9..f9a20b9e 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -53,7 +53,7 @@ def handle_connection_errors(client): log_api_error(e, client.api_version) raise ConnectionError() except (ReadTimeout, socket.timeout) as e: - log_timeout_error() + log_timeout_error(client.timeout) raise ConnectionError() diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index fc914791..3430c25c 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -42,6 +42,14 @@ class DockerClientTestCase(unittest.TestCase): assert fake_log.error.call_count == 1 assert '123' in fake_log.error.call_args[0][0] + with mock.patch('compose.cli.errors.log') as fake_log: + with pytest.raises(errors.ConnectionError): + with errors.handle_connection_errors(client): + raise errors.ReadTimeout() + + assert fake_log.error.call_count == 1 + assert '123' in fake_log.error.call_args[0][0] + def test_user_agent(self): client = docker_client(os.environ) expected = "docker-compose/{0} docker-py/{1} {2}/{3}".format( From 6f4be1cffc43d97d8070506286e0f15aec4c6b51 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Jul 2016 14:05:59 -0700 Subject: [PATCH 442/580] json_splitter: Don't break when buffer contains leading whitespace. Add error logging with detailed output for decode errors Signed-off-by: Joffrey F --- compose/utils.py | 12 +++++++++++- tests/unit/utils_test.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index 925a8e79..eea73be1 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -5,11 +5,13 @@ import codecs import hashlib import json import json.decoder +import logging import six json_decoder = json.JSONDecoder() +log = logging.getLogger(__name__) def get_output_stream(stream): @@ -60,13 +62,21 @@ def split_buffer(stream, splitter=None, decoder=lambda a: a): yield item if buffered: - yield decoder(buffered) + try: + yield decoder(buffered) + except ValueError: + log.error( + 'Compose tried parsing the following chunk as a JSON object, ' + 'but failed:\n%s' % repr(buffered) + ) + raise def json_splitter(buffer): """Attempt to parse a json object from a buffer. If there is at least one object, return it and the rest of the buffer, otherwise return None. """ + buffer = buffer.strip() try: obj, index = json_decoder.raw_decode(buffer) rest = buffer[json.decoder.WHITESPACE.match(buffer, index).end():] diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 8ee37b07..85231957 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -15,6 +15,10 @@ class TestJsonSplitter(object): data = '{"foo": "bar"}\n \n{"next": "obj"}' assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') + def test_json_splitter_leading_whitespace(self): + data = '\n \r{"foo": "bar"}\n\n {"next": "obj"}' + assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') + class TestStreamAsText(object): @@ -43,3 +47,16 @@ class TestJsonStream(object): [1, 2, 3], [], ] + + def test_with_leading_whitespace(self): + stream = [ + '\n \r\n {"one": "two"}{"x": 1}', + ' {"three": "four"}\t\t{"x": 2}' + ] + output = list(utils.json_stream(stream)) + assert output == [ + {'one': 'two'}, + {'x': 1}, + {'three': 'four'}, + {'x': 2} + ] From 48258e2b4668a7a4917a3c3f102ff97ba86e0908 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 1 Aug 2016 12:19:20 +0100 Subject: [PATCH 443/580] Add note to bundle docs about requiring an experimental Engine build Signed-off-by: Aanand Prasad --- docs/bundles.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/bundles.md b/docs/bundles.md index 5ca2c1ec..096c9ec3 100644 --- a/docs/bundles.md +++ b/docs/bundles.md @@ -50,6 +50,15 @@ Wrote bundle to vossibility-stack.dab ## Creating a stack from a bundle +> **Note**: Because support for stacks and bundles is in the experimental stage, +> you need to install an experimental build of Docker Engine to use it. +> +> If you're on Mac or Windows, download the “Beta channel” version of +> [Docker for Mac](https://docs.docker.com/docker-for-mac/) or +> [Docker for Windows](https://docs.docker.com/docker-for-windows/) to install +> it. If you're on Linux, follow the instructions in the +> [experimental build README](https://github.com/docker/docker/blob/master/experimental/README.md). + A stack is created using the `docker deploy` command: ```bash From b3a4d76d4faf172efcb1c0fc691cd1b50c786447 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Aug 2016 11:44:25 +0100 Subject: [PATCH 444/580] Handle connection errors on project initialization Signed-off-by: Aanand Prasad --- compose/cli/command.py | 4 +++- tests/acceptance/cli_test.py | 13 +++++++++++++ .../volumes-from-container/docker-compose.yml | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/volumes-from-container/docker-compose.yml diff --git a/compose/cli/command.py b/compose/cli/command.py index 2c70d31a..02035428 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -8,6 +8,7 @@ import ssl import six +from . import errors from . import verbose_proxy from .. import config from ..config.environment import Environment @@ -110,7 +111,8 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, host=host, environment=environment ) - return Project.from_config(project_name, config_data, client) + with errors.handle_connection_errors(client): + return Project.from_config(project_name, config_data, client) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7641870b..3939a97b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -154,6 +154,19 @@ class CLITestCase(DockerClientTestCase): 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 + + def test_host_not_reachable_volumes_from_container(self): + self.base_dir = 'tests/fixtures/volumes-from-container' + + container = self.client.create_container('busybox', 'true', name='composetest_data_container') + self.addCleanup(self.client.remove_container, container) + + result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) + assert "Couldn't connect to Docker daemon" in result.stderr + def test_config_list_services(self): self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config', '--services']) diff --git a/tests/fixtures/volumes-from-container/docker-compose.yml b/tests/fixtures/volumes-from-container/docker-compose.yml new file mode 100644 index 00000000..495fcaae --- /dev/null +++ b/tests/fixtures/volumes-from-container/docker-compose.yml @@ -0,0 +1,5 @@ +version: "2" +services: + test: + image: busybox + volumes_from: ["container:composetest_data_container"] From 9abbe1b7f8cf4e83fea7b115204d50384c9723ed Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 Aug 2016 11:50:57 -0700 Subject: [PATCH 445/580] Catchable error for parse failures in split_buffer Signed-off-by: Joffrey F --- compose/cli/main.py | 3 ++- compose/errors.py | 5 +++++ compose/utils.py | 10 ++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index db06a5e1..20200b09 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -23,6 +23,7 @@ from ..config.environment import Environment from ..config.serialize import serialize_config from ..const import DEFAULT_TIMEOUT from ..const import IS_WINDOWS_PLATFORM +from ..errors import StreamParseError from ..progress_stream import StreamOutputError from ..project import NoSuchService from ..project import OneOffFilter @@ -75,7 +76,7 @@ def main(): except NeedsBuildError as e: log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name) sys.exit(1) - except errors.ConnectionError: + except (errors.ConnectionError, StreamParseError): sys.exit(1) diff --git a/compose/errors.py b/compose/errors.py index 9f68760d..376cc555 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -5,3 +5,8 @@ from __future__ import unicode_literals class OperationFailedError(Exception): def __init__(self, reason): self.msg = reason + + +class StreamParseError(RuntimeError): + def __init__(self, reason): + self.msg = reason diff --git a/compose/utils.py b/compose/utils.py index eea73be1..6d9a9fdc 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -9,6 +9,8 @@ import logging import six +from .errors import StreamParseError + json_decoder = json.JSONDecoder() log = logging.getLogger(__name__) @@ -64,12 +66,12 @@ def split_buffer(stream, splitter=None, decoder=lambda a: a): if buffered: try: yield decoder(buffered) - except ValueError: + except Exception as e: log.error( - 'Compose tried parsing the following chunk as a JSON object, ' - 'but failed:\n%s' % repr(buffered) + 'Compose tried decoding the following data chunk, but failed:' + '\n%s' % repr(buffered) ) - raise + raise StreamParseError(e) def json_splitter(buffer): From 4cba653eeb3c054557a02b23b905da8a11bbc8e5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Aug 2016 15:19:02 +0100 Subject: [PATCH 446/580] Disambiguate 'Swarm' in integration doc Signed-off-by: Aanand Prasad --- docs/swarm.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/swarm.md b/docs/swarm.md index bbab6908..f956f8c2 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -11,6 +11,10 @@ parent="workw_compose" # Using Compose with Swarm +> **Note:** “Swarm” here refers to [Docker Swarm](/swarm/overview.md), a product separate from Docker Engine. It does _not_ refer to [swarm mode](/engine/swarm), which is a built-in feature of Docker Engine introduced in version 1.12. +> +> Integration between Compose and swarm mode is at the experimental stage. See [Docker Stacks and Bundles](bundles.md) for details. + Docker Compose and [Docker Swarm](/swarm/overview.md) aim to have full integration, meaning you can point a Compose app at a Swarm cluster and have it all just work as if you were using a single Docker host. From 17f46f8999e66e3dbb8f8438002caf17fe6065a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JG=C2=B2?= Date: Wed, 3 Aug 2016 11:17:54 +0200 Subject: [PATCH 447/580] Update rm.md Receiving this message when using the -a flag : `--all flag is obsolete. This is now the default behavior of `docker-compose rm`, I proposed to mark it in the docs but I don't know which way is the best Signed-off-by: jgsqware --- compose/cli/main.py | 3 +-- docs/reference/rm.md | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index db06a5e1..7655fe9c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -615,8 +615,7 @@ class TopLevelCommand(object): Options: -f, --force Don't ask to confirm removal -v Remove any anonymous volumes attached to containers - -a, --all Obsolete. Also remove one-off containers created by - docker-compose run + -a, --all Deprecated - no effect. """ if options.get('--all'): log.warn( diff --git a/docs/reference/rm.md b/docs/reference/rm.md index 8285a4ae..6351e6cf 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -17,8 +17,7 @@ Usage: rm [options] [SERVICE...] Options: -f, --force Don't ask to confirm removal -v Remove any anonymous volumes attached to containers - -a, --all Also remove one-off containers created by - docker-compose run + -a, --all Deprecated - no effect. ``` Removes stopped service containers. From c0305024f53c07607c34ff253860af386b004fbe Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 9 Aug 2016 15:13:01 -0700 Subject: [PATCH 448/580] Remove surrounding quotes from TLS paths, if present Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 7 ++++--- compose/cli/utils.py | 8 ++++++++ tests/unit/cli/docker_client_test.py | 13 +++++++++++++ tests/unit/cli/utils_test.py | 23 +++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 tests/unit/cli/utils_test.py diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index ce191fbf..b196d303 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -11,15 +11,16 @@ from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT from .errors import UserError from .utils import generate_user_agent +from .utils import unquote_path log = logging.getLogger(__name__) def tls_config_from_options(options): tls = options.get('--tls', False) - ca_cert = options.get('--tlscacert') - cert = options.get('--tlscert') - key = options.get('--tlskey') + ca_cert = unquote_path(options.get('--tlscacert')) + cert = unquote_path(options.get('--tlscert')) + key = unquote_path(options.get('--tlskey')) verify = options.get('--tlsverify') skip_hostname_check = options.get('--skip-hostname-check', False) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index f60f61cd..e10a3674 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -122,3 +122,11 @@ def generate_user_agent(): else: parts.append("{}/{}".format(p_system, p_release)) return " ".join(parts) + + +def unquote_path(s): + if not s: + return s + if s[0] == '"' and s[-1] == '"': + return s[1:-1] + return s diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 3430c25c..aaa935af 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -144,3 +144,16 @@ class TLSConfigTestCase(unittest.TestCase): result = tls_config_from_options(options) assert isinstance(result, docker.tls.TLSConfig) assert result.assert_hostname is False + + def test_tls_client_and_ca_quoted_paths(self): + options = { + '--tlscacert': '"{0}"'.format(self.ca_cert), + '--tlscert': '"{0}"'.format(self.client_cert), + '--tlskey': '"{0}"'.format(self.key), + '--tlsverify': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (self.client_cert, self.key) + assert result.ca_cert == self.ca_cert + assert result.verify is True diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py new file mode 100644 index 00000000..066fb359 --- /dev/null +++ b/tests/unit/cli/utils_test.py @@ -0,0 +1,23 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import unittest + +from compose.cli.utils import unquote_path + + +class UnquotePathTest(unittest.TestCase): + def test_no_quotes(self): + assert unquote_path('hello') == 'hello' + + def test_simple_quotes(self): + assert unquote_path('"hello"') == 'hello' + + def test_uneven_quotes(self): + assert unquote_path('"hello') == '"hello' + assert unquote_path('hello"') == 'hello"' + + def test_nested_quotes(self): + assert unquote_path('""hello""') == '"hello"' + assert unquote_path('"hel"lo"') == 'hel"lo' + assert unquote_path('"hello""') == 'hello"' From d824cb9b0678ec2ad460b034231c00c05df8c0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Wed, 1 Jun 2016 21:15:12 +0200 Subject: [PATCH 449/580] Add support for swappiness constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run a service using `docker run --memory-swappiness=0` (see https://docs.docker.com/engine/reference/run/) refs #2383 Signed-off-by: Jean-François Roche --- compose/config/config.py | 1 + compose/config/config_schema_v1.json | 1 + compose/config/config_schema_v2.0.json | 1 + compose/service.py | 4 +++- docs/compose-file.md | 3 ++- tests/integration/service_test.py | 5 +++++ tests/unit/config/config_test.py | 18 ++++++++++++++++++ 7 files changed, 31 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index d3ab1d4b..91c2f6a6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -69,6 +69,7 @@ DOCKER_CONFIG_KEYS = [ 'mac_address', 'mem_limit', 'memswap_limit', + 'mem_swappiness', 'net', 'oom_score_adj' 'pid', diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json index 36a93793..94354cda 100644 --- a/compose/config/config_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -85,6 +85,7 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, + "mem_swappiness": {"type": "integer"}, "net": {"type": "string"}, "pid": {"type": ["string", "null"]}, diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index ac46944c..59caac9b 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -139,6 +139,7 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, + "mem_swappiness": {"type": "integer"}, "network_mode": {"type": "string"}, "networks": { diff --git a/compose/service.py b/compose/service.py index 60343542..d0cdeeb3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -55,6 +55,7 @@ DOCKER_START_KEYS = [ 'mem_limit', 'memswap_limit', 'oom_score_adj', + 'mem_swappiness', 'pid', 'privileged', 'restart', @@ -704,7 +705,8 @@ class Service(object): cpu_quota=options.get('cpu_quota'), shm_size=options.get('shm_size'), 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') ) def build(self, no_cache=False, pull=False, force_rm=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index d6d0cadc..9f4bd121 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -744,7 +744,7 @@ then read-write will be used. > - container_name > - container_name:rw -### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, oom_score_adj, privileged, read\_only, restart, shm\_size, stdin\_open, tty, user, working\_dir +### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, mem\_swappiness, oom\_score\_adj, privileged, read\_only, restart, shm\_size, stdin\_open, tty, user, working\_dir Each of these is a single value, analogous to its [docker run](https://docs.docker.com/engine/reference/run/) counterpart. @@ -763,6 +763,7 @@ Each of these is a single value, analogous to its mem_limit: 1000000000 memswap_limit: 2000000000 + mem_swappiness: 10 privileged: true restart: always diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 97ad7476..24dec983 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -852,6 +852,11 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9']) + def test_mem_swappiness(self): + service = self.create_service('web', mem_swappiness=11) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.MemorySwappiness'), 11) + def test_restart_always_value(self): service = self.create_service('web', restart={'Name': 'always'}) container = create_and_start_container(service) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d88c1d47..02810d2b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1271,6 +1271,24 @@ class ConfigTest(unittest.TestCase): } ] + def test_swappiness_option(self): + actual = config.load(build_config_details({ + 'version': '2', + 'services': { + 'web': { + 'image': 'alpine', + 'mem_swappiness': 10, + } + } + })) + assert actual.services == [ + { + 'name': 'web', + 'image': 'alpine', + 'mem_swappiness': 10, + } + ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): base = { 'volumes': ['.:/app'], From bf91c64983a8598f9170d3f298b9abed100aacd0 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Mon, 22 Aug 2016 12:18:07 +1000 Subject: [PATCH 450/580] Add docs checking Jenkinsfile Signed-off-by: Sven Dowideit --- Jenkinsfile | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..fa29520b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,8 @@ +// Only run on Linux atm +wrappedNode(label: 'docker') { + deleteDir() + stage "checkout" + checkout scm + + documentationChecker("docs") +} From 817c76c8e9ebbeb864ef69e8e03819fefb1a784d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 11:17:39 -0700 Subject: [PATCH 451/580] Fix command hint in bundle to pull services instead of images Signed-off-by: Joffrey F --- compose/bundle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index afbdabfa..854cc799 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -46,8 +46,9 @@ class NeedsPush(Exception): class NeedsPull(Exception): - def __init__(self, image_name): + def __init__(self, image_name, service_name): self.image_name = image_name + self.service_name = service_name class MissingDigests(Exception): @@ -74,7 +75,7 @@ def get_image_digests(project, allow_push=False): except NeedsPush as e: needs_push.add(e.image_name) except NeedsPull as e: - needs_pull.add(e.image_name) + needs_pull.add(e.service_name) if needs_push or needs_pull: raise MissingDigests(needs_push, needs_pull) @@ -109,7 +110,7 @@ def get_image_digest(service, allow_push=False): return image['RepoDigests'][0] if 'build' not in service.options: - raise NeedsPull(service.image_name) + raise NeedsPull(service.image_name, service.name) if not allow_push: raise NeedsPush(service.image_name) From dada36f732ed7f7fc05c0f9841a432c490c7ff9c Mon Sep 17 00:00:00 2001 From: George Lester Date: Fri, 8 Jul 2016 00:29:13 -0700 Subject: [PATCH 452/580] Supported group_add Signed-off-by: George Lester --- compose/config/config.py | 1 + compose/config/config_schema_v2.0.json | 7 +++++++ compose/service.py | 4 +++- docs/compose-file.md | 14 ++++++++++++++ tests/integration/service_test.py | 8 ++++++++ tests/unit/config/config_test.py | 20 ++++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 91c2f6a6..d36aefa5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -61,6 +61,7 @@ DOCKER_CONFIG_KEYS = [ 'env_file', 'environment', 'extra_hosts', + 'group_add', 'hostname', 'image', 'ipc', diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 59caac9b..76688916 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -168,6 +168,13 @@ ] }, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, "pid": {"type": ["string", "null"]}, "ports": { diff --git a/compose/service.py b/compose/service.py index d0cdeeb3..b5a35b7e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -48,6 +48,7 @@ DOCKER_START_KEYS = [ 'dns_search', 'env_file', 'extra_hosts', + 'group_add', 'ipc', 'read_only', 'log_driver', @@ -706,7 +707,8 @@ class Service(object): shm_size=options.get('shm_size'), tmpfs=options.get('tmpfs'), oom_score_adj=options.get('oom_score_adj'), - mem_swappiness=options.get('mem_swappiness') + mem_swappiness=options.get('mem_swappiness'), + group_add=options.get('group_add') ) def build(self, no_cache=False, pull=False, force_rm=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index 9f4bd121..384649b1 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -889,6 +889,20 @@ A full example: host2: 172.28.1.6 host3: 172.28.1.7 +### group_add + +Specify additional groups (by name or number) which the user inside the container will be a member of. Groups must exist in both the container and the host system to be added. An example of where this is useful is when multiple containers (running as different users) need to all read or write the same file on the host system. That file can be owned by a group shared by all the containers, and specified in `group_add`. See the [Docker documentation](https://docs.docker.com/engine/reference/run/#/additional-groups) for more details. + +A full example: + + version: '2' + services: + image: alpine + group_add: + - mail + +Running `id` inside the created container will show that the user belongs to the `mail` group, which would not have been the case if `group_add` were not used. + ### internal By default, Docker also connects a bridge network to it to provide external connectivity. If you want to create an externally isolated overlay network, you can set this option to `true`. diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 24dec983..a5ca81ee 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -867,6 +867,14 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.OomScoreAdj'), 500) + def test_group_add_value(self): + service = self.create_service('web', group_add=["root", "1"]) + container = create_and_start_container(service) + + host_container_groupadd = container.get('HostConfig.GroupAdd') + self.assertTrue("root" in host_container_groupadd) + self.assertTrue("1" in host_container_groupadd) + def test_restart_on_failure_value(self): service = self.create_service('web', restart={ 'Name': 'on-failure', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 02810d2b..837630c1 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1289,6 +1289,26 @@ class ConfigTest(unittest.TestCase): } ] + def test_group_add_option(self): + + actual = config.load(build_config_details({ + 'version': '2', + 'services': { + 'web': { + 'image': 'alpine', + 'group_add': ["docker", 777] + } + } + })) + + assert actual.services == [ + { + 'name': 'web', + 'image': 'alpine', + 'group_add': ["docker", 777] + } + ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): base = { 'volumes': ['.:/app'], From b64bd07f225dd8af9f2b8fd096dfd957022c3dda Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 9 Sep 2016 17:19:14 -0700 Subject: [PATCH 453/580] Update docker-py dependency to latest release Signed-off-by: Joffrey F --- requirements.txt | 5 +++-- setup.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 831ed65a..7f28514b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.9.0 +docker-py==1.10.2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' ipaddress==1.0.16 jsonschema==2.5.1 +pypiwin32==219; sys_platform == 'win32' requests==2.7.0 -six==1.7.3 +six==1.10.0 texttable==0.8.4 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index 5cb52dae..34b40273 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.9.0, < 2.0', + 'docker-py >= 1.10.2, < 2.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 9759f27fa6a970ee9e01d1edd2d8b4a96eb510fa Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 13 Sep 2016 14:50:37 +0100 Subject: [PATCH 454/580] Fix integration test on Docker for Mac Signed-off-by: Aanand Prasad --- tests/unit/cli/errors_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 71fa9dee..1d454a08 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -32,7 +32,7 @@ class TestHandleConnectionErrors(object): raise ConnectionError() _, args, _ = mock_logging.error.mock_calls[0] - assert "Couldn't connect to Docker daemon at" in args[0] + assert "Couldn't connect to Docker daemon" in args[0] def test_api_error_version_mismatch(self, mock_logging): with pytest.raises(errors.ConnectionError): From 7dd2e33057f8dab7bb63ceedc8ea598f993463c4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Sep 2016 16:02:58 -0700 Subject: [PATCH 455/580] Only allow log streaming if logdriver is json-file or journald Signed-off-by: Joffrey F --- compose/container.py | 2 +- tests/unit/container_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index 2c16863d..bda4e659 100644 --- a/compose/container.py +++ b/compose/container.py @@ -163,7 +163,7 @@ class Container(object): @property def has_api_logs(self): log_type = self.log_driver - return not log_type or log_type != 'none' + return not log_type or log_type in ('json-file', 'journald') def attach_log_stream(self): """A log stream can only be attached if the container uses a json-file diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 47f60de8..62e3aa2c 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -150,6 +150,34 @@ class ContainerTest(unittest.TestCase): container = Container(None, self.container_dict, has_been_inspected=True) assert container.short_id == self.container_id[:12] + def test_has_api_logs(self): + container_dict = { + 'HostConfig': { + 'LogConfig': { + 'Type': 'json-file' + } + } + } + + container = Container(None, container_dict, has_been_inspected=True) + assert container.has_api_logs is True + + container_dict['HostConfig']['LogConfig']['Type'] = 'none' + container = Container(None, container_dict, has_been_inspected=True) + assert container.has_api_logs is False + + container_dict['HostConfig']['LogConfig']['Type'] = 'syslog' + container = Container(None, container_dict, has_been_inspected=True) + assert container.has_api_logs is False + + container_dict['HostConfig']['LogConfig']['Type'] = 'journald' + container = Container(None, container_dict, has_been_inspected=True) + assert container.has_api_logs is True + + container_dict['HostConfig']['LogConfig']['Type'] = 'foobar' + container = Container(None, container_dict, has_been_inspected=True) + assert container.has_api_logs is False + class GetContainerNameTestCase(unittest.TestCase): From 3fcd648ba2e63191f72c7bdb53cadc387c90769f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Sep 2016 15:49:54 -0700 Subject: [PATCH 456/580] Catch APIError while printing container logs Signed-off-by: Joffrey F --- compose/cli/log_printer.py | 11 +++++++++-- tests/unit/cli/log_printer_test.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index b48462ff..299ddea4 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -6,6 +6,7 @@ from collections import namedtuple from itertools import cycle from threading import Thread +from docker.errors import APIError from six.moves import _thread as thread from six.moves.queue import Empty from six.moves.queue import Queue @@ -176,8 +177,14 @@ def build_log_generator(container, log_args): def wait_on_exit(container): - exit_code = container.wait() - return "%s exited with code %s\n" % (container.name, exit_code) + try: + exit_code = container.wait() + return "%s exited with code %s\n" % (container.name, exit_code) + except APIError as e: + return "Unexpected API error for %s (HTTP code %s)\nResponse body:\n%s\n" % ( + container.name, e.response.status_code, + e.response.text or '[empty]' + ) def start_producer_thread(thread_args): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index ab48eefc..b908eb68 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -4,7 +4,9 @@ from __future__ import unicode_literals import itertools import pytest +import requests import six +from docker.errors import APIError from six.moves.queue import Queue from compose.cli.log_printer import build_log_generator @@ -56,6 +58,26 @@ def test_wait_on_exit(): assert expected == wait_on_exit(mock_container) +def test_wait_on_exit_raises(): + status_code = 500 + + def mock_wait(): + resp = requests.Response() + resp.status_code = status_code + raise APIError('Bad server', resp) + + mock_container = mock.Mock( + spec=Container, + name='cname', + wait=mock_wait + ) + + expected = 'Unexpected API error for {} (HTTP code {})\n'.format( + mock_container.name, status_code, + ) + assert expected in wait_on_exit(mock_container) + + def test_build_no_log_generator(mock_container): mock_container.has_api_logs = False mock_container.log_driver = 'none' From fd254caa681ac697b9aad03d4b8c5e2e04d4f437 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 27 Jun 2016 17:27:31 -0700 Subject: [PATCH 457/580] Add support for link-local IPs in service.networks definition Signed-off-by: Joffrey F --- compose/config/config.py | 5 +- compose/config/config_schema_v2.1.json | 319 +++++++++++++++++++++++++ compose/config/serialize.py | 7 +- compose/const.py | 5 +- compose/service.py | 4 +- docs/compose-file.md | 34 +++ tests/integration/project_test.py | 27 +++ tests/integration/testcases.py | 32 ++- tests/unit/config/config_test.py | 34 ++- 9 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 compose/config/config_schema_v2.1.json diff --git a/compose/config/config.py b/compose/config/config.py index 91c2f6a6..32eda81f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,7 @@ from cached_property import cached_property 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 ..utils import build_string_dict from .environment import env_vars_from_file from .environment import Environment @@ -173,7 +174,7 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): if version == '2': version = V2_0 - if version != V2_0: + if version not in (V2_0, V2_1): raise ConfigurationError( 'Version in "{}" is unsupported. {}' .format(self.filename, VERSION_EXPLANATION)) @@ -423,7 +424,7 @@ def process_config_file(config_file, environment, service_name=None): 'service', environment,) - if config_file.version == V2_0: + if config_file.version in (V2_0, V2_1): processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json new file mode 100644 index 00000000..de4ddf25 --- /dev/null +++ b/compose/config/config_schema_v2.1.json @@ -0,0 +1,319 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v2.1.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 + } + }, + + "additionalProperties": false, + + "definitions": { + + "service": { + "id": "#/definitions/service", + "type": "object", + + "properties": { + "build": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "context": {"type": "string"}, + "dockerfile": {"type": "string"}, + "args": {"$ref": "#/definitions/list_or_dict"} + }, + "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"}, + "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, + "cpuset": {"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 + }, + + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "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"} + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "mem_limit": {"type": ["number", "string"]}, + "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"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "ports" + }, + "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"]}, + "stdin_open": {"type": "boolean"}, + "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"}, + "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "volume_driver": {"type": "string"}, + "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "working_dir": {"type": "string"} + }, + + "dependencies": { + "memswap_limit": ["mem_limit"] + }, + "additionalProperties": false + }, + + "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" + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + } + }, + "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 + }, + "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"] + } + } + } + } + } +} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index b788a55d..0e6efbdf 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,6 +7,7 @@ import yaml from compose.config import types from compose.config.config import V1 from compose.config.config import V2_0 +from compose.config.config import V2_1 def serialize_config_type(dumper, data): @@ -32,8 +33,12 @@ def denormalize_config(config): if 'external_name' in net_conf: del net_conf['external_name'] + version = config.version + if version not in (V2_0, V2_1): + version = V2_0 + return { - 'version': V2_0, + 'version': version, 'services': services, 'networks': networks, 'volumes': config.volumes, diff --git a/compose/const.py b/compose/const.py index b930e0bf..e7b1ae97 100644 --- a/compose/const.py +++ b/compose/const.py @@ -16,13 +16,16 @@ LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' +COMPOSEFILE_V2_1 = '2.1' API_VERSIONS = { COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', + COMPOSEFILE_V2_1: '1.24', } API_VERSION_TO_ENGINE_VERSION = { API_VERSIONS[COMPOSEFILE_V1]: '1.9.0', - API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0' + API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', + API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', } diff --git a/compose/service.py b/compose/service.py index d0cdeeb3..31ea9e0f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -477,7 +477,9 @@ class Service(object): aliases=self._get_aliases(netdefs, container), ipv4_address=netdefs.get('ipv4_address', None), ipv6_address=netdefs.get('ipv6_address', None), - links=self._get_links(False)) + links=self._get_links(False), + link_local_ips=netdefs.get('link_local_ips', None), + ) def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): for c in self.duplicate_containers(): diff --git a/docs/compose-file.md b/docs/compose-file.md index 9f4bd121..c8fa112d 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -621,6 +621,31 @@ An example: - subnet: 2001:3984:3989::/64 gateway: 2001:3984:3989::1 +#### link_local_ips + +> [Version 2.1 file format](#version-2.1) only. + +Specify a list of link-local IPs. Link-local IPs are special IPs which belong +to a well known subnet and are purely managed by the operator, usually +dependent on the architecture where they are deployed. Therefore they are not +managed by docker (IPAM driver). + +Example usage: + + version: '2.1' + services: + app: + image: busybox + command: top + networks: + app_net: + link_local_ips: + - 57.123.22.11 + - 57.123.22.13 + networks: + app_net: + driver: bridge + ### pid pid: "host" @@ -1040,6 +1065,15 @@ A more extended example, defining volumes and networks: back-tier: driver: bridge +### Version 2.1 + +An upgrade of [version 2](#version-2) that introduces new parameters only +available with Docker Engine version **1.12.0+** + +Introduces: + +- [`link_local_ips`](#link_local_ips) +- ... ### Upgrading diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 80915c1a..2241f70f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -13,6 +13,7 @@ from .testcases import DockerClientTestCase from compose.config import config from compose.config import ConfigurationError from compose.config.config import V2_0 +from compose.config.config import V2_1 from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT @@ -21,6 +22,7 @@ from compose.container import Container from compose.project import Project from compose.project import ProjectError from compose.service import ConvergenceStrategy +from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only @@ -756,6 +758,31 @@ class ProjectTest(DockerClientTestCase): with self.assertRaises(ProjectError): project.up() + @v2_1_only() + def test_up_with_network_link_local_ips(self): + config_data = config.Config( + version=V2_1, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': { + 'linklocaltest': { + 'link_local_ips': ['169.254.8.8'] + } + } + }], + volumes={}, + networks={ + 'linklocaltest': {'driver': 'bridge'} + } + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + project.up() + @v2_only() def test_project_up_with_network_internal(self): self.require_api_version('1.23') diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 3e33a6c0..c7743fb8 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -12,6 +12,7 @@ from compose.cli.docker_client import docker_client 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.environment import Environment from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT @@ -33,18 +34,22 @@ def get_links(container): return [format_link(link) for link in links] -def engine_version_too_low_for_v2(): +def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return False + return V2_1 version = os.environ['DOCKER_VERSION'].partition('-')[0] - return version_lt(version, '1.10') + if version_lt(version, '1.10'): + return V1 + elif version_lt(version, '1.12'): + return V2_0 + return V2_1 def v2_only(): def decorator(f): @functools.wraps(f) def wrapper(self, *args, **kwargs): - if engine_version_too_low_for_v2(): + if engine_max_version() == V1: skip("Engine version is too low") return return f(self, *args, **kwargs) @@ -53,14 +58,23 @@ def v2_only(): return decorator +def v2_1_only(): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if engine_max_version() in (V1, V2_0): + skip('Engine version is too low') + return + return f(self, *args, **kwargs) + return wrapper + + return decorator + + class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - if engine_version_too_low_for_v2(): - version = API_VERSIONS[V1] - else: - version = API_VERSIONS[V2_0] - + version = API_VERSIONS[engine_max_version()] cls.client = docker_client(Environment(), version) @classmethod diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 02810d2b..8087c773 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -17,6 +17,7 @@ from compose.config.config import resolve_build_args 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.environment import Environment from compose.config.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION @@ -155,6 +156,8 @@ class ConfigTest(unittest.TestCase): for version in ['2', '2.0']: cfg = config.load(build_config_details({'version': version})) assert cfg.version == V2_0 + cfg = config.load(build_config_details({'version': '2.1'})) + assert cfg.version == V2_1 def test_v1_file_version(self): cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) @@ -182,7 +185,7 @@ class ConfigTest(unittest.TestCase): with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( - {'version': '2.1'}, + {'version': '2.18'}, filename='filename.yml', ) ) @@ -344,6 +347,35 @@ class ConfigTest(unittest.TestCase): }, 'working_dir', 'filename.yml') ) + def test_load_config_link_local_ips_network(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.1', + 'services': { + 'web': { + 'image': 'example/web', + 'networks': { + 'foobar': { + 'aliases': ['foo', 'bar'], + 'link_local_ips': ['169.254.8.8'] + } + } + } + }, + 'networks': {'foobar': {}} + } + ) + + details = config.ConfigDetails('.', [base_file]) + web_service = config.load(details).services[0] + assert web_service['networks'] == { + 'foobar': { + 'aliases': ['foo', 'bar'], + 'link_local_ips': ['169.254.8.8'] + } + } + def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: From 66b395d950d333e945333717b525485ec9f664fe Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 25 Jul 2016 15:21:27 -0700 Subject: [PATCH 458/580] Include docker-py link-local fix and improve integration test Signed-off-by: Joffrey F --- tests/integration/project_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2241f70f..4427fe6b 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -783,6 +783,17 @@ class ProjectTest(DockerClientTestCase): ) project.up() + service_container = project.get_service('web').containers()[0] + ipam_config = service_container.inspect().get( + 'NetworkSettings', {} + ).get( + 'Networks', {} + ).get( + 'composetest_linklocaltest', {} + ).get('IPAMConfig', {}) + assert 'LinkLocalIPs' in ipam_config + assert ipam_config['LinkLocalIPs'] == ['169.254.8.8'] + @v2_only() def test_project_up_with_network_internal(self): self.require_api_version('1.23') From 64517e31fce5293f58295c567bd5487e07b069f4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Sep 2016 17:59:20 -0700 Subject: [PATCH 459/580] Force default host on windows to the default TCP host (instead of npipe) Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 6 ++++++ tests/unit/cli/docker_client_test.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index b196d303..7950c242 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,6 +9,7 @@ from docker.tls import TLSConfig from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT +from ..const import IS_WINDOWS_PLATFORM from .errors import UserError from .utils import generate_user_agent from .utils import unquote_path @@ -71,4 +72,9 @@ def docker_client(environment, version=None, tls_config=None, host=None, kwargs['user_agent'] = generate_user_agent() + if 'base_url' not in kwargs and IS_WINDOWS_PLATFORM: + # docker-py 1.10 defaults to using npipes, but we don't want that + # change in compose yet - use the default TCP connection instead. + kwargs['base_url'] = 'tcp://127.0.0.1:2375' + return Client(**kwargs) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index aaa935af..6cdb7da5 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -60,6 +60,14 @@ class DockerClientTestCase(unittest.TestCase): ) self.assertEqual(client.headers['User-Agent'], expected) + @mock.patch.dict(os.environ) + def test_docker_client_default_windows_host(self): + with mock.patch('compose.cli.docker_client.IS_WINDOWS_PLATFORM', True): + if 'DOCKER_HOST' in os.environ: + del os.environ['DOCKER_HOST'] + client = docker_client(os.environ) + assert client.base_url == 'http://127.0.0.1:2375' + class TLSConfigTestCase(unittest.TestCase): ca_cert = 'tests/fixtures/tls/ca.pem' From fc6791f3f0e3e8ae23d9b380398f39bea2e83101 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 16 Sep 2016 14:12:15 -0700 Subject: [PATCH 460/580] Bump docker-py dependency Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7f28514b..7acdd130 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.10.2 +docker-py==1.10.3 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' diff --git a/setup.py b/setup.py index 34b40273..80258fbd 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.10.2, < 2.0', + 'docker-py >= 1.10.3, < 2.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From cb3bf869f46e2aa2ead695b942a9d1d7b07b23f3 Mon Sep 17 00:00:00 2001 From: Andreas Kohn Date: Tue, 20 Sep 2016 13:57:34 +0200 Subject: [PATCH 461/580] Fix typo Signed-off-by: Andreas Kohn --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec7d5b5..cd4a2370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ Bug Fixes - Fixed a bug in Windows environment where volume mappings of the host's root directory would be parsed incorrectly. -- Fixed a bug where `docker-compose config` would ouput an invalid +- Fixed a bug where `docker-compose config` would output an invalid Compose file if external networks were specified. - Fixed an issue where unset buildargs would be assigned a string From 79116592668be2c22d0bc188e1f1d92ff8be104c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 16:39:30 -0700 Subject: [PATCH 462/580] Improve volumespec parsing on windows platforms Signed-off-by: Joffrey F --- compose/config/config.py | 10 +--- compose/config/types.py | 85 +++++++++++++++++++++------------ compose/utils.py | 9 ++++ tests/unit/config/types_test.py | 28 +++++++++-- 4 files changed, 90 insertions(+), 42 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 91c2f6a6..2789e9ed 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import functools import logging -import ntpath import os import string import sys @@ -16,6 +15,7 @@ from cached_property import cached_property from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V2_0 as V2_0 from ..utils import build_string_dict +from ..utils import splitdrive from .environment import env_vars_from_file from .environment import Environment from .environment import split_env @@ -942,13 +942,7 @@ def split_path_mapping(volume_path): path. Using splitdrive so windows absolute paths won't cause issues with splitting on ':'. """ - # splitdrive is very naive, so handle special cases where we can be sure - # the first character is not a drive. - if (volume_path.startswith('.') or volume_path.startswith('~') or - volume_path.startswith('/')): - drive, volume_config = '', volume_path - else: - drive, volume_config = ntpath.splitdrive(volume_path) + drive, volume_config = splitdrive(volume_path) if ':' in volume_config: (host, container) = volume_config.split(':', 1) diff --git a/compose/config/types.py b/compose/config/types.py index e6a3dea0..9664b580 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -12,6 +12,7 @@ import six from compose.config.config import V1 from compose.config.errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM +from compose.utils import splitdrive class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')): @@ -114,41 +115,23 @@ def parse_extra_hosts(extra_hosts_config): return extra_hosts_dict -def normalize_paths_for_engine(external_path, internal_path): +def normalize_path_for_engine(path): """Windows paths, c:\my\path\shiny, need to be changed to be compatible with the Engine. Volume paths are expected to be linux style /c/my/path/shiny/ """ - if not IS_WINDOWS_PLATFORM: - return external_path, internal_path + drive, tail = splitdrive(path) - if external_path: - drive, tail = os.path.splitdrive(external_path) + if drive: + path = '/' + drive.lower().rstrip(':') + tail - if drive: - external_path = '/' + drive.lower().rstrip(':') + tail - - external_path = external_path.replace('\\', '/') - - return external_path, internal_path.replace('\\', '/') + return path.replace('\\', '/') class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): @classmethod - def parse(cls, volume_config): - """Parse a volume_config path and split it into external:internal[:mode] - parts to be returned as a valid VolumeSpec. - """ - if IS_WINDOWS_PLATFORM: - # relative paths in windows expand to include the drive, eg C:\ - # so we join the first 2 parts back together to count as one - drive, tail = os.path.splitdrive(volume_config) - parts = tail.split(":") - - if drive: - parts[0] = drive + parts[0] - else: - parts = volume_config.split(':') + def _parse_unix(cls, volume_config): + parts = volume_config.split(':') if len(parts) > 3: raise ConfigurationError( @@ -156,13 +139,11 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): "external:internal[:mode]" % volume_config) if len(parts) == 1: - external, internal = normalize_paths_for_engine( - None, - os.path.normpath(parts[0])) + external = None + internal = os.path.normpath(parts[0]) else: - external, internal = normalize_paths_for_engine( - os.path.normpath(parts[0]), - os.path.normpath(parts[1])) + external = os.path.normpath(parts[0]) + internal = os.path.normpath(parts[1]) mode = 'rw' if len(parts) == 3: @@ -170,6 +151,48 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): return cls(external, internal, mode) + @classmethod + def _parse_win32(cls, volume_config): + # relative paths in windows expand to include the drive, eg C:\ + # so we join the first 2 parts back together to count as one + mode = 'rw' + + def separate_next_section(volume_config): + drive, tail = splitdrive(volume_config) + parts = tail.split(':', 1) + if drive: + parts[0] = drive + parts[0] + return parts + + parts = separate_next_section(volume_config) + if len(parts) == 1: + internal = normalize_path_for_engine(os.path.normpath(parts[0])) + external = None + else: + external = parts[0] + parts = separate_next_section(parts[1]) + external = normalize_path_for_engine(os.path.normpath(external)) + internal = normalize_path_for_engine(os.path.normpath(parts[0])) + if len(parts) > 1: + if ':' in parts[1]: + raise ConfigurationError( + "Volume %s has incorrect format, should be " + "external:internal[:mode]" % volume_config + ) + mode = parts[1] + + return cls(external, internal, mode) + + @classmethod + def parse(cls, volume_config): + """Parse a volume_config path and split it into external:internal[:mode] + parts to be returned as a valid VolumeSpec. + """ + if IS_WINDOWS_PLATFORM: + return cls._parse_win32(volume_config) + else: + return cls._parse_unix(volume_config) + def repr(self): external = self.external + ':' if self.external else '' return '{ext}{v.internal}:{v.mode}'.format(ext=external, v=self) diff --git a/compose/utils.py b/compose/utils.py index 6d9a9fdc..8f05e308 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -6,6 +6,7 @@ import hashlib import json import json.decoder import logging +import ntpath import six @@ -108,3 +109,11 @@ def microseconds_from_time_nano(time_nano): def build_string_dict(source_dict): return dict((k, str(v if v is not None else '')) for k, v in source_dict.items()) + + +def splitdrive(path): + if len(path) == 0: + return ('', '') + if path[0] in ['.', '\\', '/', '~']: + return ('', path) + return ntpath.splitdrive(path) diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index c741a339..8dfa65d5 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -9,7 +9,6 @@ from compose.config.errors import ConfigurationError from compose.config.types import parse_extra_hosts from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec -from compose.const import IS_WINDOWS_PLATFORM def test_parse_extra_hosts_list(): @@ -64,15 +63,38 @@ class TestVolumeSpec(object): VolumeSpec.parse('one:two:three:four') assert 'has incorrect format' in exc.exconly() - @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') def test_parse_volume_windows_absolute_path(self): windows_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" - assert VolumeSpec.parse(windows_path) == ( + assert VolumeSpec._parse_win32(windows_path) == ( "/c/Users/me/Documents/shiny/config", "/opt/shiny/config", "ro" ) + def test_parse_volume_windows_internal_path(self): + windows_path = 'C:\\Users\\reimu\\scarlet:C:\\scarlet\\app:ro' + assert VolumeSpec._parse_win32(windows_path) == ( + '/c/Users/reimu/scarlet', + '/c/scarlet/app', + 'ro' + ) + + def test_parse_volume_windows_just_drives(self): + windows_path = 'E:\\:C:\\:ro' + assert VolumeSpec._parse_win32(windows_path) == ( + '/e/', + '/c/', + 'ro' + ) + + def test_parse_volume_windows_mixed_notations(self): + windows_path = '/c/Foo:C:\\bar' + assert VolumeSpec._parse_win32(windows_path) == ( + '/c/Foo', + '/c/bar', + 'rw' + ) + class TestVolumesFromSpec(object): From 33424189d43ce7ec0a55abdd709d08af8840e7e5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Sep 2016 17:13:53 -0700 Subject: [PATCH 463/580] Denormalize function defaults to latest version Minor docs fix Signed-off-by: Joffrey F --- compose/config/serialize.py | 2 +- docs/compose-file.md | 2 +- tests/acceptance/cli_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 0e6efbdf..95b1387f 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -35,7 +35,7 @@ def denormalize_config(config): version = config.version if version not in (V2_0, V2_1): - version = V2_0 + version = V2_1 return { 'version': version, diff --git a/docs/compose-file.md b/docs/compose-file.md index c8fa112d..625e5bf6 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -623,7 +623,7 @@ An example: #### link_local_ips -> [Version 2.1 file format](#version-2.1) only. +> [Added in version 2.1 file format](#version-21). Specify a list of link-local IPs. Link-local IPs are special IPs which belong to a well known subnet and are purely managed by the operator, usually diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3939a97b..2247ffff 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -257,7 +257,7 @@ class CLITestCase(DockerClientTestCase): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) assert yaml.load(result.stdout) == { - 'version': '2.0', + 'version': '2.1', 'services': { 'net': { 'image': 'busybox', From 53fa44c01e7b50b571c48101d6ca59ac0fd94ace Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Sep 2016 18:05:59 -0700 Subject: [PATCH 464/580] Don't break when interpolating environment with unicode characters Signed-off-by: Joffrey F --- compose/service.py | 2 ++ tests/acceptance/cli_test.py | 19 +++++++++++++++++++ .../unicode-environment/docker-compose.yml | 7 +++++++ 3 files changed, 28 insertions(+) create mode 100644 tests/fixtures/unicode-environment/docker-compose.yml diff --git a/compose/service.py b/compose/service.py index b5a35b7e..1759bf7d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1109,6 +1109,8 @@ def format_environment(environment): def format_env(key, value): if value is None: return key + if isinstance(value, six.binary_type): + value = value.decode('utf-8') return '{key}={value}'.format(key=key, value=value) return [format_env(*item) for item in environment.items()] diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3939a97b..67cca8c7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from __future__ import absolute_import from __future__ import unicode_literals @@ -12,6 +13,7 @@ from collections import namedtuple from operator import attrgetter import py +import six import yaml from docker import errors @@ -1286,6 +1288,23 @@ class CLITestCase(DockerClientTestCase): 'simplecomposefile_simple_run_1', 'exited')) + @mock.patch.dict(os.environ) + def test_run_unicode_env_values_from_system(self): + value = 'ą, ć, ę, ł, ń, ó, ś, ź, ż' + if six.PY2: # os.environ doesn't support unicode values in Py2 + os.environ['BAR'] = value.encode('utf-8') + else: # ... and doesn't support byte values in Py3 + os.environ['BAR'] = value + self.base_dir = 'tests/fixtures/unicode-environment' + result = self.dispatch(['run', 'simple']) + + if six.PY2: # Can't retrieve output on Py3. See issue #3670 + assert value == result.stdout.strip() + + container = self.project.containers(one_off=OneOffFilter.only, stopped=True)[0] + environment = container.get('Config.Env') + assert 'FOO={}'.format(value) in environment + @mock.patch.dict(os.environ) def test_run_env_values_from_system(self): os.environ['FOO'] = 'bar' diff --git a/tests/fixtures/unicode-environment/docker-compose.yml b/tests/fixtures/unicode-environment/docker-compose.yml new file mode 100644 index 00000000..a41af4f0 --- /dev/null +++ b/tests/fixtures/unicode-environment/docker-compose.yml @@ -0,0 +1,7 @@ +version: '2' +services: + simple: + image: busybox:latest + command: sh -c 'echo $$FOO' + environment: + FOO: ${BAR} From 5667de87e88e0abcc876647ff7e73510de8b878a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Sep 2016 12:34:59 -0700 Subject: [PATCH 465/580] Fix the contributors script to show only contributors on the current branch Signed-off-by: Joffrey F --- script/release/contributors | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/release/contributors b/script/release/contributors index 1e69b143..4657dd80 100755 --- a/script/release/contributors +++ b/script/release/contributors @@ -15,10 +15,10 @@ EOM [[ -n "$1" ]] || usage PREV_RELEASE=$1 -VERSION=HEAD +BRANCH="$(git rev-parse --abbrev-ref HEAD)" URL="https://api.github.com/repos/docker/compose/compare" -contribs=$(curl -sf "$URL/$PREV_RELEASE...$VERSION" | \ +contribs=$(curl -sf "$URL/$PREV_RELEASE...$BRANCH" | \ jq -r '.commits[].author.login' | \ sort | \ uniq -c | \ From 90356b7040be49e402b1e176f4db7461659fbbd5 Mon Sep 17 00:00:00 2001 From: Matthew Bray Date: Wed, 28 Sep 2016 12:04:13 +0100 Subject: [PATCH 466/580] Zsh completion: permit multiple --file arguments Before this change: ``` $ docker-compose --file docker-compose.yml - -- option -- --help -h -- Get help --host -H -- Daemon socket to connect to --project-name -p -- Specify an alternate project name (default: directory name) --skip-hostname-check -- 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) --tls -- Use TLS; implied by --tlsverify --tlscacert -- Trust certs signed only by this CA --tlscert -- Path to TLS certificate file --tlskey -- Path to TLS key file --tlsverify -- Use TLS and verify the remote --verbose -- Show more output --version -v -- Print version and exit ``` (Note the `--file` argument is no longer available to complete.) After this change: ``` docker-compose --file docker-compose.yml - -- option -- --file -f -- Specify an alternate docker-compose file (default: docker-compose.yml) --help -h -- Get help --host -H -- Daemon socket to connect to --project-name -p -- Specify an alternate project name (default: directory name) --skip-hostname-check -- 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) --tls -- Use TLS; implied by --tlsverify --tlscacert -- Trust certs signed only by this CA --tlscert -- Path to TLS certificate file --tlskey -- Path to TLS key file --tlsverify -- Use TLS and verify the remote --verbose -- Show more output --version -v -- Print version and exit ``` Signed-off-by: Matt Bray --- contrib/completion/zsh/_docker-compose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 928e28de..fae75842 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -388,7 +388,7 @@ _docker-compose() { _arguments -C \ '(- :)'{-h,--help}'[Get help]' \ - '(-f --file)'{-f,--file}'[Specify an alternate docker-compose file (default: docker-compose.yml)]:file:_files -g "*.yml"' \ + '*'{-f,--file}'[Specify an alternate docker-compose file (default: docker-compose.yml)]:file:_files -g "*.yml"' \ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ '--verbose[Show more output]' \ '(- :)'{-v,--version}'[Print version and exit]' \ From dc8a39f70d66948bcd220d1693ed8ab167fa3513 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Sep 2016 15:31:58 -0700 Subject: [PATCH 467/580] Add support for "isolation" in config Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 1 + compose/service.py | 8 ++++- tests/integration/project_test.py | 43 ++++++++++++++++++++++++++ tests/unit/config/config_test.py | 23 ++++++++++++-- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index de4ddf25..243759fa 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -123,6 +123,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, + "isolation": {"type": "string"}, "labels": {"$ref": "#/definitions/list_or_dict"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/compose/service.py b/compose/service.py index c461220f..f4f4b90d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -682,7 +682,7 @@ class Service(object): logging_dict = options.get('logging', None) log_config = get_log_config(logging_dict) - return self.client.create_host_config( + host_config = self.client.create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=build_port_bindings(options.get('ports') or []), binds=options.get('binds'), @@ -713,6 +713,12 @@ class Service(object): group_add=options.get('group_add') ) + # TODO: Add as an argument to create_host_config once it's supported + # in docker-py + host_config['Isolation'] = options.get('isolation') + + return host_config + def build(self, no_cache=False, pull=False, force_rm=False): log.info('Building %s' % self.name) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4427fe6b..8588c6b1 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -794,6 +794,49 @@ class ProjectTest(DockerClientTestCase): assert 'LinkLocalIPs' in ipam_config assert ipam_config['LinkLocalIPs'] == ['169.254.8.8'] + @v2_1_only() + def test_up_with_isolation(self): + self.require_api_version('1.24') + config_data = config.Config( + version=V2_1, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'isolation': 'default' + }], + volumes={}, + networks={} + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + project.up() + service_container = project.get_service('web').containers()[0] + assert service_container.inspect()['HostConfig']['Isolation'] == 'default' + + @v2_1_only() + def test_up_with_invalid_isolation(self): + self.require_api_version('1.24') + config_data = config.Config( + version=V2_1, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'isolation': 'foobar' + }], + volumes={}, + networks={} + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + with self.assertRaises(ProjectError): + project.up() + @v2_only() def test_project_up_with_network_internal(self): self.require_api_version('1.23') diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 88b990e5..d9bc5764 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -351,7 +351,7 @@ class ConfigTest(unittest.TestCase): base_file = config.ConfigFile( 'base.yaml', { - 'version': '2.1', + 'version': V2_1, 'services': { 'web': { 'image': 'example/web', @@ -1330,7 +1330,7 @@ class ConfigTest(unittest.TestCase): 'image': 'alpine', 'group_add': ["docker", 777] } - } + } })) assert actual.services == [ @@ -1341,6 +1341,25 @@ class ConfigTest(unittest.TestCase): } ] + def test_isolation_option(self): + actual = config.load(build_config_details({ + 'version': V2_1, + 'services': { + 'web': { + 'image': 'win10', + 'isolation': 'hyperv' + } + } + })) + + assert actual.services == [ + { + 'name': 'web', + 'image': 'win10', + 'isolation': 'hyperv', + } + ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): base = { 'volumes': ['.:/app'], From 007cf96452a28941fde58f8b775f7cd48d86c1c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Sep 2016 15:36:57 -0700 Subject: [PATCH 468/580] Use docker-py's default behavior when no explicit host on Windows Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 6 ------ tests/unit/cli/docker_client_test.py | 8 -------- 2 files changed, 14 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 7950c242..b196d303 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,7 +9,6 @@ from docker.tls import TLSConfig from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT -from ..const import IS_WINDOWS_PLATFORM from .errors import UserError from .utils import generate_user_agent from .utils import unquote_path @@ -72,9 +71,4 @@ def docker_client(environment, version=None, tls_config=None, host=None, kwargs['user_agent'] = generate_user_agent() - if 'base_url' not in kwargs and IS_WINDOWS_PLATFORM: - # docker-py 1.10 defaults to using npipes, but we don't want that - # change in compose yet - use the default TCP connection instead. - kwargs['base_url'] = 'tcp://127.0.0.1:2375' - return Client(**kwargs) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 6cdb7da5..aaa935af 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -60,14 +60,6 @@ class DockerClientTestCase(unittest.TestCase): ) self.assertEqual(client.headers['User-Agent'], expected) - @mock.patch.dict(os.environ) - def test_docker_client_default_windows_host(self): - with mock.patch('compose.cli.docker_client.IS_WINDOWS_PLATFORM', True): - if 'DOCKER_HOST' in os.environ: - del os.environ['DOCKER_HOST'] - client = docker_client(os.environ) - assert client.base_url == 'http://127.0.0.1:2375' - class TLSConfigTestCase(unittest.TestCase): ca_cert = 'tests/fixtures/tls/ca.pem' From fe08be698d36d42a66839ce284989947220931cd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 10 Mar 2016 17:33:01 -0500 Subject: [PATCH 469/580] Support inline default values. Signed-off-by: Daniel Nephin --- compose/config/config.py | 22 +++++--- compose/config/interpolation.py | 74 +++++++++++++++++++------ tests/unit/config/interpolation_test.py | 74 ++++++++++++++++++++----- tests/unit/interpolation_test.py | 36 ------------ 4 files changed, 130 insertions(+), 76 deletions(-) delete mode 100644 tests/unit/interpolation_test.py diff --git a/compose/config/config.py b/compose/config/config.py index aea1e094..4d32b50c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -413,31 +413,35 @@ def load_services(config_details, config_file): return build_services(service_config) -def interpolate_config_section(filename, config, section, environment): - validate_config_section(filename, config, section) - return interpolate_environment_variables(config, section, environment) +def interpolate_config_section(config_file, config, section, environment): + validate_config_section(config_file.filename, config, section) + return interpolate_environment_variables( + config_file.version, + config, + section, + environment) def process_config_file(config_file, environment, service_name=None): services = interpolate_config_section( - config_file.filename, + config_file, config_file.get_service_dicts(), 'service', - environment,) + environment) if config_file.version in (V2_0, V2_1): processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( - config_file.filename, + config_file, config_file.get_volumes(), 'volume', - environment,) + environment) processed_config['networks'] = interpolate_config_section( - config_file.filename, + config_file, config_file.get_networks(), 'network', - environment,) + environment) if config_file.version == V1: processed_config = services diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 63020d91..cb841437 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -7,14 +7,35 @@ from string import Template import six from .errors import ConfigurationError +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_0 as V2_0 + + log = logging.getLogger(__name__) -def interpolate_environment_variables(config, section, environment): +class Interpolator(object): + + def __init__(self, templater, mapping): + self.templater = templater + self.mapping = mapping + + def interpolate(self, string): + try: + return self.templater(string).substitute(self.mapping) + except ValueError: + raise InvalidInterpolation(string) + + +def interpolate_environment_variables(version, config, section, environment): + if version in (V2_0, V1): + interpolator = Interpolator(Template, environment) + else: + interpolator = Interpolator(TemplateWithDefaults, environment) def process_item(name, config_dict): return dict( - (key, interpolate_value(name, key, val, section, environment)) + (key, interpolate_value(name, key, val, section, interpolator)) for key, val in (config_dict or {}).items() ) @@ -24,9 +45,9 @@ def interpolate_environment_variables(config, section, environment): ) -def interpolate_value(name, config_key, value, section, mapping): +def interpolate_value(name, config_key, value, section, interpolator): try: - return recursive_interpolate(value, mapping) + return recursive_interpolate(value, interpolator) except InvalidInterpolation as e: raise ConfigurationError( 'Invalid interpolation format for "{config_key}" option ' @@ -37,25 +58,44 @@ def interpolate_value(name, config_key, value, section, mapping): string=e.string)) -def recursive_interpolate(obj, mapping): +def recursive_interpolate(obj, interpolator): if isinstance(obj, six.string_types): - return interpolate(obj, mapping) - elif isinstance(obj, dict): + return interpolator.interpolate(obj) + if isinstance(obj, dict): return dict( - (key, recursive_interpolate(val, mapping)) + (key, recursive_interpolate(val, interpolator)) for (key, val) in obj.items() ) - elif isinstance(obj, list): - return [recursive_interpolate(val, mapping) for val in obj] - else: - return obj + if isinstance(obj, list): + return [recursive_interpolate(val, interpolator) for val in obj] + return obj -def interpolate(string, mapping): - try: - return Template(string).substitute(mapping) - except ValueError: - raise InvalidInterpolation(string) +class TemplateWithDefaults(Template): + idpattern = r'[_a-z][_a-z0-9]*(?::?-[_a-z0-9]+)?' + + # Modified from python2.7/string.py + def substitute(self, mapping): + # Helper function for .sub() + def convert(mo): + # Check the most common path first. + named = mo.group('named') or mo.group('braced') + if named is not None: + if ':-' in named: + var, _, default = named.partition(':-') + return mapping.get(var) or default + if '-' in named: + var, _, default = named.partition('-') + return mapping.get(var, default) + val = mapping[named] + return '%s' % (val,) + if mo.group('escaped') is not None: + return self.delimiter + if mo.group('invalid') is not None: + self._invalid(mo) + raise ValueError('Unrecognized named group in pattern', + self.pattern) + return self.pattern.sub(convert, self.template) class InvalidInterpolation(Exception): diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 42b5db6e..22444495 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -1,21 +1,28 @@ from __future__ import absolute_import from __future__ import unicode_literals -import os - -import mock import pytest from compose.config.environment import Environment from compose.config.interpolation import interpolate_environment_variables +from compose.config.interpolation import Interpolator +from compose.config.interpolation import InvalidInterpolation +from compose.config.interpolation import TemplateWithDefaults -@pytest.yield_fixture +@pytest.fixture def mock_env(): - with mock.patch.dict(os.environ): - os.environ['USER'] = 'jenny' - os.environ['FOO'] = 'bar' - yield + return Environment({'USER': 'jenny', 'FOO': 'bar'}) + + +@pytest.fixture +def variable_mapping(): + return Environment({'FOO': 'first', 'BAR': ''}) + + +@pytest.fixture +def defaults_interpolator(variable_mapping): + return Interpolator(TemplateWithDefaults, variable_mapping).interpolate def test_interpolate_environment_variables_in_services(mock_env): @@ -43,9 +50,8 @@ def test_interpolate_environment_variables_in_services(mock_env): } } } - assert interpolate_environment_variables( - services, 'service', Environment.from_env_file(None) - ) == expected + value = interpolate_environment_variables("2.0", services, 'service', mock_env) + assert value == expected def test_interpolate_environment_variables_in_volumes(mock_env): @@ -69,6 +75,46 @@ def test_interpolate_environment_variables_in_volumes(mock_env): }, 'other': {}, } - assert interpolate_environment_variables( - volumes, 'volume', Environment.from_env_file(None) - ) == expected + value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env) + assert value == expected + + +def test_escaped_interpolation(defaults_interpolator): + assert defaults_interpolator('$${foo}') == '${foo}' + + +def test_invalid_interpolation(defaults_interpolator): + with pytest.raises(InvalidInterpolation): + defaults_interpolator('${') + with pytest.raises(InvalidInterpolation): + defaults_interpolator('$}') + with pytest.raises(InvalidInterpolation): + defaults_interpolator('${}') + with pytest.raises(InvalidInterpolation): + defaults_interpolator('${ }') + with pytest.raises(InvalidInterpolation): + defaults_interpolator('${ foo}') + with pytest.raises(InvalidInterpolation): + defaults_interpolator('${foo }') + with pytest.raises(InvalidInterpolation): + defaults_interpolator('${foo!}') + + +def test_interpolate_missing_no_default(defaults_interpolator): + assert defaults_interpolator("This ${missing} var") == "This var" + assert defaults_interpolator("This ${BAR} var") == "This var" + + +def test_interpolate_with_value(defaults_interpolator): + assert defaults_interpolator("This $FOO var") == "This first var" + assert defaults_interpolator("This ${FOO} var") == "This first var" + + +def test_interpolate_missing_with_default(defaults_interpolator): + assert defaults_interpolator("ok ${missing:-def}") == "ok def" + assert defaults_interpolator("ok ${missing-def}") == "ok def" + + +def test_interpolate_with_empty_and_default_value(defaults_interpolator): + assert defaults_interpolator("ok ${BAR:-def}") == "ok def" + assert defaults_interpolator("ok ${BAR-def}") == "ok " diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py deleted file mode 100644 index c3050c2c..00000000 --- a/tests/unit/interpolation_test.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import unittest - -from compose.config.environment import Environment as bddict -from compose.config.interpolation import interpolate -from compose.config.interpolation import InvalidInterpolation - - -class InterpolationTest(unittest.TestCase): - def test_valid_interpolations(self): - self.assertEqual(interpolate('$foo', bddict(foo='hi')), 'hi') - self.assertEqual(interpolate('${foo}', bddict(foo='hi')), 'hi') - - self.assertEqual(interpolate('${subject} love you', bddict(subject='i')), 'i love you') - self.assertEqual(interpolate('i ${verb} you', bddict(verb='love')), 'i love you') - self.assertEqual(interpolate('i love ${object}', bddict(object='you')), 'i love you') - - def test_empty_value(self): - self.assertEqual(interpolate('${foo}', bddict(foo='')), '') - - def test_unset_value(self): - self.assertEqual(interpolate('${foo}', bddict()), '') - - def test_escaped_interpolation(self): - self.assertEqual(interpolate('$${foo}', bddict(foo='hi')), '${foo}') - - def test_invalid_strings(self): - self.assertRaises(InvalidInterpolation, lambda: interpolate('${', bddict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('$}', bddict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${}', bddict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${ }', bddict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${ foo}', bddict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo }', bddict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo!}', bddict())) From a37d99f20114efced6381336990252f2e8238850 Mon Sep 17 00:00:00 2001 From: Matt Bray Date: Fri, 30 Sep 2016 00:38:48 +0100 Subject: [PATCH 470/580] Zsh completion: change --file description text Signed-off-by: Matt Bray --- contrib/completion/zsh/_docker-compose | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index fae75842..ceb7d0f5 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -386,9 +386,17 @@ _docker-compose() { integer ret=1 typeset -A opt_args + local file_description + + if [[ -n ${words[(r)-f]} || -n ${words[(r)--file]} ]] ; then + file_description="Specify an override docker-compose file (default: docker-compose.override.yml)" + else + file_description="Specify an alternate docker-compose file (default: docker-compose.yml)" + fi + _arguments -C \ '(- :)'{-h,--help}'[Get help]' \ - '*'{-f,--file}'[Specify an alternate docker-compose file (default: docker-compose.yml)]:file:_files -g "*.yml"' \ + '*'{-f,--file}"[${file_description}]:file:_files -g '*.yml'" \ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ '--verbose[Show more output]' \ '(- :)'{-v,--version}'[Print version and exit]' \ From 1a4e81920b31a2241fb37c2737f183939ad32058 Mon Sep 17 00:00:00 2001 From: John Mulhausen Date: Tue, 4 Oct 2016 17:56:18 -0700 Subject: [PATCH 471/580] Remove old documentation source, add README on migration Signed-off-by: John Mulhausen --- docs/Dockerfile | 8 - docs/Makefile | 38 - docs/README.md | 90 +-- docs/bundles.md | 209 ----- docs/completion.md | 68 -- docs/compose-file.md | 1223 ----------------------------- docs/django.md | 194 ----- docs/env-file.md | 43 - docs/environment-variables.md | 107 --- docs/extends.md | 354 --------- docs/faq.md | 128 --- docs/gettingstarted.md | 191 ----- docs/images/django-it-worked.png | Bin 28446 -> 0 bytes docs/images/rails-welcome.png | Bin 71034 -> 0 bytes docs/images/wordpress-files.png | Bin 70823 -> 0 bytes docs/images/wordpress-lang.png | Bin 30149 -> 0 bytes docs/images/wordpress-welcome.png | Bin 62063 -> 0 bytes docs/index.md | 30 - docs/install.md | 136 ---- docs/link-env-deprecated.md | 48 -- docs/networking.md | 154 ---- docs/overview.md | 188 ----- docs/production.md | 88 --- docs/rails.md | 174 ---- docs/reference/build.md | 25 - docs/reference/bundle.md | 31 - docs/reference/config.md | 23 - docs/reference/create.md | 26 - docs/reference/down.md | 38 - docs/reference/envvars.md | 92 --- docs/reference/events.md | 34 - docs/reference/exec.md | 29 - docs/reference/help.md | 18 - docs/reference/index.md | 42 - docs/reference/kill.md | 24 - docs/reference/logs.md | 25 - docs/reference/overview.md | 127 --- docs/reference/pause.md | 18 - docs/reference/port.md | 23 - docs/reference/ps.md | 21 - docs/reference/pull.md | 21 - docs/reference/push.md | 21 - docs/reference/restart.md | 21 - docs/reference/rm.md | 28 - docs/reference/run.md | 56 -- docs/reference/scale.md | 21 - docs/reference/start.md | 18 - docs/reference/stop.md | 22 - docs/reference/unpause.md | 18 - docs/reference/up.md | 55 -- docs/startup-order.md | 88 --- docs/swarm.md | 185 ----- docs/wordpress.md | 112 --- 53 files changed, 7 insertions(+), 4726 deletions(-) delete mode 100644 docs/Dockerfile delete mode 100644 docs/Makefile delete mode 100644 docs/bundles.md delete mode 100644 docs/completion.md delete mode 100644 docs/compose-file.md delete mode 100644 docs/django.md delete mode 100644 docs/env-file.md delete mode 100644 docs/environment-variables.md delete mode 100644 docs/extends.md delete mode 100644 docs/faq.md delete mode 100644 docs/gettingstarted.md delete mode 100644 docs/images/django-it-worked.png delete mode 100644 docs/images/rails-welcome.png delete mode 100644 docs/images/wordpress-files.png delete mode 100644 docs/images/wordpress-lang.png delete mode 100644 docs/images/wordpress-welcome.png delete mode 100644 docs/index.md delete mode 100644 docs/install.md delete mode 100644 docs/link-env-deprecated.md delete mode 100644 docs/networking.md delete mode 100644 docs/overview.md delete mode 100644 docs/production.md delete mode 100644 docs/rails.md delete mode 100644 docs/reference/build.md delete mode 100644 docs/reference/bundle.md delete mode 100644 docs/reference/config.md delete mode 100644 docs/reference/create.md delete mode 100644 docs/reference/down.md delete mode 100644 docs/reference/envvars.md delete mode 100644 docs/reference/events.md delete mode 100644 docs/reference/exec.md delete mode 100644 docs/reference/help.md delete mode 100644 docs/reference/index.md delete mode 100644 docs/reference/kill.md delete mode 100644 docs/reference/logs.md delete mode 100644 docs/reference/overview.md delete mode 100644 docs/reference/pause.md delete mode 100644 docs/reference/port.md delete mode 100644 docs/reference/ps.md delete mode 100644 docs/reference/pull.md delete mode 100644 docs/reference/push.md delete mode 100644 docs/reference/restart.md delete mode 100644 docs/reference/rm.md delete mode 100644 docs/reference/run.md delete mode 100644 docs/reference/scale.md delete mode 100644 docs/reference/start.md delete mode 100644 docs/reference/stop.md delete mode 100644 docs/reference/unpause.md delete mode 100644 docs/reference/up.md delete mode 100644 docs/startup-order.md delete mode 100644 docs/swarm.md delete mode 100644 docs/wordpress.md diff --git a/docs/Dockerfile b/docs/Dockerfile deleted file mode 100644 index 7b5a3b24..00000000 --- a/docs/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM docs/base:oss -MAINTAINER Docker Docs - -ENV PROJECT=compose -# To get the git info for this repo -COPY . /src -RUN rm -rf /docs/content/$PROJECT/ -COPY . /docs/content/$PROJECT/ diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index e6629289..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,38 +0,0 @@ -.PHONY: all default docs docs-build docs-shell shell test - -# to allow `make DOCSDIR=1 docs-shell` (to create a bind mount in docs) -DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR):/docs/content/compose) - -# to allow `make DOCSPORT=9000 docs` -DOCSPORT := 8000 - -# Get the IP ADDRESS -DOCKER_IP=$(shell python -c "import urlparse ; print urlparse.urlparse('$(DOCKER_HOST)').hostname or ''") -HUGO_BASE_URL=$(shell test -z "$(DOCKER_IP)" && echo localhost || echo "$(DOCKER_IP)") -HUGO_BIND_IP=0.0.0.0 - -GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) -GIT_BRANCH_CLEAN := $(shell echo $(GIT_BRANCH) | sed -e "s/[^[:alnum:]]/-/g") -DOCKER_DOCS_IMAGE := docker-docs$(if $(GIT_BRANCH_CLEAN),:$(GIT_BRANCH_CLEAN)) - -DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) -e AWS_S3_BUCKET -e NOCACHE - -# for some docs workarounds (see below in "docs-build" target) -GITCOMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) - -default: docs - -docs: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) --watch - -docs-draft: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --buildDrafts="true" --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) - -docs-shell: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" bash - -test: docs-build - $(DOCKER_RUN_DOCS) "$(DOCKER_DOCS_IMAGE)" - -docs-build: - docker build -t "$(DOCKER_DOCS_IMAGE)" . diff --git a/docs/README.md b/docs/README.md index e60fa48c..03d2e3a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,86 +1,10 @@ - +# The docs have been moved! -# Contributing to the Docker Compose documentation +The documentation for Compose has been merged into +[the general documentation repo](https://github.com/docker/docker.github.io). -The documentation in this directory is part of the [https://docs.docker.com](https://docs.docker.com) website. Docker uses [the Hugo static generator](http://gohugo.io/overview/introduction/) to convert project Markdown files to a static HTML site. +The docs for Compose are now here: +https://github.com/docker/docker.github.io/tree/master/compose -You don't need to be a Hugo expert to contribute to the compose documentation. If you are familiar with Markdown, you can modify the content in the `docs` files. - -If you want to add a new file or change the location of the document in the menu, you do need to know a little more. - -## Documentation contributing workflow - -1. Edit a Markdown file in the tree. - -2. Save your changes. - -3. Make sure you are in the `docs` subdirectory. - -4. Build the documentation. - - $ make docs - ---> ffcf3f6c4e97 - Removing intermediate container a676414185e8 - Successfully built ffcf3f6c4e97 - docker run --rm -it -e AWS_S3_BUCKET -e NOCACHE -p 8000:8000 -e DOCKERHOST "docs-base:test-tooling" hugo server --port=8000 --baseUrl=192.168.59.103 --bind=0.0.0.0 - ERROR: 2015/06/13 MenuEntry's .Url is deprecated and will be removed in Hugo 0.15. Use .URL instead. - 0 of 4 drafts rendered - 0 future content - 12 pages created - 0 paginator pages created - 0 tags created - 0 categories created - in 55 ms - Serving pages from /docs/public - Web Server is available at http://0.0.0.0:8000/ - Press Ctrl+C to stop - -5. Open the available server in your browser. - - The documentation server has the complete menu but only the Docker Compose - documentation resolves. You can't access the other project docs from this - localized build. - -## Tips on Hugo metadata and menu positioning - -The top of each Docker Compose documentation file contains TOML metadata. The metadata is commented out to prevent it from appearing in GitHub. - - - -The metadata alone has this structure: - - +++ - title = "Extending services in Compose" - description = "How to use Docker Compose's extends keyword to share configuration between files and projects" - keywords = ["fig, composition, compose, docker, orchestration, documentation, docs"] - [menu.main] - parent="workw_compose" - weight=2 - +++ - -The `[menu.main]` section refers to navigation defined [in the main Docker menu](https://github.com/docker/docs-base/blob/hugo/config.toml). This metadata says *add a menu item called* Extending services in Compose *to the menu with the* `smn_workdw_compose` *identifier*. If you locate the menu in the configuration, you'll find *Create multi-container applications* is the menu title. - -You can move an article in the tree by specifying a new parent. You can shift the location of the item by changing its weight. Higher numbers are heavier and shift the item to the bottom of menu. Low or no numbers shift it up. - - -## Other key documentation repositories - -The `docker/docs-base` repository contains [the Hugo theme and menu configuration](https://github.com/docker/docs-base). If you open the `Dockerfile` you'll see the `make docs` relies on this as a base image for building the Compose documentation. - -The `docker/docs.docker.com` repository contains [build system for building the Docker documentation site](https://github.com/docker/docs.docker.com). Fork this repository to build the entire documentation site. +As always, the docs remain open-source and we appreciate your feedback and +pull requests! diff --git a/docs/bundles.md b/docs/bundles.md deleted file mode 100644 index 096c9ec3..00000000 --- a/docs/bundles.md +++ /dev/null @@ -1,209 +0,0 @@ - - - -# Docker Stacks and Distributed Application Bundles (experimental) - -> **Note**: This is a copy of the [Docker Stacks and Distributed Application -> Bundles](https://github.com/docker/docker/blob/v1.12.0-rc4/experimental/docker-stacks-and-bundles.md) -> document in the [docker/docker repo](https://github.com/docker/docker). - -## Overview - -Docker Stacks and Distributed Application Bundles are experimental features -introduced in Docker 1.12 and Docker Compose 1.8, alongside the concept of -swarm mode, and Nodes and Services in the Engine API. - -A Dockerfile can be built into an image, and containers can be created from -that image. Similarly, a docker-compose.yml can be built into a **distributed -application bundle**, and **stacks** can be created from that bundle. In that -sense, the bundle is a multi-services distributable image format. - -As of Docker 1.12 and Compose 1.8, the features are experimental. Neither -Docker Engine nor the Docker Registry support distribution of bundles. - -## Producing a bundle - -The easiest way to produce a bundle is to generate it using `docker-compose` -from an existing `docker-compose.yml`. Of course, that's just *one* possible way -to proceed, in the same way that `docker build` isn't the only way to produce a -Docker image. - -From `docker-compose`: - -```bash -$ docker-compose bundle -WARNING: Unsupported key 'network_mode' in services.nsqd - ignoring -WARNING: Unsupported key 'links' in services.nsqd - ignoring -WARNING: Unsupported key 'volumes' in services.nsqd - ignoring -[...] -Wrote bundle to vossibility-stack.dab -``` - -## Creating a stack from a bundle - -> **Note**: Because support for stacks and bundles is in the experimental stage, -> you need to install an experimental build of Docker Engine to use it. -> -> If you're on Mac or Windows, download the “Beta channel” version of -> [Docker for Mac](https://docs.docker.com/docker-for-mac/) or -> [Docker for Windows](https://docs.docker.com/docker-for-windows/) to install -> it. If you're on Linux, follow the instructions in the -> [experimental build README](https://github.com/docker/docker/blob/master/experimental/README.md). - -A stack is created using the `docker deploy` command: - -```bash -# docker deploy --help - -Usage: docker deploy [OPTIONS] STACK - -Create and update a stack - -Options: - --file string Path to a Distributed Application Bundle file (Default: STACK.dab) - --help Print usage - --with-registry-auth Send registry authentication details to Swarm agents -``` - -Let's deploy the stack created before: - -```bash -# docker deploy vossibility-stack -Loading bundle from vossibility-stack.dab -Creating service vossibility-stack_elasticsearch -Creating service vossibility-stack_kibana -Creating service vossibility-stack_logstash -Creating service vossibility-stack_lookupd -Creating service vossibility-stack_nsqd -Creating service vossibility-stack_vossibility-collector -``` - -We can verify that services were correctly created: - -```bash -# docker service ls -ID NAME REPLICAS IMAGE -COMMAND -29bv0vnlm903 vossibility-stack_lookupd 1 nsqio/nsq@sha256:eeba05599f31eba418e96e71e0984c3dc96963ceb66924dd37a47bf7ce18a662 /nsqlookupd -4awt47624qwh vossibility-stack_nsqd 1 nsqio/nsq@sha256:eeba05599f31eba418e96e71e0984c3dc96963ceb66924dd37a47bf7ce18a662 /nsqd --data-path=/data --lookupd-tcp-address=lookupd:4160 -4tjx9biia6fs vossibility-stack_elasticsearch 1 elasticsearch@sha256:12ac7c6af55d001f71800b83ba91a04f716e58d82e748fa6e5a7359eed2301aa -7563uuzr9eys vossibility-stack_kibana 1 kibana@sha256:6995a2d25709a62694a937b8a529ff36da92ebee74bafd7bf00e6caf6db2eb03 -9gc5m4met4he vossibility-stack_logstash 1 logstash@sha256:2dc8bddd1bb4a5a34e8ebaf73749f6413c101b2edef6617f2f7713926d2141fe logstash -f /etc/logstash/conf.d/logstash.conf -axqh55ipl40h vossibility-stack_vossibility-collector 1 icecrime/vossibility-collector@sha256:f03f2977203ba6253988c18d04061c5ec7aab46bca9dfd89a9a1fa4500989fba --config /config/config.toml --debug -``` - -## Managing stacks - -Stacks are managed using the `docker stack` command: - -```bash -# docker stack --help - -Usage: docker stack COMMAND - -Manage Docker stacks - -Options: - --help Print usage - -Commands: - config Print the stack configuration - deploy Create and update a stack - rm Remove the stack - services List the services in the stack - tasks List the tasks in the stack - -Run 'docker stack COMMAND --help' for more information on a command. -``` - -## Bundle file format - -Distributed application bundles are described in a JSON format. When bundles -are persisted as files, the file extension is `.dab`. - -A bundle has two top-level fields: `version` and `services`. The version used -by Docker 1.12 tools is `0.1`. - -`services` in the bundle are the services that comprise the app. They -correspond to the new `Service` object introduced in the 1.12 Docker Engine API. - -A service has the following fields: - -
-
- Image (required) string -
-
- The image that the service will run. Docker images should be referenced - with full content hash to fully specify the deployment artifact for the - service. Example: - postgres@sha256:e0a230a9f5b4e1b8b03bb3e8cf7322b0e42b7838c5c87f4545edb48f5eb8f077 -
-
- Command []string -
-
- Command to run in service containers. -
-
- Args []string -
-
- Arguments passed to the service containers. -
-
- Env []string -
-
- Environment variables. -
-
- Labels map[string]string -
-
- Labels used for setting meta data on services. -
-
- Ports []Port -
-
- Service ports (composed of Port (int) and - Protocol (string). A service description can - only specify the container port to be exposed. These ports can be - mapped on runtime hosts at the operator's discretion. -
- -
- WorkingDir string -
-
- Working directory inside the service containers. -
- -
- User string -
-
- Username or UID (format: <name|uid>[:<group|gid>]). -
- -
- Networks []string -
-
- Networks that the service containers should be connected to. An entity - deploying a bundle should create networks as needed. -
-
- -> **Note:** Some configuration options are not yet supported in the DAB format, -> including volume mounts. diff --git a/docs/completion.md b/docs/completion.md deleted file mode 100644 index 2076d512..00000000 --- a/docs/completion.md +++ /dev/null @@ -1,68 +0,0 @@ - - -# Command-line Completion - -Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) -for the bash and zsh shell. - -## Installing Command Completion - -### Bash - -Make sure bash completion is installed. If you use a current Linux in a non-minimal installation, bash completion should be available. -On a Mac, install with `brew install bash-completion` - -Place the completion script in `/etc/bash_completion.d/` (`/usr/local/etc/bash_completion.d/` on a Mac), using e.g. - - curl -L https://raw.githubusercontent.com/docker/compose/$(docker-compose version --short)/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose - -Completion will be available upon next login. - -### Zsh - -Place the completion script in your `/path/to/zsh/completion`, using e.g. `~/.zsh/completion/` - - mkdir -p ~/.zsh/completion - curl -L https://raw.githubusercontent.com/docker/compose/$(docker-compose version --short)/contrib/completion/zsh/_docker-compose > ~/.zsh/completion/_docker-compose - -Include the directory in your `$fpath`, e.g. by adding in `~/.zshrc` - - fpath=(~/.zsh/completion $fpath) - -Make sure `compinit` is loaded or do it by adding in `~/.zshrc` - - autoload -Uz compinit && compinit -i - -Then reload your shell - - exec $SHELL -l - -## Available completions - -Depending on what you typed on the command line so far, it will complete - - - available docker-compose commands - - options that are available for a particular command - - service names that make sense in a given context (e.g. services with running or stopped instances or services based on images vs. services based on Dockerfiles). For `docker-compose scale`, completed service names will automatically have "=" appended. - - arguments for selected options, e.g. `docker-compose kill -s` will complete some signals like SIGHUP and SIGUSR1. - -Enjoy working with Compose faster and with less typos! - -## Compose documentation - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/compose-file.md b/docs/compose-file.md deleted file mode 100644 index cfc242ce..00000000 --- a/docs/compose-file.md +++ /dev/null @@ -1,1223 +0,0 @@ - - - -# Compose file reference - -The Compose file is a [YAML](http://yaml.org/) file defining -[services](#service-configuration-reference), -[networks](#network-configuration-reference) and -[volumes](#volume-configuration-reference). -The default path for a Compose file is `./docker-compose.yml`. - -A service definition contains configuration which will be applied to each -container started for that service, much like passing command-line parameters to -`docker run`. Likewise, network and volume definitions are analogous to -`docker network create` and `docker volume create`. - -As with `docker run`, options specified in the Dockerfile (e.g., `CMD`, -`EXPOSE`, `VOLUME`, `ENV`) are respected by default - you don't need to -specify them again in `docker-compose.yml`. - -You can use environment variables in configuration values with a Bash-like -`${VARIABLE}` syntax - see [variable substitution](#variable-substitution) for -full details. - - -## Service configuration reference - -> **Note:** There are two versions of the Compose file format – version 1 (the -> legacy format, which does not support volumes or networks) and version 2 (the -> most up-to-date). For more information, see the [Versioning](#versioning) -> section. - -This section contains a list of all configuration options supported by a service -definition. - -### build - -Configuration options that are applied at build time. - -`build` can be specified either as a string containing a path to the build -context, or an object with the path specified under [context](#context) and -optionally [dockerfile](#dockerfile) and [args](#args). - - build: ./dir - - build: - context: ./dir - dockerfile: Dockerfile-alternate - args: - buildno: 1 - -If you specify `image` as well as `build`, then Compose names the built image -with the `webapp` and optional `tag` specified in `image`: - - build: ./dir - image: webapp:tag - -This will result in an image named `webapp` and tagged `tag`, built from `./dir`. - -> **Note**: In the [version 1 file format](#version-1), `build` is different in -> two ways: -> -> - Only the string form (`build: .`) is allowed - not the object form. -> - Using `build` together with `image` is not allowed. Attempting to do so -> results in an error. - -#### context - -> [Version 2 file format](#version-2) only. In version 1, just use -> [build](#build). - -Either a path to a directory containing a Dockerfile, or a url to a git repository. - -When the value supplied is a relative path, it is interpreted as relative to the -location of the Compose file. This directory is also the build context that is -sent to the Docker daemon. - -Compose will build and tag it with a generated name, and use that image thereafter. - - build: - context: ./dir - -#### dockerfile - -Alternate Dockerfile. - -Compose will use an alternate file to build with. A build path must also be -specified. - - build: - context: . - dockerfile: Dockerfile-alternate - -> **Note**: In the [version 1 file format](#version-1), `dockerfile` is -> different in two ways: - - * It appears alongside `build`, not as a sub-option: - - build: . - dockerfile: Dockerfile-alternate - - * Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. - -#### args - -> [Version 2 file format](#version-2) only. - -Add build arguments, which are environment variables accessible only during the -build process. - -First, specify the arguments in your Dockerfile: - - ARG buildno - ARG password - - RUN echo "Build number: $buildno" - RUN script-requiring-password.sh "$password" - -Then specify the arguments under the `build` key. You can pass either a mapping -or a list: - - build: - context: . - args: - buildno: 1 - password: secret - - build: - context: . - args: - - buildno=1 - - password=secret - -You can omit the value when specifying a build argument, in which case its value -at build time is the value in the environment where Compose is running. - - args: - - buildno - - password - -> **Note**: YAML boolean values (`true`, `false`, `yes`, `no`, `on`, `off`) must -> be enclosed in quotes, so that the parser interprets them as strings. - -### cap_add, cap_drop - -Add or drop container capabilities. -See `man 7 capabilities` for a full list. - - cap_add: - - ALL - - cap_drop: - - NET_ADMIN - - SYS_ADMIN - -### command - -Override the default command. - - command: bundle exec thin -p 3000 - -The command can also be a list, in a manner similar to [dockerfile](https://docs.docker.com/engine/reference/builder/#cmd): - - command: [bundle, exec, thin, -p, 3000] - -### cgroup_parent - -Specify an optional parent cgroup for the container. - - cgroup_parent: m-executor-abcd - -### container_name - -Specify a custom container name, rather than a generated default name. - - container_name: my-web-container - -Because Docker container names must be unique, you cannot scale a service -beyond 1 container if you have specified a custom name. Attempting to do so -results in an error. - -### devices - -List of device mappings. Uses the same format as the `--device` docker -client create option. - - devices: - - "/dev/ttyUSB0:/dev/ttyUSB0" - -### depends_on - -Express dependency between services, which has two effects: - -- `docker-compose up` will start services in dependency order. In the following - example, `db` and `redis` will be started before `web`. - -- `docker-compose up SERVICE` will automatically include `SERVICE`'s - dependencies. In the following example, `docker-compose up web` will also - create and start `db` and `redis`. - -Simple example: - - version: '2' - services: - web: - build: . - depends_on: - - db - - redis - redis: - image: redis - db: - image: postgres - -> **Note:** `depends_on` will not wait for `db` and `redis` to be "ready" before -> starting `web` - only until they have been started. If you need to wait -> for a service to be ready, see [Controlling startup order](startup-order.md) -> for more on this problem and strategies for solving it. - -### dns - -Custom DNS servers. Can be a single value or a list. - - dns: 8.8.8.8 - dns: - - 8.8.8.8 - - 9.9.9.9 - -### dns_search - -Custom DNS search domains. Can be a single value or a list. - - dns_search: example.com - dns_search: - - dc1.example.com - - dc2.example.com - -### tmpfs - -> [Version 2 file format](#version-2) only. - -Mount a temporary file system inside the container. Can be a single value or a list. - - tmpfs: /run - tmpfs: - - /run - - /tmp - -### entrypoint - -Override the default entrypoint. - - entrypoint: /code/entrypoint.sh - -The entrypoint can also be a list, in a manner similar to [dockerfile](https://docs.docker.com/engine/reference/builder/#entrypoint): - - entrypoint: - - php - - -d - - zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20100525/xdebug.so - - -d - - memory_limit=-1 - - vendor/bin/phpunit - - -### env_file - -Add environment variables from a file. Can be a single value or a list. - -If you have specified a Compose file with `docker-compose -f FILE`, paths in -`env_file` are relative to the directory that file is in. - -Environment variables specified in `environment` override these values. - - env_file: .env - - env_file: - - ./common.env - - ./apps/web.env - - /opt/secrets.env - -Compose expects each line in an env file to be in `VAR=VAL` format. Lines -beginning with `#` (i.e. comments) are ignored, as are blank lines. - - # Set Rails/Rack environment - RACK_ENV=development - -> **Note:** If your service specifies a [build](#build) option, variables -> defined in environment files will _not_ be automatically visible during the -> build. Use the [args](#args) sub-option of `build` to define build-time -> environment variables. - -### environment - -Add environment variables. You can use either an array or a dictionary. Any -boolean values; true, false, yes no, need to be enclosed in quotes to ensure -they are not converted to True or False by the YML parser. - -Environment variables with only a key are resolved to their values on the -machine Compose is running on, which can be helpful for secret or host-specific values. - - environment: - RACK_ENV: development - SHOW: 'true' - SESSION_SECRET: - - environment: - - RACK_ENV=development - - SHOW=true - - SESSION_SECRET - -> **Note:** If your service specifies a [build](#build) option, variables -> defined in `environment` will _not_ be automatically visible during the -> build. Use the [args](#args) sub-option of `build` to define build-time -> environment variables. - -### expose - -Expose ports without publishing them to the host machine - they'll only be -accessible to linked services. Only the internal port can be specified. - - expose: - - "3000" - - "8000" - -### extends - -Extend another service, in the current file or another, optionally overriding -configuration. - -You can use `extends` on any service together with other configuration keys. -The `extends` value must be a dictionary defined with a required `service` -and an optional `file` key. - - extends: - file: common.yml - service: webapp - -The `service` the name of the service being extended, for example -`web` or `database`. The `file` is the location of a Compose configuration -file defining that service. - -If you omit the `file` Compose looks for the service configuration in the -current file. The `file` value can be an absolute or relative path. If you -specify a relative path, Compose treats it as relative to the location of the -current file. - -You can extend a service that itself extends another. You can extend -indefinitely. Compose does not support circular references and `docker-compose` -returns an error if it encounters one. - -For more on `extends`, see the -[the extends documentation](extends.md#extending-services). - -### external_links - -Link to containers started outside this `docker-compose.yml` or even outside -of Compose, especially for containers that provide shared or common services. -`external_links` follow semantics similar to `links` when specifying both the -container name and the link alias (`CONTAINER:ALIAS`). - - external_links: - - redis_1 - - project_db_1:mysql - - project_db_1:postgresql - -> **Note:** If you're using the [version 2 file format](#version-2), the -> externally-created containers must be connected to at least one of the same -> networks as the service which is linking to them. - -### extra_hosts - -Add hostname mappings. Use the same values as the docker client `--add-host` parameter. - - extra_hosts: - - "somehost:162.242.195.82" - - "otherhost:50.31.209.229" - -An entry with the ip address and hostname will be created in `/etc/hosts` inside containers for this service, e.g: - - 162.242.195.82 somehost - 50.31.209.229 otherhost - -### image - -Specify the image to start the container from. Can either be a repository/tag or -a partial image ID. - - image: redis - image: ubuntu:14.04 - image: tutum/influxdb - image: example-registry.com:4000/postgresql - image: a4bc65fd - -If the image does not exist, Compose attempts to pull it, unless you have also -specified [build](#build), in which case it builds it using the specified -options and tags it with the specified tag. - -> **Note**: In the [version 1 file format](#version-1), using `build` together -> with `image` is not allowed. Attempting to do so results in an error. - -### labels - -Add metadata to containers using [Docker labels](https://docs.docker.com/engine/userguide/labels-custom-metadata/). You can use either an array or a dictionary. - -It's recommended that you use reverse-DNS notation to prevent your labels from conflicting with those used by other software. - - labels: - com.example.description: "Accounting webapp" - com.example.department: "Finance" - com.example.label-with-empty-value: "" - - labels: - - "com.example.description=Accounting webapp" - - "com.example.department=Finance" - - "com.example.label-with-empty-value" - -### links - -Link to containers in another service. Either specify both the service name and -a link alias (`SERVICE:ALIAS`), or just the service name. - - web: - links: - - db - - db:database - - redis - -Containers for the linked service will be reachable at a hostname identical to -the alias, or the service name if no alias was specified. - -Links also express dependency between services in the same way as -[depends_on](#depends-on), so they determine the order of service startup. - -> **Note:** If you define both links and [networks](#networks), services with -> links between them must share at least one network in common in order to -> communicate. - -### logging - -> [Version 2 file format](#version-2) only. In version 1, use -> [log_driver](#log_driver) and [log_opt](#log_opt). - -Logging configuration for the service. - - logging: - driver: syslog - options: - syslog-address: "tcp://192.168.0.42:123" - -The `driver` name specifies a logging driver for the service's -containers, as with the ``--log-driver`` option for docker run -([documented here](https://docs.docker.com/engine/reference/logging/overview/)). - -The default value is json-file. - - driver: "json-file" - driver: "syslog" - driver: "none" - -> **Note:** Only the `json-file` driver makes the logs available directly from -> `docker-compose up` and `docker-compose logs`. Using any other driver will not -> print any logs. - -Specify logging options for the logging driver with the ``options`` key, as with the ``--log-opt`` option for `docker run`. - -Logging options are key-value pairs. An example of `syslog` options: - - driver: "syslog" - options: - syslog-address: "tcp://192.168.0.42:123" - -### log_driver - -> [Version 1 file format](#version-1) only. In version 2, use -> [logging](#logging). - -Specify a log driver. The default is `json-file`. - - log_driver: syslog - -### log_opt - -> [Version 1 file format](#version-1) only. In version 2, use -> [logging](#logging). - -Specify logging options as key-value pairs. An example of `syslog` options: - - log_opt: - syslog-address: "tcp://192.168.0.42:123" - -### net - -> [Version 1 file format](#version-1) only. In version 2, use -> [network_mode](#network_mode). - -Network mode. Use the same values as the docker client `--net` parameter. -The `container:...` form can take a service name instead of a container name or -id. - - net: "bridge" - net: "host" - net: "none" - net: "container:[service name or container name/id]" - -### network_mode - -> [Version 2 file format](#version-2) only. In version 1, use [net](#net). - -Network mode. Use the same values as the docker client `--net` parameter, plus -the special form `service:[service name]`. - - network_mode: "bridge" - network_mode: "host" - network_mode: "none" - network_mode: "service:[service name]" - network_mode: "container:[container name/id]" - -### networks - -> [Version 2 file format](#version-2) only. In version 1, use [net](#net). - -Networks to join, referencing entries under the -[top-level `networks` key](#network-configuration-reference). - - services: - some-service: - networks: - - some-network - - other-network - -#### aliases - -Aliases (alternative hostnames) for this service on the network. Other containers on the same network can use either the service name or this alias to connect to one of the service's containers. - -Since `aliases` is network-scoped, the same service can have different aliases on different networks. - -> **Note**: A network-wide alias can be shared by multiple containers, and even by multiple services. If it is, then exactly which container the name will resolve to is not guaranteed. - -The general format is shown here. - - services: - some-service: - networks: - some-network: - aliases: - - alias1 - - alias3 - other-network: - aliases: - - alias2 - -In the example below, three services are provided (`web`, `worker`, and `db`), along with two networks (`new` and `legacy`). The `db` service is reachable at the hostname `db` or `database` on the `new` network, and at `db` or `mysql` on the `legacy` network. - - version: '2' - - services: - web: - build: ./web - networks: - - new - - worker: - build: ./worker - networks: - - legacy - - db: - image: mysql - networks: - new: - aliases: - - database - legacy: - aliases: - - mysql - - networks: - new: - legacy: - -#### ipv4_address, ipv6_address - -Specify a static IP address for containers for this service when joining the network. - -The corresponding network configuration in the [top-level networks section](#network-configuration-reference) must have an `ipam` block with subnet and gateway configurations covering each static address. If IPv6 addressing is desired, the `com.docker.network.enable_ipv6` driver option must be set to `true`. - -An example: - - version: '2' - - services: - app: - image: busybox - command: ifconfig - networks: - app_net: - ipv4_address: 172.16.238.10 - ipv6_address: 2001:3984:3989::10 - - networks: - app_net: - driver: bridge - driver_opts: - com.docker.network.enable_ipv6: "true" - ipam: - driver: default - config: - - subnet: 172.16.238.0/24 - gateway: 172.16.238.1 - - subnet: 2001:3984:3989::/64 - gateway: 2001:3984:3989::1 - -#### link_local_ips - -> [Added in version 2.1 file format](#version-21). - -Specify a list of link-local IPs. Link-local IPs are special IPs which belong -to a well known subnet and are purely managed by the operator, usually -dependent on the architecture where they are deployed. Therefore they are not -managed by docker (IPAM driver). - -Example usage: - - version: '2.1' - services: - app: - image: busybox - command: top - networks: - app_net: - link_local_ips: - - 57.123.22.11 - - 57.123.22.13 - networks: - app_net: - driver: bridge - -### pid - - pid: "host" - -Sets the PID mode to the host PID mode. This turns on sharing between -container and the host operating system the PID address space. Containers -launched with this flag will be able to access and manipulate other -containers in the bare-metal machine's namespace and vise-versa. - -### ports - -Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container -port (a random host port will be chosen). - -> **Note:** When mapping ports in the `HOST:CONTAINER` format, you may experience -> erroneous results when using a container port lower than 60, because YAML will -> parse numbers in the format `xx:yy` as sexagesimal (base 60). For this reason, -> we recommend always explicitly specifying your port mappings as strings. - - ports: - - "3000" - - "3000-3005" - - "8000:8000" - - "9090-9091:8080-8081" - - "49100:22" - - "127.0.0.1:8001:8001" - - "127.0.0.1:5000-5010:5000-5010" - -### security_opt - -Override the default labeling scheme for each container. - - security_opt: - - label:user:USER - - label:role:ROLE - -### stop_signal - -Sets an alternative signal to stop the container. By default `stop` uses -SIGTERM. Setting an alternative signal using `stop_signal` will cause -`stop` to send that signal instead. - - stop_signal: SIGUSR1 - -### ulimits - -Override the default ulimits for a container. You can either specify a single -limit as an integer or soft/hard limits as a mapping. - - - ulimits: - nproc: 65535 - nofile: - soft: 20000 - hard: 40000 - -### volumes, volume\_driver - -Mount paths or named volumes, optionally specifying a path on the host machine -(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). -For [version 2 files](#version-2), named volumes need to be specified with the -[top-level `volumes` key](#volume-configuration-reference). -When using [version 1](#version-1), the Docker Engine will create the named -volume automatically if it doesn't exist. - -You can mount a relative path on the host, which will expand relative to -the directory of the Compose configuration file being used. Relative paths -should always begin with `.` or `..`. - - volumes: - # Just specify a path and let the Engine create a volume - - /var/lib/mysql - - # Specify an absolute path mapping - - /opt/data:/var/lib/mysql - - # Path on the host, relative to the Compose file - - ./cache:/tmp/cache - - # User-relative path - - ~/configs:/etc/configs/:ro - - # Named volume - - datavolume:/var/lib/mysql - -If you do not use a host path, you may specify a `volume_driver`. - - volume_driver: mydriver - -Note that for [version 2 files](#version-2), this driver -will not apply to named volumes (you should use the `driver` option when -[declaring the volume](#volume-configuration-reference) instead). -For [version 1](#version-1), both named volumes and container volumes will -use the specified driver. - -> Note: No path expansion will be done if you have also specified a -> `volume_driver`. - -See [Docker Volumes](https://docs.docker.com/engine/userguide/dockervolumes/) and -[Volume Plugins](https://docs.docker.com/engine/extend/plugins_volume/) for more -information. - -### volumes_from - -Mount all of the volumes from another service or container, optionally -specifying read-only access (``ro``) or read-write (``rw``). If no access level is specified, -then read-write will be used. - - volumes_from: - - service_name - - service_name:ro - - container:container_name - - container:container_name:rw - -> **Note:** The `container:...` formats are only supported in the -> [version 2 file format](#version-2). In [version 1](#version-1), you can use -> container names without marking them as such: -> -> - service_name -> - service_name:ro -> - container_name -> - container_name:rw - -### cpu\_shares, cpu\_quota, cpuset, domainname, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, mem\_swappiness, oom\_score\_adj, privileged, read\_only, restart, shm\_size, stdin\_open, tty, user, working\_dir - -Each of these is a single value, analogous to its -[docker run](https://docs.docker.com/engine/reference/run/) counterpart. - - cpu_shares: 73 - cpu_quota: 50000 - cpuset: 0,1 - - user: postgresql - working_dir: /code - - domainname: foo.com - hostname: foo - ipc: host - mac_address: 02:42:ac:11:65:43 - - mem_limit: 1000000000 - memswap_limit: 2000000000 - mem_swappiness: 10 - privileged: true - - restart: always - - read_only: true - shm_size: 64M - stdin_open: true - tty: true - - -## Volume configuration reference - -While it is possible to declare volumes on the fly as part of the service -declaration, this section allows you to create named volumes that can be -reused across multiple services (without relying on `volumes_from`), and are -easily retrieved and inspected using the docker command line or API. -See the [docker volume](https://docs.docker.com/engine/reference/commandline/volume_create/) -subcommand documentation for more information. - -### driver - -Specify which volume driver should be used for this volume. Defaults to -`local`. The Docker Engine will return an error if the driver is not available. - - driver: foobar - -### driver_opts - -Specify a list of options as key-value pairs to pass to the driver for this -volume. Those options are driver-dependent - consult the driver's -documentation for more information. Optional. - - driver_opts: - foo: "bar" - baz: 1 - -### external - -If set to `true`, specifies that this volume has been created outside of -Compose. `docker-compose up` will not attempt to create it, and will raise -an error if it doesn't exist. - -`external` cannot be used in conjunction with other volume configuration keys -(`driver`, `driver_opts`). - -In the example below, instead of attemping to create a volume called -`[projectname]_data`, Compose will look for an existing volume simply -called `data` and mount it into the `db` service's containers. - - version: '2' - - services: - db: - image: postgres - volumes: - - data:/var/lib/postgresql/data - - volumes: - data: - external: true - -You can also specify the name of the volume separately from the name used to -refer to it within the Compose file: - - volumes: - data: - external: - name: actual-name-of-volume - - -## Network configuration reference - -The top-level `networks` key lets you specify networks to be created. For a full -explanation of Compose's use of Docker networking features, see the -[Networking guide](networking.md). - -### driver - -Specify which driver should be used for this network. - -The default driver depends on how the Docker Engine you're using is configured, -but in most instances it will be `bridge` on a single host and `overlay` on a -Swarm. - -The Docker Engine will return an error if the driver is not available. - - driver: overlay - -### driver_opts - -Specify a list of options as key-value pairs to pass to the driver for this -network. Those options are driver-dependent - consult the driver's -documentation for more information. Optional. - - driver_opts: - foo: "bar" - baz: 1 - -### ipam - -Specify custom IPAM config. This is an object with several properties, each of -which is optional: - -- `driver`: Custom IPAM driver, instead of the default. -- `config`: A list with zero or more config blocks, each containing any of - the following keys: - - `subnet`: Subnet in CIDR format that represents a network segment - - `ip_range`: Range of IPs from which to allocate container IPs - - `gateway`: IPv4 or IPv6 gateway for the master subnet - - `aux_addresses`: Auxiliary IPv4 or IPv6 addresses used by Network driver, - as a mapping from hostname to IP - -A full example: - - ipam: - driver: default - config: - - subnet: 172.28.0.0/16 - ip_range: 172.28.5.0/24 - gateway: 172.28.5.254 - aux_addresses: - host1: 172.28.1.5 - host2: 172.28.1.6 - host3: 172.28.1.7 - -### group_add - -Specify additional groups (by name or number) which the user inside the container will be a member of. Groups must exist in both the container and the host system to be added. An example of where this is useful is when multiple containers (running as different users) need to all read or write the same file on the host system. That file can be owned by a group shared by all the containers, and specified in `group_add`. See the [Docker documentation](https://docs.docker.com/engine/reference/run/#/additional-groups) for more details. - -A full example: - - version: '2' - services: - image: alpine - group_add: - - mail - -Running `id` inside the created container will show that the user belongs to the `mail` group, which would not have been the case if `group_add` were not used. - -### internal - -By default, Docker also connects a bridge network to it to provide external connectivity. If you want to create an externally isolated overlay network, you can set this option to `true`. - -### external - -If set to `true`, specifies that this network has been created outside of -Compose. `docker-compose up` will not attempt to create it, and will raise -an error if it doesn't exist. - -`external` cannot be used in conjunction with other network configuration keys -(`driver`, `driver_opts`, `ipam`, `internal`). - -In the example below, `proxy` is the gateway to the outside world. Instead of -attemping to create a network called `[projectname]_outside`, Compose will -look for an existing network simply called `outside` and connect the `proxy` -service's containers to it. - - version: '2' - - services: - proxy: - build: ./proxy - networks: - - outside - - default - app: - build: ./app - networks: - - default - - networks: - outside: - external: true - -You can also specify the name of the network separately from the name used to -refer to it within the Compose file: - - networks: - outside: - external: - name: actual-name-of-network - - -## Versioning - -There are two versions of the Compose file format: - -- Version 1, the legacy format. This is specified by omitting a `version` key at - the root of the YAML. -- Version 2, the recommended format. This is specified with a `version: '2'` entry - at the root of the YAML. - -To move your project from version 1 to 2, see the [Upgrading](#upgrading) -section. - -> **Note:** If you're using -> [multiple Compose files](extends.md#different-environments) or -> [extending services](extends.md#extending-services), each file must be of the -> same version - you cannot mix version 1 and 2 in a single project. - -Several things differ depending on which version you use: - -- The structure and permitted configuration keys -- The minimum Docker Engine version you must be running -- Compose's behaviour with regards to networking - -These differences are explained below. - - -### Version 1 - -Compose files that do not declare a version are considered "version 1". In -those files, all the [services](#service-configuration-reference) are declared -at the root of the document. - -Version 1 is supported by **Compose up to 1.6.x**. It will be deprecated in a -future Compose release. - -Version 1 files cannot declare named -[volumes](#volume-configuration-reference), [networks](networking.md) or -[build arguments](#args). - -Example: - - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis - redis: - image: redis - - -### Version 2 - -Compose files using the version 2 syntax must indicate the version number at -the root of the document. All [services](#service-configuration-reference) -must be declared under the `services` key. - -Version 2 files are supported by **Compose 1.6.0+** and require a Docker Engine -of version **1.10.0+**. - -Named [volumes](#volume-configuration-reference) can be declared under the -`volumes` key, and [networks](#network-configuration-reference) can be declared -under the `networks` key. - -Simple example: - - version: '2' - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - redis: - image: redis - -A more extended example, defining volumes and networks: - - version: '2' - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - networks: - - front-tier - - back-tier - redis: - image: redis - volumes: - - redis-data:/var/lib/redis - networks: - - back-tier - volumes: - redis-data: - driver: local - networks: - front-tier: - driver: bridge - back-tier: - driver: bridge - -### Version 2.1 - -An upgrade of [version 2](#version-2) that introduces new parameters only -available with Docker Engine version **1.12.0+** - -Introduces: - -- [`link_local_ips`](#link_local_ips) -- ... - -### Upgrading - -In the majority of cases, moving from version 1 to 2 is a very simple process: - -1. Indent the whole file by one level and put a `services:` key at the top. -2. Add a `version: '2'` line at the top of the file. - -It's more complicated if you're using particular configuration features: - -- `dockerfile`: This now lives under the `build` key: - - build: - context: . - dockerfile: Dockerfile-alternate - -- `log_driver`, `log_opt`: These now live under the `logging` key: - - logging: - driver: syslog - options: - syslog-address: "tcp://192.168.0.42:123" - -- `links` with environment variables: As documented in the - [environment variables reference](link-env-deprecated.md), environment variables - created by - links have been deprecated for some time. In the new Docker network system, - they have been removed. You should either connect directly to the - appropriate hostname or set the relevant environment variable yourself, - using the link hostname: - - web: - links: - - db - environment: - - DB_PORT=tcp://db:5432 - -- `external_links`: Compose uses Docker networks when running version 2 - projects, so links behave slightly differently. In particular, two - containers must be connected to at least one network in common in order to - communicate, even if explicitly linked together. - - Either connect the external container to your app's - [default network](networking.md), or connect both the external container and - your service's containers to an - [external network](networking.md#using-a-pre-existing-network). - -- `net`: This is now replaced by [network_mode](#network_mode): - - net: host -> network_mode: host - net: bridge -> network_mode: bridge - net: none -> network_mode: none - - If you're using `net: "container:[service name]"`, you must now use - `network_mode: "service:[service name]"` instead. - - net: "container:web" -> network_mode: "service:web" - - If you're using `net: "container:[container name/id]"`, the value does not - need to change. - - net: "container:cont-name" -> network_mode: "container:cont-name" - net: "container:abc12345" -> network_mode: "container:abc12345" - -- `volumes` with named volumes: these must now be explicitly declared in a - top-level `volumes` section of your Compose file. If a service mounts a - named volume called `data`, you must declare a `data` volume in your - top-level `volumes` section. The whole file might look like this: - - version: '2' - services: - db: - image: postgres - volumes: - - data:/var/lib/postgresql/data - volumes: - data: {} - - By default, Compose creates a volume whose name is prefixed with your - project name. If you want it to just be called `data`, declare it as - external: - - volumes: - data: - external: true - -## Variable substitution - -Your configuration options can contain environment variables. Compose uses the -variable values from the shell environment in which `docker-compose` is run. -For example, suppose the shell contains `EXTERNAL_PORT=8000` and you supply -this configuration: - - web: - build: . - ports: - - "${EXTERNAL_PORT}:5000" - -When you run `docker-compose up` with this configuration, Compose looks for -the `EXTERNAL_PORT` environment variable in the shell and substitutes its -value in. In this example, Compose resolves the port mapping to `"8000:5000"` -before creating the `web` container. - -If an environment variable is not set, Compose substitutes with an empty -string. In the example above, if `EXTERNAL_PORT` is not set, the value for the -port mapping is `:5000` (which is of course an invalid port mapping, and will -result in an error when attempting to create the container). - -Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Extended shell-style -features, such as `${VARIABLE-default}` and `${VARIABLE/foo/bar}`, are not -supported. - -You can use a `$$` (double-dollar sign) when your configuration needs a literal -dollar sign. This also prevents Compose from interpolating a value, so a `$$` -allows you to refer to environment variables that you don't want processed by -Compose. - - web: - build: . - command: "$$VAR_NOT_INTERPOLATED_BY_COMPOSE" - -If you forget and use a single dollar sign (`$`), Compose interprets the value as an environment variable and will warn you: - - The VAR_NOT_INTERPOLATED_BY_COMPOSE is not set. Substituting an empty string. - -## Compose documentation - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) diff --git a/docs/django.md b/docs/django.md deleted file mode 100644 index 1cf2a567..00000000 --- a/docs/django.md +++ /dev/null @@ -1,194 +0,0 @@ - - - -# Quickstart: Docker Compose and Django - -This quick-start guide demonstrates how to use Docker Compose to set up and run a simple Django/PostgreSQL app. Before starting, you'll need to have -[Compose installed](install.md). - -### Define the project components - -For this project, you need to create a Dockerfile, a Python dependencies file, -and a `docker-compose.yml` file. - -1. Create an empty project directory. - - You can name the directory something easy for you to remember. This directory is the context for your application image. The directory should only contain resources to build that image. - -2. Create a new file called `Dockerfile` in your project directory. - - The Dockerfile defines an application's image content via one or more build - commands that configure that image. Once built, you can run the image in a - container. For more information on `Dockerfiles`, see the [Docker user - guide](/engine/tutorials/dockerimages.md#building-an-image-from-a-dockerfile) - and the [Dockerfile reference](/engine/reference/builder.md). - -3. Add the following content to the `Dockerfile`. - - FROM python:2.7 - ENV PYTHONUNBUFFERED 1 - RUN mkdir /code - WORKDIR /code - ADD requirements.txt /code/ - RUN pip install -r requirements.txt - ADD . /code/ - - This `Dockerfile` starts with a Python 2.7 base image. The base image is - modified by adding a new `code` directory. The base image is further modified - by installing the Python requirements defined in the `requirements.txt` file. - -4. Save and close the `Dockerfile`. - -5. Create a `requirements.txt` in your project directory. - - This file is used by the `RUN pip install -r requirements.txt` command in your `Dockerfile`. - -6. Add the required software in the file. - - Django - psycopg2 - -7. Save and close the `requirements.txt` file. - -8. Create a file called `docker-compose.yml` in your project directory. - - The `docker-compose.yml` file describes the services that make your app. In - this example those services are a web server and database. The compose file - also describes which Docker images these services use, how they link - together, any volumes they might need mounted inside the containers. - Finally, the `docker-compose.yml` file describes which ports these services - expose. See the [`docker-compose.yml` reference](compose-file.md) for more - information on how this file works. - -9. Add the following configuration to the file. - - version: '2' - services: - db: - image: postgres - web: - build: . - command: python manage.py runserver 0.0.0.0:8000 - volumes: - - .:/code - ports: - - "8000:8000" - depends_on: - - db - - This file defines two services: The `db` service and the `web` service. - -10. Save and close the `docker-compose.yml` file. - -### Create a Django project - -In this step, you create a Django started project by building the image from the build context defined in the previous procedure. - -1. Change to the root of your project directory. - -2. Create the Django project using the `docker-compose` command. - - $ docker-compose run web django-admin.py startproject composeexample . - - This instructs Compose to run `django-admin.py startproject composeeexample` - in a container, using the `web` service's image and configuration. Because - the `web` image doesn't exist yet, Compose builds it from the current - directory, as specified by the `build: .` line in `docker-compose.yml`. - - Once the `web` service image is built, Compose runs it and executes the - `django-admin.py startproject` command in the container. This command - instructs Django to create a set of files and directories representing a - Django project. - -3. After the `docker-compose` command completes, list the contents of your project. - - $ ls -l - drwxr-xr-x 2 root root composeexample - -rw-rw-r-- 1 user user docker-compose.yml - -rw-rw-r-- 1 user user Dockerfile - -rwxr-xr-x 1 root root manage.py - -rw-rw-r-- 1 user user requirements.txt - - If you are running Docker on Linux, the files `django-admin` created are owned - by root. This happens because the container runs as the root user. Change the - ownership of the the new files. - - sudo chown -R $USER:$USER . - - If you are running Docker on Mac or Windows, you should already have ownership - of all files, including those generated by `django-admin`. List the files just - verify this. - - $ ls -l - total 32 - -rw-r--r-- 1 user staff 145 Feb 13 23:00 Dockerfile - drwxr-xr-x 6 user staff 204 Feb 13 23:07 composeexample - -rw-r--r-- 1 user staff 159 Feb 13 23:02 docker-compose.yml - -rwxr-xr-x 1 user staff 257 Feb 13 23:07 manage.py - -rw-r--r-- 1 user staff 16 Feb 13 23:01 requirements.txt - - -### Connect the database - -In this section, you set up the database connection for Django. - -1. In your project directory, edit the `composeexample/settings.py` file. - -2. Replace the `DATABASES = ...` with the following: - - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'postgres', - 'USER': 'postgres', - 'HOST': 'db', - 'PORT': 5432, - } - } - - These settings are determined by the - [postgres](https://hub.docker.com/_/postgres/) Docker image - specified in `docker-compose.yml`. - -3. Save and close the file. - -4. Run the `docker-compose up` command. - - $ docker-compose up - Starting composepractice_db_1... - Starting composepractice_web_1... - Attaching to composepractice_db_1, composepractice_web_1 - ... - db_1 | PostgreSQL init process complete; ready for start up. - ... - db_1 | LOG: database system is ready to accept connections - db_1 | LOG: autovacuum launcher started - .. - web_1 | Django version 1.8.4, using settings 'composeexample.settings' - web_1 | Starting development server at http://0.0.0.0:8000/ - web_1 | Quit the server with CONTROL-C. - - At this point, your Django app should be running at port `8000` on your - Docker host. If you are using a Docker Machine VM, you can use the - `docker-machine ip MACHINE_NAME` to get the IP address. - - ![Django example](images/django-it-worked.png) - -## More Compose documentation - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Getting Started](gettingstarted.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/env-file.md b/docs/env-file.md deleted file mode 100644 index be2625f8..00000000 --- a/docs/env-file.md +++ /dev/null @@ -1,43 +0,0 @@ - - - -# Environment file - -Compose supports declaring default environment variables in an environment -file named `.env` placed in the folder `docker-compose` command is executed from -*(current working directory)*. - -Compose expects each line in an env file to be in `VAR=VAL` format. Lines -beginning with `#` (i.e. comments) are ignored, as are blank lines. - -> Note: Values present in the environment at runtime will always override -> those defined inside the `.env` file. Similarly, values passed via -> command-line arguments take precedence as well. - -Those environment variables will be used for -[variable substitution](compose-file.md#variable-substitution) in your Compose -file, but can also be used to define the following -[CLI variables](reference/envvars.md): - -- `COMPOSE_API_VERSION` -- `COMPOSE_FILE` -- `COMPOSE_HTTP_TIMEOUT` -- `COMPOSE_PROJECT_NAME` -- `DOCKER_CERT_PATH` -- `DOCKER_HOST` -- `DOCKER_TLS_VERIFY` - -## More Compose documentation - -- [User guide](index.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/environment-variables.md b/docs/environment-variables.md deleted file mode 100644 index a2e74f0a..00000000 --- a/docs/environment-variables.md +++ /dev/null @@ -1,107 +0,0 @@ - - -# Environment variables in Compose - -There are multiple parts of Compose that deal with environment variables in one sense or another. This page should help you find the information you need. - - -## Substituting environment variables in Compose files - -It's possible to use environment variables in your shell to populate values inside a Compose file: - - web: - image: "webapp:${TAG}" - -For more information, see the [Variable substitution](compose-file.md#variable-substitution) section in the Compose file reference. - - -## Setting environment variables in containers - -You can set environment variables in a service's containers with the ['environment' key](compose-file.md#environment), just like with `docker run -e VARIABLE=VALUE ...`: - - web: - environment: - - DEBUG=1 - - -## Passing environment variables through to containers - -You can pass environment variables from your shell straight through to a service's containers with the ['environment' key](compose-file.md#environment) by not giving them a value, just like with `docker run -e VARIABLE ...`: - - web: - environment: - - DEBUG - -The value of the `DEBUG` variable in the container will be taken from the value for the same variable in the shell in which Compose is run. - - -## The “env_file” configuration option - -You can pass multiple environment variables from an external file through to a service's containers with the ['env_file' option](compose-file.md#env-file), just like with `docker run --env-file=FILE ...`: - - web: - env_file: - - web-variables.env - - -## Setting environment variables with 'docker-compose run' - -Just like with `docker run -e`, you can set environment variables on a one-off container with `docker-compose run -e`: - - $ docker-compose run -e DEBUG=1 web python console.py - -You can also pass a variable through from the shell by not giving it a value: - - $ docker-compose run -e DEBUG web python console.py - -The value of the `DEBUG` variable in the container will be taken from the value for the same variable in the shell in which Compose is run. - - -## The “.env” file - -You can set default values for any environment variables referenced in the Compose file, or used to configure Compose, in an [environment file](env-file.md) named `.env`: - - $ cat .env - TAG=v1.5 - - $ cat docker-compose.yml - version: '2.0' - services: - web: - image: "webapp:${TAG}" - -When you run `docker-compose up`, the `web` service defined above uses the image `webapp:v1.5`. You can verify this with the [config command](reference/config.md), which prints your resolved application config to the terminal: - - $ docker-compose config - version: '2.0' - services: - web: - image: 'webapp:v1.5' - -Values in the shell take precedence over those specified in the `.env` file. If you set `TAG` to a different value in your shell, the substitution in `image` uses that instead: - - $ export TAG=v2.0 - - $ docker-compose config - version: '2.0' - services: - web: - image: 'webapp:v2.0' - -## Configuring Compose using environment variables - -Several environment variables are available for you to configure the Docker Compose command-line behaviour. They begin with `COMPOSE_` or `DOCKER_`, and are documented in [CLI Environment Variables](reference/envvars.md). - - -## Environment variables created by links - -When using the ['links' option](compose-file.md#links) in a [v1 Compose file](compose-file.md#version-1), environment variables will be created for each link. They are documented in the [Link environment variables reference](link-env-deprecated.md). Please note, however, that these variables are deprecated - you should just use the link alias as a hostname instead. diff --git a/docs/extends.md b/docs/extends.md deleted file mode 100644 index 6f457391..00000000 --- a/docs/extends.md +++ /dev/null @@ -1,354 +0,0 @@ - - - -# Extending services and Compose files - -Compose supports two methods of sharing common configuration: - -1. Extending an entire Compose file by - [using multiple Compose files](#multiple-compose-files) -2. Extending individual services with [the `extends` field](#extending-services) - - -## Multiple Compose files - -Using multiple Compose files enables you to customize a Compose application -for different environments or different workflows. - -### Understanding multiple Compose files - -By default, Compose reads two files, a `docker-compose.yml` and an optional -`docker-compose.override.yml` file. By convention, the `docker-compose.yml` -contains your base configuration. The override file, as its name implies, can -contain configuration overrides for existing services or entirely new -services. - -If a service is defined in both files Compose merges the configurations using -the rules described in [Adding and overriding -configuration](#adding-and-overriding-configuration). - -To use multiple override files, or an override file with a different name, you -can use the `-f` option to specify the list of files. Compose merges files in -the order they're specified on the command line. See the [`docker-compose` -command reference](./reference/overview.md) for more information about -using `-f`. - -When you use multiple configuration files, you must make sure all paths in the -files are relative to the base Compose file (the first Compose file specified -with `-f`). This is required because override files need not be valid -Compose files. Override files can contain small fragments of configuration. -Tracking which fragment of a service is relative to which path is difficult and -confusing, so to keep paths easier to understand, all paths must be defined -relative to the base file. - -### Example use case - -In this section are two common use cases for multiple compose files: changing a -Compose app for different environments, and running administrative tasks -against a Compose app. - -#### Different environments - -A common use case for multiple files is changing a development Compose app -for a production-like environment (which may be production, staging or CI). -To support these differences, you can split your Compose configuration into -a few different files: - -Start with a base file that defines the canonical configuration for the -services. - -**docker-compose.yml** - - web: - image: example/my_web_app:latest - links: - - db - - cache - - db: - image: postgres:latest - - cache: - image: redis:latest - -In this example the development configuration exposes some ports to the -host, mounts our code as a volume, and builds the web image. - -**docker-compose.override.yml** - - - web: - build: . - volumes: - - '.:/code' - ports: - - 8883:80 - environment: - DEBUG: 'true' - - db: - command: '-d' - ports: - - 5432:5432 - - cache: - ports: - - 6379:6379 - -When you run `docker-compose up` it reads the overrides automatically. - -Now, it would be nice to use this Compose app in a production environment. So, -create another override file (which might be stored in a different git -repo or managed by a different team). - -**docker-compose.prod.yml** - - web: - ports: - - 80:80 - environment: - PRODUCTION: 'true' - - cache: - environment: - TTL: '500' - -To deploy with this production Compose file you can run - - docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d - -This deploys all three services using the configuration in -`docker-compose.yml` and `docker-compose.prod.yml` (but not the -dev configuration in `docker-compose.override.yml`). - - -See [production](production.md) for more information about Compose in -production. - -#### Administrative tasks - -Another common use case is running adhoc or administrative tasks against one -or more services in a Compose app. This example demonstrates running a -database backup. - -Start with a **docker-compose.yml**. - - web: - image: example/my_web_app:latest - links: - - db - - db: - image: postgres:latest - -In a **docker-compose.admin.yml** add a new service to run the database -export or backup. - - dbadmin: - build: database_admin/ - links: - - db - -To start a normal environment run `docker-compose up -d`. To run a database -backup, include the `docker-compose.admin.yml` as well. - - docker-compose -f docker-compose.yml -f docker-compose.admin.yml \ - run dbadmin db-backup - - -## Extending services - -Docker Compose's `extends` keyword enables sharing of common configurations -among different files, or even different projects entirely. Extending services -is useful if you have several services that reuse a common set of configuration -options. Using `extends` you can define a common set of service options in one -place and refer to it from anywhere. - -> **Note:** `links`, `volumes_from`, and `depends_on` are never shared between -> services using >`extends`. These exceptions exist to avoid -> implicit dependencies—you always define `links` and `volumes_from` -> locally. This ensures dependencies between services are clearly visible when -> reading the current file. Defining these locally also ensures changes to the -> referenced file don't result in breakage. - -### Understand the extends configuration - -When defining any service in `docker-compose.yml`, you can declare that you are -extending another service like this: - - web: - extends: - file: common-services.yml - service: webapp - -This instructs Compose to re-use the configuration for the `webapp` service -defined in the `common-services.yml` file. Suppose that `common-services.yml` -looks like this: - - webapp: - build: . - ports: - - "8000:8000" - volumes: - - "/data" - -In this case, you'll get exactly the same result as if you wrote -`docker-compose.yml` with the same `build`, `ports` and `volumes` configuration -values defined directly under `web`. - -You can go further and define (or re-define) configuration locally in -`docker-compose.yml`: - - web: - extends: - file: common-services.yml - service: webapp - environment: - - DEBUG=1 - cpu_shares: 5 - - important_web: - extends: web - cpu_shares: 10 - -You can also write other services and link your `web` service to them: - - web: - extends: - file: common-services.yml - service: webapp - environment: - - DEBUG=1 - cpu_shares: 5 - links: - - db - db: - image: postgres - -### Example use case - -Extending an individual service is useful when you have multiple services that -have a common configuration. The example below is a Compose app with -two services: a web application and a queue worker. Both services use the same -codebase and share many configuration options. - -In a **common.yml** we define the common configuration: - - app: - build: . - environment: - CONFIG_FILE_PATH: /code/config - API_KEY: xxxyyy - cpu_shares: 5 - -In a **docker-compose.yml** we define the concrete services which use the -common configuration: - - webapp: - extends: - file: common.yml - service: app - command: /code/run_web_app - ports: - - 8080:8080 - links: - - queue - - db - - queue_worker: - extends: - file: common.yml - service: app - command: /code/run_worker - links: - - queue - -## Adding and overriding configuration - -Compose copies configurations from the original service over to the local one. -If a configuration option is defined in both the original service the local -service, the local value *replaces* or *extends* the original value. - -For single-value options like `image`, `command` or `mem_limit`, the new value -replaces the old value. - - # original service - command: python app.py - - # local service - command: python otherapp.py - - # result - command: python otherapp.py - -> **Note:** In the case of `build` and `image`, when using -> [version 1 of the Compose file format](compose-file.md#version-1), using one -> option in the local service causes Compose to discard the other option if it -> was defined in the original service. -> -> For example, if the original service defines `image: webapp` and the -> local service defines `build: .` then the resulting service will have -> `build: .` and no `image` option. -> -> This is because `build` and `image` cannot be used together in a version 1 -> file. - -For the **multi-value options** `ports`, `expose`, `external_links`, `dns`, -`dns_search`, and `tmpfs`, Compose concatenates both sets of values: - - # original service - expose: - - "3000" - - # local service - expose: - - "4000" - - "5000" - - # result - expose: - - "3000" - - "4000" - - "5000" - -In the case of `environment`, `labels`, `volumes` and `devices`, Compose -"merges" entries together with locally-defined values taking precedence: - - # original service - environment: - - FOO=original - - BAR=original - - # local service - environment: - - BAR=local - - BAZ=local - - # result - environment: - - FOO=original - - BAR=local - - BAZ=local - - - - -## Compose documentation - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Getting Started](gettingstarted.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index 45885255..00000000 --- a/docs/faq.md +++ /dev/null @@ -1,128 +0,0 @@ - - -# Frequently asked questions - -If you don’t see your question here, feel free to drop by `#docker-compose` on -freenode IRC and ask the community. - - -## Can I control service startup order? - -Yes - see [Controlling startup order](startup-order.md). - - -## Why do my services take 10 seconds to recreate or stop? - -Compose stop attempts to stop a container by sending a `SIGTERM`. It then waits -for a [default timeout of 10 seconds](./reference/stop.md). After the timeout, -a `SIGKILL` is sent to the container to forcefully kill it. If you -are waiting for this timeout, it means that your containers aren't shutting down -when they receive the `SIGTERM` signal. - -There has already been a lot written about this problem of -[processes handling signals](https://medium.com/@gchudnov/trapping-signals-in-docker-containers-7a57fdda7d86) -in containers. - -To fix this problem, try the following: - -* Make sure you're using the JSON form of `CMD` and `ENTRYPOINT` -in your Dockerfile. - - For example use `["program", "arg1", "arg2"]` not `"program arg1 arg2"`. - Using the string form causes Docker to run your process using `bash` which - doesn't handle signals properly. Compose always uses the JSON form, so don't - worry if you override the command or entrypoint in your Compose file. - -* If you are able, modify the application that you're running to -add an explicit signal handler for `SIGTERM`. - -* Set the `stop_signal` to a signal which the application knows how to handle: - - web: - build: . - stop_signal: SIGINT - -* If you can't modify the application, wrap the application in a lightweight init -system (like [s6](http://skarnet.org/software/s6/)) or a signal proxy (like -[dumb-init](https://github.com/Yelp/dumb-init) or -[tini](https://github.com/krallin/tini)). Either of these wrappers take care of -handling `SIGTERM` properly. - -## How do I run multiple copies of a Compose file on the same host? - -Compose uses the project name to create unique identifiers for all of a -project's containers and other resources. To run multiple copies of a project, -set a custom project name using the [`-p` command line -option](./reference/overview.md) or the [`COMPOSE_PROJECT_NAME` -environment variable](./reference/envvars.md#compose-project-name). - -## What's the difference between `up`, `run`, and `start`? - -Typically, you want `docker-compose up`. Use `up` to start or restart all the -services defined in a `docker-compose.yml`. In the default "attached" -mode, you'll see all the logs from all the containers. In "detached" mode (`-d`), -Compose exits after starting the containers, but the containers continue to run -in the background. - -The `docker-compose run` command is for running "one-off" or "adhoc" tasks. It -requires the service name you want to run and only starts containers for services -that the running service depends on. Use `run` to run tests or perform -an administrative task such as removing or adding data to a data volume -container. The `run` command acts like `docker run -ti` in that it opens an -interactive terminal to the container and returns an exit status matching the -exit status of the process in the container. - -The `docker-compose start` command is useful only to restart containers -that were previously created, but were stopped. It never creates new -containers. - -## Can I use json instead of yaml for my Compose file? - -Yes. [Yaml is a superset of json](http://stackoverflow.com/a/1729545/444646) so -any JSON file should be valid Yaml. To use a JSON file with Compose, -specify the filename to use, for example: - -```bash -docker-compose -f docker-compose.json up -``` - -## Should I include my code with `COPY`/`ADD` or a volume? - -You can add your code to the image using `COPY` or `ADD` directive in a -`Dockerfile`. This is useful if you need to relocate your code along with the -Docker image, for example when you're sending code to another environment -(production, CI, etc). - -You should use a `volume` if you want to make changes to your code and see them -reflected immediately, for example when you're developing code and your server -supports hot code reloading or live-reload. - -There may be cases where you'll want to use both. You can have the image -include the code using a `COPY`, and use a `volume` in your Compose file to -include the code from the host during development. The volume overrides -the directory contents of the image. - -## Where can I find example compose files? - -There are [many examples of Compose files on -github](https://github.com/search?q=in%3Apath+docker-compose.yml+extension%3Ayml&type=Code). - - -## Compose documentation - -- [Installing Compose](install.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md deleted file mode 100644 index 249bff72..00000000 --- a/docs/gettingstarted.md +++ /dev/null @@ -1,191 +0,0 @@ - - - -# Getting Started - -On this page you build a simple Python web application running on Docker Compose. The -application uses the Flask framework and increments a value in Redis. While the -sample uses Python, the concepts demonstrated here should be understandable even -if you're not familiar with it. - -## Prerequisites - -Make sure you have already -[installed both Docker Engine and Docker Compose](install.md). You -don't need to install Python, it is provided by a Docker image. - -## Step 1: Setup - -1. Create a directory for the project: - - $ mkdir composetest - $ cd composetest - -2. With your favorite text editor create a file called `app.py` in your project - directory. - - from flask import Flask - from redis import Redis - - app = Flask(__name__) - redis = Redis(host='redis', port=6379) - - @app.route('/') - def hello(): - redis.incr('hits') - return 'Hello World! I have been seen %s times.' % redis.get('hits') - - if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) - -3. Create another file called `requirements.txt` in your project directory and - add the following: - - flask - redis - - These define the applications dependencies. - -## Step 2: Create a Docker image - -In this step, you build a new Docker image. The image contains all the -dependencies the Python application requires, including Python itself. - -1. In your project directory create a file named `Dockerfile` and add the - following: - - FROM python:2.7 - ADD . /code - WORKDIR /code - RUN pip install -r requirements.txt - CMD python app.py - - This tells Docker to: - - * Build an image starting with the Python 2.7 image. - * Add the current directory `.` into the path `/code` in the image. - * Set the working directory to `/code`. - * Install the Python dependencies. - * Set the default command for the container to `python app.py` - - For more information on how to write Dockerfiles, see the [Docker user guide](/engine/tutorials/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). - -2. Build the image. - - $ docker build -t web . - - This command builds an image named `web` from the contents of the current - directory. The command automatically locates the `Dockerfile`, `app.py`, and - `requirements.txt` files. - - -## Step 3: Define services - -Define a set of services using `docker-compose.yml`: - -1. Create a file called docker-compose.yml in your project directory and add - the following: - - - version: '2' - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - depends_on: - - redis - redis: - image: redis - -This Compose file defines two services, `web` and `redis`. The web service: - -* Builds from the `Dockerfile` in the current directory. -* Forwards the exposed port 5000 on the container to port 5000 on the host machine. -* Mounts the project directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. -* Links the web service to the Redis service. - -The `redis` service uses the latest public [Redis](https://registry.hub.docker.com/_/redis/) image pulled from the Docker Hub registry. - -## Step 4: Build and run your app with Compose - -1. From your project directory, start up your application. - - $ docker-compose up - Pulling image redis... - Building web... - Starting composetest_redis_1... - Starting composetest_web_1... - redis_1 | [8] 02 Jan 18:43:35.576 # Server started, Redis version 2.8.3 - web_1 | * Running on http://0.0.0.0:5000/ - web_1 | * Restarting with stat - - Compose pulls a Redis image, builds an image for your code, and start the - services you defined. - -2. Enter `http://0.0.0.0:5000/` in a browser to see the application running. - - If you're using Docker on Linux natively, then the web app should now be - listening on port 5000 on your Docker daemon host. If `http://0.0.0.0:5000` - doesn't resolve, you can also try `http://localhost:5000`. - - If you're using Docker Machine on a Mac, use `docker-machine ip MACHINE_VM` to get - the IP address of your Docker host. Then, `open http://MACHINE_VM_IP:5000` in a - browser. - - You should see a message in your browser saying: - - `Hello World! I have been seen 1 times.` - -3. Refresh the page. - - The number should increment. - -## Step 5: Experiment with some other commands - -If you want to run your services in the background, you can pass the `-d` flag -(for "detached" mode) to `docker-compose up` and use `docker-compose ps` to -see what is currently running: - - $ docker-compose up -d - Starting composetest_redis_1... - Starting composetest_web_1... - $ docker-compose ps - Name Command State Ports - ------------------------------------------------------------------- - composetest_redis_1 /usr/local/bin/run Up - composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp - -The `docker-compose run` command allows you to run one-off commands for your -services. For example, to see what environment variables are available to the -`web` service: - - $ docker-compose run web env - -See `docker-compose --help` to see other available commands. You can also install [command completion](completion.md) for the bash and zsh shell, which will also show you available commands. - -If you started Compose with `docker-compose up -d`, you'll probably want to stop -your services once you've finished with them: - - $ docker-compose stop - -At this point, you have seen the basics of how Compose works. - - -## Where to go next - -- Next, try the quick start guide for [Django](django.md), - [Rails](rails.md), or [WordPress](wordpress.md). -- [Explore the full list of Compose commands](./reference/index.md) -- [Compose configuration file reference](compose-file.md) diff --git a/docs/images/django-it-worked.png b/docs/images/django-it-worked.png deleted file mode 100644 index 75769754b975748dea24a9896083e6e9447d121f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28446 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 a6Y}y^58ih#ifY@Kcf`{7Q?LtYPUU~rH#6%1 diff --git a/docs/images/wordpress-files.png b/docs/images/wordpress-files.png deleted file mode 100644 index 4762935baeb03568530e962231687bbcd38d075b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70823 zcmaI6WmubCumy@+(PG8jp}1>@{m-!@36)^37pXM znD1VM8;-&1Zfiy(c}Mz%d*mQZ$||2=Vyl zQhFrjpVjk}~W489Mo0X^!jJwzqhl!NuGG zB?E>Q{0P(A!OFODmGJA)V0^>3itn#sG;8ix2{K9EA=RL=fDB{CB$OW&AMDa1qIw475R^CS5GDylsDwBL;Kz~1zRR^kG(x=%)tdKQLm-_SSwxL*qex;%(hVtMRbZ6_ zSxHzSSfLegf3H0tU$XPJ#0a?Uu?Q`mWN<*q#7b>`p{?xdKVMq5b#ijjM1_ozru>Uc zZOB*Z^l-m~LZEt&I(W=8qON65bFnb)PUK8`S*Fq_gjFaR)F;b0mNtiyfmEd zb;EI(pohU0C9Sy;a{-TirS8K*DqDM_`bLgfZ^FN!T;gmc=!WF46Z06eq|yeg81##f z-)7`EQBst@O0h%&nltl&PU41EpHVtxnXGL)m$kFmB-mhZLCM0(e8_XOvgl-Y& z;Xnl54yJ@uVES5ZUZ(q_V=Ixf6PUm*c5Zb|Ysj zIO9LZW0@uJhh(nbuS$p_m;NfHEpgF;W6-CK8W^G{DK0G`HLwoaM=b`e7Uz8lwTGb} zY-~8NK+DZ=L&*UYcQaU`r1Gr=+2}g1gxV|lVrMt+MaP+(#)(0I? zzFJZ~)|(6-`5gc{QJGJPWIUC*eHS=>qS0Ffp;vXyelZ)6tAB+oIOkX#aY&+Hx;P>= z=G|MS=~h6ZHed3Kw#5qW$|B zA;ZtQiVpsxkLo^Fj^r)f9mORq3)#6RG3&Yq*5GH46OM6d2a$-a554!ze;KioNB4+> z7(FR@t>QQxvRPE;qJA-p{JwurT_Y0w)5^!33Di4ObWi)PKXl#NQ2(F;QDH0xwbbS#j(kyPFc8Q+(u5@ zbAO-qcFE1jsA3rzJVPHtGMk}H;;n7RS6AvCH1jxt-I-BaX)mL>Lz=8zF!J?OweenL zZmW`cLUOGPr)yQ_5{(#uSG*<_bs-k*Tf0Z{{pE0?ruT@tm%DWr@*f6TD^~<0)Ywzf zE~*#P-a%Fuvz8Zg@Cq_h$O2KM1ta^M0u>&1!q0dVWrdzzv8M&Em$mIVDHCr27*Rx; zQ}wZ9Ey)R;7&-TeknN^Ra_dV1j=@9MeG zCGP5NICFdn{?EQNWKy*M)Ai)SHCFatruD0xg)$rle%>O z)*L=8$)Edz0e4-+oiOGs=b7CLp^bTqUnTfR(La&_N~gb1E~&P6zQw(7p(yX+qjG(V z4`f!v56j#>(=RE8RrnI{!MlPW`+)9vRpFCiumOad|wqQ;A!8y&#g+rWm#2%_oLX?FiyP!}KBi`&?`%^YTDyT9sF z*4oy@o#2!H3)*YS!g_;;f{N{9|T*xWa z{n?0&o@m5fxZU?ty};jxI!I%r(d%iQNY^J&{*OiH4R_v}5vCA`-Gs#k=f&b(l#iwe zXKuE0T!>AvYUywLZN5w5mhtn|K?8g7RRkIagEQZc^+}l4?lMk93EI_3=nM=)+xxJl zd#_lfi|qr?*e9>9xBv~tWXAhD7$p68*DK5%LA)NvXRPBjtd|&;b({N%=^j0>=*C%6 z?0WY%Bn9HY^LDH6%;~|uBSOAc>c;Hva(_Sh1N;Lf`A)VJd3PZ$lO-J3*Y7N2&=s_u zohc*S9yZ&Ro438FmUQPUBTXekmxv>}2f5>XLHAGf@9o;*_FyqcT-X)%U!Mc+iD7R*opz)D08nauasAb8%d&J0cCMA-TS45c9j3 zbkjY~bDcYRA082^+~kQ-i#_6Y=NP`QKnneAev*ln9b)H78;=eRM%?>oV(FZ^wa^4e zAA=J5^-@AAy4=_89Zn+BDKPUojN_JNR%h#Wi1os=|(%< zkV3ssbxxD!G<%nSKuh-^IMQnKB6KXs3+b9k%443ouhCv?^U^cv&Hzwg(!;;4=cLHr zlw~^JK$nHbEoP+5z7SzE&@e-+Sz0Eu&D-nf&#edf>_qB0!9yR9*iZ9jc6&(mQ%NeP zK`V93MDR4OnOC@wfkBBU3*}i8Y#dO zk*d}jYjY5t@@5~}qfxcME6925ZFzJ?YsS;VR$@C*4Tys+tl83SX-rO{`%x^5B*B81R^kN^JK+rhyi`j*=^+>>`MHjMCd{pvgVZE751I*TZBS_pq89iX8S-=BY}(${V1$VbE8r#hnN) zA3s}g7vV9T@d=qcOp+^<6X}Tg1Byl(BS!Wg+_mSQ3KsXKXm!L*aJM%rV36tMkak3C zNzt2WB7P*vpy2a`%SYs8qZ^JB|KHg{co!3R+aV11O5-63?{@S`#OUu;{+q6TvHlOR zKH2QlLbT+oxaM@R%`iBE7x9ZOADJ%0U&zZU2MM~I;#?VZfX^)0@mXQhT^e`{0d7UAL>$Aq28?qi8%NL?1*HAK!Y zp9l(5s1I{qgl$Gaf|Oa+5^zd*_u%|`GgMvF_^q=bj7yCF%qnk=GXPT+L%M>G>@19Z zCw&#_J|AAdT5g*=oTS5!!teGGDIePIAmS@YJ>4S5uBeu(Z}S3(J!AAN?oN5Pg&FkD ztQ0Q;l${FEK-=T0pXf1CFHH=JsHNAoTA+BE+z0MC`7szPJ@$@Z_DsG1R%8twIpHo@ z3$8VfPH5b~Q@if`!|-<&aWVaQAY(-5pq3tDz#i6W`K=)c)FZ%4*); z(WCc_U6FpFLi9>c{2jeL=p(TJVHd!H)ehTzm*;H(`<)G8)=0s&g1W_5F2i9_dpeB9)hM)jYMvPW#__ z-|Ku+sGhj`+2D(Z9GPdJveg=c@b=m}&{H50;3VfFJ?p|m&dKi#f0{;Cj+5W|#HUK_ zqxk-b1xC%5@y+M{mOa9f+71Vy5eE1$7tbwQLx)+YY9A#5(y&k|$sqndJrl(a#}p4E z3t)^bfYO7N?iHfWcuB;sD90@ep}x#XOXT3+J|VsQhEym-k;Ia`f+-i4hz4>IyD;C3 zYKoj)E}}L)vETBcA2&myL3QQVeO7fmt-HxJOhy5V+pc|q^XdTe+%(yQ?k}0qd)|s# zTB=L3HKm!3au2oPuSR12X@PNE%g*u=EPJc_sDnF;yVPx#9emqEA=~0afT7rLNHi9m z*nk70H-xQZUNr)hmoB2I4q+V!>Z(fY(a4Xo`h!3Vb50GD>sN7qFP5{Fel1-CJ+Z_8PN9EEzaj}sQCRNS!Z{#X@w z7fvNpJX3-O0`=&9Rpj8MQOb=zi0nQ|^fIp>|IdbH^C_oOj(*XQKGWmxYxIj+Pk^D0(cm7=2xnOsqI88NwwAZ!m(+B0=|j`(87 zGp&q`?L2GmbSwEuSV*587PQA3UvAoertt>bTXbAii)6`@o-Fvz+tSICh;=BCxEpdh zf5ZGpCs(%yPIuaimlVW|L%isLUj*D?y2$wPyGZMxx*SAa%$GEAh}#Sgj24W0^?ZIB zVfr=fYH|l`3v;D&g+sgxpB614c$TNV(&H0;Y`bwbS#{fI^iNq^8c1V-jTsA=#LHS> zMhYQ2X1N?;NgiM*f^#xI)BWRGkN)}bEwaQ-yCq4&-#!ub1_Dt>YH;!4?UY`3I>3)q(t-xQKdjUe29mDzSp0H-WR-YMZku!% zb9A`~n8dgfG`(%}bA$I>R?MIJalrv54p0_;JUClXB|ekM5)83LeM{lWE`7CKjvRp- zDY7&D$nKe=R!Kv;ACz73Tfd!EnP!OqtNMv$&;T8pT$aoq${kl1?&TCWq(~Y_!VN@Z z+SMA();WRG@s>WvDw+UG3(uq0`%m{(HD;9nOLY2HPb?qLSBo?G_Kfz+*H?_O>D)m- zVT&x^tOJe_ca}U#we(kx@hx-Y*r|KJ(Y^~Z zZRfs9$Y;4+86>kTIu9jV?FqiPi}m3EW#g6kmgvJ9n`zfE4n_G-`x&0q4GVrruzw)K zl_8>$O}TKM@>l0iFgdEuH~i&Fl=g?ME6!}9r~tuABXM)+99jMteP>_2Z{12WYg{Y# z8N5~Hl&*C%Fn&E()0tN{EO(^!_S}g=6M}o z$I;~dpVMJf3V&)6bg{=|E>|0zuO`BRPyh^BEP%NbbLdobn-7gIGk&C^3Papb8O^&S z&}K!dPkZSYm}+ivVSPfjRvi|j$gb#DH~}})%dIt^e(CCxbUjUBzgcN4HM8O1oe$y;9Z%;c5|(d|v=bZ1??Y?>$PZkUH+M=ATC}<#JyG+WS^u zM?@Y87O!Uk^jEdjXkggWHl*fL@@_@f`v+$@T9KIsGCyI_vDdlc^Voxj9lG-)W5Db4 z@_Fq314K8Csh;LJP_48bV1;Ui}QP<7uwYIOkd1FnLZ_ zNc(ouYx@)SW8MCm{*)91L5kh`+M5WWIu1^tYO+C-U7Y~8Z z>Zo5i7i{1CY(3F(eJ`=}3~wsbF*VB6Ka~Vc(!v5cx}V(4cd~fnyJz)czN)N9EJ#lv zqaFDt=@q(7<${E30og{$8f}?m!mmkUk67gD0EZu{hDt0z1X5IBk6HiGslvIweXrB7 ztyw4(sFKvfN#X-|?|t5G1Il;Dye5`e*0Y6A_C=)z zQDapLr0AeZ=w|+E?5~Jzwpd#MOFtAeeWMty{F&Ra>+$s9`6A9fEp3->@Ntqen_@i6o}Ztxh)r6Z<%-vV=}{gI@wCW zMah>-W<=7!T)HI9Nyh*^X3r{OM&JY~FP+k(%vLP=zbE2=cX%n&^kUrkuqG=Ie)m~l z#VX4+_#u*mHrLOB%p~|~+K3~bdW`1k)dqr6x zkae)2?ajVn6vZ9r5JfwMJ#ZjoNvo zB!hdzoB37I5_wZlvgCQEU0kTjNxI2{3=~TQu>aVsM1Qbz2oFJ4sKjZhVnqqioV$?P z0o|tOmq{;uKW2R3~=0MJGK{1J1|DpqXck-H+%nB>HIu(@pS@G^et!@b2oNcd@X#cT$-;1&7 z?V2JuaLN7T!(Bb@QXdCC)4$qnH&y@fjM(HD_pXLn;DJXYi3)U#)Y)5A`f0P!?Wvgg z={_)GIFm27NM4JA?6kFDs>}Em&J8A0WG}S1(GPZDrHFEFjDm468)flzG*t~KsjQJ*Tcla;cEa4>Ry(6pUa&KY# zA2jQnS8hJ(&HRjct?zkzvhNuo-@F(VdzZCi7h~-y*&b*#>Z??-a(Lbjq(ZYd&e@0f z>Ps*)2;l(=2ugZ=&j?5)fq0^bUlu1B%hqeKQ9laZnw)%HwuNug49b{8h@EMrMuL|L z(VsUbF*$*m4(s?FG6`=-vyR`AsW$IJ!j+Ea7?5@P!DJ1-@Z)~2(!!i+Yk+?yF5)weLH=^tB8l_&sV43WXT4z2Tr!x>_+ zckc|ObI^175pOqcMsdDA%}1>jrdPAlU>#JQN?EJI_*ukAzko^jI`_=<{&e7`zUSGR zuT;46_CY1>dtUn$z!(|1;?H))*1k6?>p^7A?tO#+V;Dug4KeNi48%`p_ha^vcW@O( z)!L4}4i!$#p{Fpslz5XQXS`C|YzTtoN!YDOK&d7L>?+BZk~pYOrd&vckeXjaE!7tB zV%5!9#*X?JReMh&R-Ieq1R;Z8;3iH~?v83D8H3)bKG~wjV;+Xo8;-`po%8--lMFE& z`N-KY=QF+lvF#5x#nWrMBwdzvG207c{3}kfNpk1?-tDI7%6#i0qFJalG~hh{E&Yx5 z2$HEQN^_?N%9tLW-%|VG0@m2@Q1ufSxqFViIH;hl=jn*Vu%7s(b0eK4=!{xyCrK>7 z#yA>CbH?2sf8|ZQk{h$|5;xkz#ljCgRMppGyA-H(+h=0G=|+9B6bgHx>&WHzZhXN0 z*P|oR6ajv48>(_a$enwCwVqlItALuPIky=7PQ>tLO8ScmDS8eb{6~||bQQWd6Wf2- z1$ki3vfSB^uqf22h}NyQ?8-A%McE@3HNF2h9~_XyqJTuurfjK@q9?-aJ#Yehc;wkF zx3;(`KWKjd_(~c5@EMZ<0q7A@Tvw5zZj1%OP!e|`vJ}_qBm%BTL^BJmbZD#LE7;oxG?evfl2Y&~sVUXpio8k~Qch z{xQqINP#?q1!z~cQyL!<`|G@MH$%gojk_Gr%sk#+yMRM&dG{!>?_{0Bw#vGUD^o(C z@mcx}k4w@B6KXWhM)lt2FCiM5QOhn4zy;pxAF}OM0KyIHaek%WGq? zhV5Bt^(+&ajbvgrRDVL>RAD~BgQJ6Z@{sycqPgzK*~nTWmY@5@G%5X2*sYZ)MSPX9uwkhg*_AO3CZZ4N%gN zPL9<6qTtbISj5?9f+`NN9ph)Mu?qw-UrEH-QT^pf61rQN>jr<|9E}0oDMpW|P*5H| z)_bRDDmL~QWvEzv0CvH{pMu$@g~+&t*!^0G<@Q9cwTCP{@VQX%M?0&`r%WsRx&FPp zWwyrIn+qyF9?Ht%2HMBdBRlNGE0pZ~F~f4QF-J4xN0#cfKIsdhdr{9AQQgHC5OcCr z&p2`nvp5PC}6lZ1Xa0h>4-m%XFhjpt|J+D zP~~N?$>W=@)dhv}ODWW>=&?7vFLLG`ncnNVg>tO`&7ZYUEAPghcaQt|z-PQaU;N&a z^*Ytwi@Kk%_?vaLNh1yYN4kCNN;%v-4py{x7j>dZrv6?1&^O=w1rcYJT3H>%I+l*V z;&!!jbPYSg_>1`B7@pu^61ueIedR{6<2b7oje#MfId1Bnzxu4Nv%Ri-d#1ei#3^Vf z@wGxe7z$h*YcS}3YO0gzG3;Q&u%J<>zAbvDRH zmi_dWx&Ld0N>mF`l<5rpHA1%D{-n+);<&)m$O*sP60~PFAup2B#q^(0EK@I4JvkBI z_(e@4m!xd0`Z+5=x$z2)>B-cJrHoJZ!1SLR8jcC78r?gkA`B>BY^6aG;qRC75A;@~ z;6ZX^!GHB;t4WiUYMBd^RW=~il!@u}x1C>Y0+S=>TFGNLo*fc6)cj8WrMTP)484|y z58C+V(T31X?b3w^)~pF{PYB`t`KrINn~WQy^`FGYDc<-qG6UE_XT;HCZOmsNW1f?=K3P`987 z*Gos&|Eu`Z5X_)k<3K+-#kY}RW_i{BjO1#)p45obzgak#Y-5{iq5|zQ=jccwWw%U` zH{QvD!`J)nu;Do(*Ql@unRpK+ih%2K-#@zQ^y< z&_Fg|>xrAZS${-hIpwIT2PtTS$9g#xO=%FbA=*`ZggqqoIIHZ3FF&-XdFHW0dcQ{{ z3=u{k>$e3oj|esPo#Vhb*->rI^~Pz(lowhiz5dH*35n25bMC*a+u5_dRKU8y_fr&5$d{11asE2xH}V~RGnKqT+o#kz^z1w7XFJZ7A( z_RP8e;rW=2hvO>|$5-nf49>MEr~!{hS!Hc*jEUS9ENG0~Luc(=3DAOT%bti z!C*~=8U3N2)sv}-UQrRiwz{3AimCs0-uo#7?m2@yo$eyPkX~pYRxbKHSyeaqhW5)( zGMss;ej^K36K4Awp2LFe_YJI_22%}n$fAnVkIZe>Pv|7YH3RBFEIz`gl#SPLIfZ99 zHjrE?pzm3~f&#|$nkt~> zPK}SJq+$-wY1oDpQyKOmqtQmrHWI}3sAlZxDbH`$g5gafEz&X;Q$+uB6F;O}9hZ9KyOCt>Xtr zdLuIk1aKg6pZIz5$DXMFM{npSE%TUwtY6lBNhvi8a8bnaW?>pPhw%CyguZt`Ot+1DwJ0%{e1BMzm+2VM4qwup+ z)`b(n(ZSc#9;f!-d!{|Nx86)B0G+f8vQ!&5pJOo6+6_;+AHn+MZrOI8OwQ!+H<|dB z`1^Cj_A~|~a!nwV*j^1M=IS0fRQ*hcfj7?zu3fnTaG?|}%@WUa4vpDbE04i&*FqIs z_FI20Knz2V7%077g+0mdoZBAvf&e|8^XW|d*TpQ)2*oVc1~OVb-jwUr!%d9U`MK+m zG*sNtK2UJ*p$~YuSocD1O!cN<$#N6_TPG<8wvmxC9lsP+K z|8pwPU89>AdUO0edss9JFgi6w601pnJ@Yb=8#Z?<-Q#|bc>0N~o{&lyF6`y}m6p8PMAJHpV zx&3X?d{atAv!fwHrPji$m;z^e0rw}PazDl(`RRogm8MwTS$7>fe*Y%qR-p@-N(5Uw+I+ZCv!Vefe6@gYAKziEge& z*4_bk@tzI&^mjqqpdXj*<3sS-`u%hlxeC6w1HVTRes)}$y~v+ zeW;_jbzh}joV^NiC3*%y1*^?SQq&|;N=!Q&zf1C@fM}ypnqMcxY!&xujCjKV@^$>{ z5R{3Ms&RC8yf6;XygAT|WP@0>wdp(Ts>|TyCT4`Ar8z>m;;E-AFRIaedKtu`S6;FV z7394!6mOtIWhugglsyXae1}=wKHq&JW!b+e09yPB#vabGqY6;{@B%sTcHB&ZU#M&! z^us=Gs^QoW|4+5R+e!xMpnRgA$1~8u0W2JghNE#jz5ZMhM7VoYoR;k~JOr5Zx(0`e zPr?Ph6Wi8LJJQ86Y^$biL`@jvAp}b0GJT@=w&Mq$?KUvOfw0&hP6lp&zClk9(#LYY z*K0X-o!<;;Z>mw;j!7u?ls%F>qP*R2|D4Oh_3g8u-)O*fUgFZ4DAhC^dWxyH9i2Rj z{+}<}ACN(F?j9Rcga|pSkiIKZpt6@#3K~Fm5s!;1ID4N(gkDU2A^orp2_8nFca4r z_*8xlQ$~rz=1QRBqSWi)hG6!r1{piFS{xing8Zp)5>@@mCh=_!a^vB=YXW515u)43 zfwc>*zS&n4NfB#)?N2$%;@W=eGny{=q}{>#wMK6}C>-5OpaxAKG?pT(L$@eMGDJBS z;qHjz>$oKmOr_)9VL6T}nGH@=g<^-oOivx+l3pkMF5tdYpd5{Yk0;l)Sz0nD8(daQ4($BMawsBpE*`qGEeZk2$={vO55^61rI92=_p=^G6a1?z$yi zQnAF{gP3B0z8#lf11E5f_L#tYf+O>6p*4jFIw?2Ch3}Pnw$sLDe>RzZ+y_4AmJQGxmoFOr-%TbWTw0afYXc1U+%1RH zmrn5j3Yn6wD8nCnNO#ziowGNT zx}-48>S+VJl2+)~BnNJ~Sr1?I2Ir?$4{Z)G<+jfGw=V!?EEXbSCXbUGV)TI}M?XdU z0PxP9-y zX^B3O`GD>cZX?UNPJ;c6;GGTQCc9DD+_`iKSga!gBf7{e@NV&p?F57h=i!kkXKVO> zoe+6MW9AI_w;5tLH9HNp^zr+3|9QCV^LMPd31fZxQt2h3G*2D%`NWJE;{{?a{U2&+ zNcAW*>&UF-EpO7i@X?IE8NuqRcW_raY%*kUsQ2?t@L!l?-%d)ul5HUC(=i^=WaV;p z8wWi%8%Ld)r>Kq7g&Ct)B0R&};WpIY7mG)KPYbLe>?a| z@h^r05ht+beEHy|`*CAXHgsifCs27911;Dxdy7v*5sZZpcTh_SH>ee>#kGAyiPN1B z;`s;zk;ZcI#J9b|!04%JlD<8VcCdvDta0=m9sGtL&Jz<*zflw6uA1BQ?X{1Zk~%M( z-4(5}R`?Ah0ez`+KkknRLynofP{U4QkV5KBnHRsOh6k4qVp=k!e;YW!ocRrGenR{6 z#-4X^!-(9~ZJWw%?s4h@s?a5};LvEyyb^LzCl*QE*0tz^UO^$MjIzSU-2Jo4LWJ%2 zdKLAjnJ+%rM>n-JtkxR789kwcWh2NS1WYq;>RN15LXN}>44vQXEf(+0Uz|uOaxs`x zlIV>mXuYKt>xOd{t2RwhHz2PQU{5~DWZ#pA+y}-r?=Hh}e!jHisJygfc0@M1s1g~6 zZXkbT1hg3&6N0+>tct}XbS5p=MaN$0Zufx|F|AU8jaCQEEq}+%7aXOEjs-AruLPdY z(hr=9FFg2Ug9}tpVhXgu%E6OllC%9Tu2}K_#zazQUF5 zB{PHWWkwpIA_}D7L@rJSS(Y9A*+l|^$U4pP^~gKw&VvW5aO~$SJ}0B!m)_QLsDo@) zSD}S>JR&hwUByt=Z~FM}RRed*&n4otbBh0*8|(as=b{Ht(k5MBa|`Wmain@cVfXqq z+o?EqrlYweM>uHwRafrw?BaY;Zh>}9(Ah@tUH(yO6Um)C83FG5XX0eGs2z6}`PUDI z1H~71K0=$f0U7cLAOsNY3?drn<98=@Gz(gAi{F8-VXOz;`ay(KVZQ`er zPbEWNF%LVybuX@kIU@90i;J);^aVTbEK10^H8F(Ph3DbNB%wLy9esbwlmc_-fy46sp1@byfJB z-)mOpo9J}Vz11CkXT-#2+g-J!s6sr=-w2oSK7nE-(&g{y{0Ips1A}|pMg6_>akZ2V z|MVy&Wj7>=-hIg?EUt5ey%E@!A2GHbo={ybK&&;{S;p}}SnCPX^Yxcg|p26fWjWhV7ZB1G?gx5SE0itTP&*JPe1ORiKoG2G7pOJuEw7-!S6oM9A1|_WSoNOr_wj|se>&~%3Q{B zId!bC6C0?ln%xA&yhfl^&%h$vnQ3_gSvS@ldPU;{2s)mk?;86D{t#w*`{q?LZMMun zPk2%l;G97#LDFdQbJXZ)x76l|9dSQO{_2-|vwZood z3|xQ$bzofh(`{s%iD#0GhSiY4$!d#E<~-|ELdXofdYsHJ{+O%xp_aPGV*1epd8}Q8 zGz!t0rt@-+AHOcIqPWgJ;-vSZH1;x)p=-&NtSK{MH|4R@wKw{9V0ZD%888onw`01xfw&Umo!PIm5rxHo& zmh@06r!q31GF`0**VEZ=C#*~PD`QMaEj~FMy9#sHrKVgI+y>YQQkJeOMkJBZ(^O{$ zsTeH=a`U-805nJ9%xuVZj_TOqVBhs9ANr{Z!*-rngsNPUP6wuu{E+RTQjl z4gCxL@HiP39@dKR3I3NXelfa~moM#M=ByEiUO6T2KKsvH-IuQmZncuv@BCilrr6Yi};Jz+gQ9k8y6I^kav;>woEt zKEks(_(hPx=}hVU8q~DP7ngu7s61$r@6rpMF;NhhX@Emk_@IM5*KE}QGkE*+=2pSA zy&^IBOe0NvH-X>mTe|6Ps}_+_CQ(d)V(sD#ATgqqb4e{h%1GBXs-lMf|AMxBWW_-M z^n=3mas_Jg#o=<=h<#vlC4Fs!svbSL@Y25%yl~#U6SdSD7p=0(!ke#k)(5aT|x8QN3mxzD<^D`t~FW3UKcJh6xP-OEZ;aIn<${w(;c=ta^GpN;cgWbHXj?%m5zyXz@W zMY3Qv;wamgoLh@*hn(Esa1s8J&mE&vqJ3?NsTe2NVrT%n7Sa;SGH?164gQe}#~n$m z3ju$xSoG5&jh7USFC8YGe2{o%%UgISJnE5#naxo88R(hpDHQ>HVKI%>A;pc$=xD6P z1qH!N96n$9iiP31+kKEgr%%E7t()(D2*1(CH0<3_e(R;2!7O(7j&TO(u`EuAATd$7 z3)h@*8z-?q4MAgX=%4qQCjVc2MY49>{-$us7VD^MH~XoQSegOelgzCcRk!~ONfeiks)E^V=GfC5#nUxGkCDT zw0>y(e7mxv5QO}>a^mMl$C2ntJ)wNHLAMgiWhpywby6P=M*%c}0%`6K$AT^GVR)yc z3^w(Hn&4y-PGxBmk!fzkID-&ab?x^__Il-q7D}qU!|>Llt9iS=g0oIV+7-N&B^?h~ z@!fvu#FDuOf~H2yQQ@d~?_Y*Px-&Ln^2-DXGp6jSDF;CXs@ z{vhcZbJ~WX)zI+tH~#scYwBzpi{aD!`Q)Dz*gvZ1f}JLglT*LAGorq_YmrEXEk>4L z-%3v0l%Sq@AwbU>`UZyuzxFwe=w>V-?jxEL1n5Pcv~LZ72(mV{k`tq3h}F5LiwM0Z z`q%_6?skQw1c=fLUaghgihbRa&CfjLcs)yMxISxD&B7#w7rr8+35^*dzZM+Hn~hF* zus%JAwBWto_9L(mJKRivCYD0~U2UO!#K9RrL}^1^<6y}@#S?Q04Gf?x>_?lDLzHhC zlsM2Hh0=F$S;k6VY&f0vGmLnY=|o1poX9K-or@%n)13evS}(OEKVHuHygjrqh{34r zq{mW7W6i*-an$hHx}v$V@zMP|N7=JZ!;+}~fdq+RVt2v#u6cgk_uTs^i}v0&pRhAk zL{}9$*BJ!6DO$T3#D`xs($~=<(^?Nt(E|g-Z1|(d&b`ET&PIt$>~x+fGg#FJQoO4j zS)<&}<141Hwt))OXgt3(|DY}#i`#}P)Ufb0n*HM{`7Ep&l-BLMKU422Rf}6dE!6OJ z;Hm=wXSCUc1JX?xfL{6b?IGq)kvApVXV|ob@ktClP`Nl38SEy$9IJ4jc#0dVep9nS z;bctEvGQnWC;OURMCk2cr$ zrze9*5J_315!O&LCPwgKHvDDP^*SBa%5~}$`p-l1KBun*YE%e2JI{bj!sPLOV8%MV zh!65=v~6qk!N+3Gu+c)I)vqz>HoI9Zg9v|MGL49AffdyRjoudZZ~A}$NC<3w1hg4N z?cDs-g%*98@S98{U#US%Dk-t>^hH}cfCcm}eSl2FeK$3^a}x4a7B9#-h8$x9I*jg^ zCRDxG%Cr@UBy_uY9!dDngAai%$A#QT&lhIOa@SE?o&kin81Hqc!b6;?&h$jbR9SWA zvgl-~W9n!c{BD9yOBjaO0@U9+2?fH`-zS3Ku9JQc;H#@Rq%Q{6GN9*;x#zJse@)`y z$4osc>4p}#l{gK*mqEt9TQu_Va{7Nq{l`# zissy>*D|HB8`ttxF&pVf=Nu2H*4e0s zwHkQR)VXM$4a+`~7Uj2eos)i^RTO?w03k2HZBuk)26X`{c*9i$g>XEvzL{dp6d|5IsZ*=Rs-Ktcb4QQ zFZ)<07a7Vo5mx4*!WQX&1%|_DMt~HOvXYv;l|Y^DSp^puFYV2pC8=A2P9LWRL2Iuo zCw44>Y>`g2u-jcLE#nlF%Xx>j=Gys>lqv|5n|gA2pRdf7Ogz&~gP}N5vQ{n2@HIQS zBUb2{U~b!CV0z(*DOs8G4ZvtWtA1uz-yS65e_W-f{lDc;(7F46p<}lgC2`)D;{6}! z^w<0F5VB}z+><_)WMe;Y?78a?@yV{nE7dO92nFb5k-}Bw6r>_lOJ5U-(@#x4aZTim zRqh{c?}!7)UF0%~v-{SLhFhw)sqEo+FoNRd?TN|~T2$SSwlxe$2QQ1ARvG0;8h>_Ajn6nrBj z`|{2)O5^2n(n3RDS_qd|_D$yBWR{fTE3WP{1W4ARP6_8l%8-qW5EH@0IjUgu{nrP&h`@pkW&ujqRM|#7<+Q zX>8j`V>Y&J+qTWdw)LI1zxVy#yY5=||AU!5d-m+-*|VR?tGCdSRQ#r4$D}q)J`DHK z76Ij(=w_7LWS!xzd+&sMKlA#QR(p?AkzaG+uVxd!dgEX9hmamwvi}0I)W#P`eLbnL za7HHO{?WxGNcQ5x0O?Rmkr1iBtUv}of1#0;VeW?@{9-pzk*gYkgJ(j&Sq8aN1ZDB& zEo^a+#n)zFcYiw)WCwNPyODu+z&1<-lLV`d!@w+wx;!saZALLOsU3;Dj2iK5rquMx_&Q`zdGw+3I!MYI>LCE0@+Rn*^4sEmoBKi;dns=N0ZhWH=HK(# zcxQyL_O}1<0>@SaS>-iE&6F##B7PXb^d7La78E=5gXv#HcvZxNx1i!KeYz0`Gd{;= zRed9ynQdg(pv5qn)3%bsh6jD_-+`YZ?>%^<5bE;o1y4#vIW_qw((n>Uw{7K4;CRQj2KJ^?nQ`*TdL9j#YUS{79$?~#N(;^ApiQn z@!0OF;`kz3?6g^zWnHFDN1)Q8upkS7QE*!mtn+YGD7&XG-Zc!a6yAIRhn6{$zmA4r zJzdJqKRupHY>`LA!5Yfolm(4=S2iQ}Nlm|Ree=nLs#q2$?`a7BN;)jTN;Ay8h@=-Y zykSW2hRq~O|D5tnQuP159I5r>BOUfXUrP2lJyc8kTa|8t+owdIB>p#JN($c){bw)` zzY@bb_+iiU=rp4gZJ(Gfk@s6mfB&IgcP2iZGLA)GY_tsuLCpz*0}wYfq3&L0j#NN6IR1{K!U?u;1L$%vD|mD5vh z5N{K4WWVHg-ob&5wYctgbFvfGaEIHaH55#Sc{f?!mvXYdnyfnG&_+?5q$uMpJg$V#wY z(m9l4O68z?YMWH4v*PDwTH%>{XEbN(b*Xt;pXXnuBQWnunKey#pg4Ch2*u;z? zXQYwB#m)8qea`*gFjQ~FOE8*cW%$)@c$I13Z=R&ly%zX43=y(i8L;nEvb;alX#A$> zlbygr4|KM>aC%y$QfHwP=!5rD+J4m60njS>#GoCSn|#WvsS~z{bDC}SN_dgn8)P>X z;~cpLo3CRX)y;qzUW^D zF8ypPK5b|2s21;T^f!;e_Akz}4gFp)HnSIU-a>KGUT-+6bdu`+_~?~8=p~PqdKtcA zlm0Fm#z7KI_5{kt`UKWjKlfk+tn&0I{k$e8E1|RrD%R(D78H%N_Ri$TtJ4|Q2#T*$ zT2t8^%xs`)6MF3L8|*M_PmZvFgm{`;3bUyAI_tU}n=EUtvOO<#$5TykWhU^8 z_T!(7xWJ2s*y|7SqI3j1Fl|~+S1j7LNaNz>zFiB!*o!Enu^UQdY8Ykw3%M3I*SwDw zjcl`DMhputf6dX{+odMyAFtdEJPStuBT8zw?vP=#IKNy&`Ok|G`sbsk1}`2^2D;jr zeRLCP3f{Z9O#3>jlM^SoZ+thPAIEFz|Fx9?8!N9rykz$7ALL3aQdJ=U4jCa^e~2n3F|2r%igIkJ*RKl)#0tbH6H{WsPX(hjJYxOD{i^98GZ-WB;0f7IdZ zD5&Y9s$Q)kyuudJ(!4*OTo|YST?w0Au8xuSS>#n_Hulxadw&7r4zW5Hx%G*h{Ki)K z5alsJMrFGlw{Del!1ge*BUanxo6j%q*~A*)TNB!P(#y7FIOewbYPF1zn??nv+|?EH zC-Acj5cTu_2d*1&EJ^?)o_}1AoYzZ_`F~DR&$CokWQS!UvG-w2EwyMHnIM3tf~!R) zhgq4rL^nJrHk-=j8RMe$x|&8*zd1d8V)Wx%>Q=xcMYAKNWUzaLA)Uh3?(y10NKFDI zBZ(xtS%%Ysns%*xFtf_h%gCmYN6}H#%*5!}Fum&f+C<;;xIh1aBA-sHj3lPhz2Bwh zHJ9i2Dyvx1e0F-v;O7SVIQ;1@-lpYR&tS|^S%v-wQaq{8xhmRnDY3boRgg#K=CkZn zc&s`>y>b1tW=q=(Bc4M7SG1|c{n^srsajvlxOFz9NhP7j!Er{E03=FQ8&!&++o@`Z zW(mcQla^KV(PTQ*1d4hkQ2&UMY!#iY1E!5I#s~EFIUUrizlS!6>K$J&sx70GRWqA` zwlC|@{&Lm&ec(48p+;#p^K0C&d3z6h|S_KkJmxIj(w}U}S@3jrLj7N{nr0jC0HmtQ|q_Lz504uEt|3lVnN`n>! zk^$^WYIII=`*Bc^M~+oTW62-ts6P*l%cfE8QA62eDLW~dnM@d3yys@JMp0I%rif7E zG^1m>f$E25Lu^8^$uT)B>Oeq-((v?sE4=Ayy$r32%#Dk(iP^0Zw9F~$5U_m+zU^$O zG=on@$WYwVq{V3k`~c=wzErjA4|xoo(dBfJwT^aGjTsXah>Q&vjRN0G&)PK;$G&M3DA@6sENFDFCgToXL?$9!uwRCsPo<|Ur}6fcovSDMzucY zv3^i`xLwER;$Pl>cT!VgM_5f{m$iJRDWpR4*B#jQ8z?`1#Z?G;|j;1yD58(e(wum zCqmxnfprU*mX<_+xCvTAowNFTY=by$CcFzNO0d1N8^kPj-&;CvJ8jkpx}v8l(TTIm zH2lqxV>KlD68J#5b@*8oPS-0LINIwo>M=VoTbS1aUhZ^8H=^1JE@HW}_9=QD<-(_~ zf@XC`#Xl9Ek$wa99sb{|!8x@QHbLob@kbu|`m?Q8fs1zc9tcM9^=oA)w~ts8n9q0E z>}8m0len4q;C&$&?w>Mx*~JGD02GcxEUfsna>v&=Fo=MRzYgRGYPjPgLcg%rJCnM@ z-G3ZTA2hIQig(UVO{<6uYD@SPO6O_uEIVNsayR3&1D$YfWIm6O9v~{w!#~wxvFR>t z3}}m!rh7UX?3nzddFex%%qw9>y* zuLHOdQ!Df3`cAMDtd6PW>uE^2&xq9QC*fqAh*8c&Qh_QuppBj!Xyj0rt%wz({Gl^N z%U<#RtIY9yPfD9GY!6}WnTR#Lk;J?sue)-7cIi!dFDce)v0^GGd!otN?CXV1lBE`3 zY3XRd@+yXdMbR>O`atO#+KZQpA{&v}drM3%*uZ**f%$xykxENH&~Y(h93Z(Bk})cufuvVF%aN(RaATUGZsE%TsH}g2PfMPf=|8Aj0pB|FFDlC7 zhxc#hUva{Fy)m8aUrsL(7?~iL^;zRwZnUiW!|r!q26LeT#4_CO!}G(HM;;YG1uj9} zhT79JTGY$#NdII$_3&rAxu@#xPKbZ$?x&pP<6;*V6`u-^{qpN^#JTymNe@^;zfGxU z&w}vegnO@SE@GXtPqGdf8=qACwCMrm`mlg+XL}gx)z;0JQWDAm6glhccC9}^zE<9a zh_AB8%LB0Vc1M_*b#4~3s6+n9#Ap9^GfRV!8hOsYjXF4E9IE)O)34`=UQ--OYaZ}@ z%jQ=u^~Vg2s@JAQ*7iDXnktFFNoAu^-1J<6bvXfnjX&VYHsOeK;+mo`r-RyhxA7$u zfTU)+Q!G?|ePVtw5`sZ}PV*9R(b9b`bzuAITVx8_n9DRt|MF?pB;dI*a_VXx2tM$R zoDB)iDK-k9>a99i^3dPspNwpe5*Aucsuer({6p zT#9Rt@gH@uX=u+YrK(j|+b4LtPGK8$X9iEX0=Q_RP&qstSuT23hIha3tbo`4sN$OUq@~@>;M9~xV zZWz&MWUk+`_z$6>`A(&c&>PigSrw_Re}^r*zcQ#l@A9%HXetB}SG6ih7BN@o-0@bN zvg2v2dO?a5ogRIu<8duH*Dk%_J_rwMf8&d%`;g~qSZPgYx9M??iSusjCBF`AG z5|3X)oNfPl{H{jeF+4<3JH#`U-(lD^OW~8fjML$Hgn?$i<|)gRJjV|ZobAs@4ysmUiNgMN z@+oSqG_|}Gk5lZvHuhX9rsgy`dqG`?0(OyspRb)nJ?Cwh2&4&{Ht0&f7UjT3&HOI> z!U&u!T-!#if`^lCTobFe9x;)mh-a-yG%~DAte=UvnPmAIytMDXXWdo%aPN(vE{PLH zNl(obB(&v1@*Qka&%ObpYzT>>`kSD)TE9WyI(txUNmJDNC_gjXhqbA}tA>`S!$TVP z`fu2C0sb?g_D&S^HJ@B3jBl^P)Sl?BD0p87)lj}8IcKrjl-MXRPjB1J&G`35f6X;n zi}RVlzPjR`!TI3O9G&@w;@|DPW$DMqFm{F57-jt!joA3;g>GN`Wdxjkj?v5CscvOfPFCQalk!8kn@x zsmZB{$N)AvnmF3Mzv%*5gDZcEi!J?m90sYw^D{U-3zF~BXRre?1>S5ezMU|5QYclI zt`#Ce`=%B0_5#RPvAXLkx9+%+9$b4QTk%K&6ZXKVJMANOhHWhU7Zog63xJ;;O>J8J}O+NtpAnJl$4hu%>w}-&zsX!ts%bx&|2M z79tg~AV+(>Do9RgB4kaCF*7gGUHk@}PiWS*MB9ODhX|!>Ls^^*jL{|q_S{kktR%IL zb>xN^9ZtoV{Y)zs_<96zIMV;k$SXWuz*>nGuWUZ}wKb zXE2pAMx0T{Ja)aC+^Ax3^DAF0^C~I)uM(aLnX)^2v!F*OsD(yXlp zT5G2xF;87k{6z4nVRTHk* z(I=n+#2LqAw1YH-@o$YeItqLFhF@Kx3S?fQ=^bV$O+gvM-e;DEC}vVKc49ZuB!IIx za5;0m5e7^rn@(^wnIai>TLa}j5u~i}0Q)at(q7CpJ zrOt?0om4_OR_WR`<`AYS(A8Sw5x%TAqS=1J6>sx8$J%cxO{g493}dd?BU=khQS4HD z?|s!ZXZ=hWb1%7(g66HImX{x24Z$B76B2vW(bY$4^S9}FWnViXpOJ(tk)taLn_(3H zc9Y36`>zX99&}M{z{4a({8Ej*)}3ri$V8wg;0bOY(dl-|2McY92zK8{Gdx_0Oa*RU zVI7znL^;v)met|}i%Z9HaJ61A#JRciEPhZ(+p+#05o>%yq zrDMmVl7EGlpYu)ra=OYNiHn1o4_Oc*)ca~j1!YF&j-GV;vKMz`E>B$(p~^!$!nLTi z%^mTC_8Xn7dLF!ogeWxgtWP9^j=D6jn?t8ooc=3h9cd0+9~h)2TO&>%hA+PA-lvhg zcO!x4#x-n5T68+LVx$ONSy@%lj-P7i@g{-~DZpppt`7nfk@#(i4!^|eiNavWBk|OH z{Lyqfo6V!pex;vvvxUlE0v4YBHJ-B_kG=&lanPQ(r_3XcaJC<;E*67CfV2g!+$Hmu zo7{&7y_Ja^rI}LjYN`6To}|TYOwR;TcXwsjte_h^uT^Mt10)k8ifMg2jM4-cJ3;Fk zVtPo1iIp&e5*FQIDzIjdlM+Dts-#es;QP-v8m>SbJxl40ZM3fX^%1nI5dLd@DL@03 z?0f=y+^XZY_2g{JzN!{V*Ut`fgTrr-bQp7YljFJf1P6=E;0`?Og#>SQ$La7r>ziY> z2#`euL!dS%Lww|NUJA5U`1yS*eZdR&mj>W*{l2%H{WE{t>xa@N0{*L7ps_OSXu9DU zGZ&ns27mnkRlOH=2rF$SGb`pk!y+Moq_g%*(h%p4zFxKc2d_~Hl&|&KD{ZO~<9eK% zF_6E6IwOz`&5_hP7W2zh@v~ofz($lPt-$Ah03d}R)Jhhuco}?B@dG*cw%ZeCl%_4w zZh|e|ZMQb12$z^YQe%rOi*J+&8Y?HP;T<&GEV9^6Rl;>j$R0x7e-9g_B!E1F01tgW zkXM%y^{i&G!ku@fN3dN!6e(}DB+B2Q%RU?`23^51({c+P>w8_$=?%Af4-XhbN5Yg6$6dg zyz12%sydP_WAxJW{2qUhxF7t4(8d+m~+qq%ZhgBjFs?u!|>QcCVO5u)C-$f~d zDIarli2bizjDO`!)S=vK(H7&wVTl4ZW2bBB0O3A289t3>v2hy%0}8cyh$D(o7$S>)JA*Q5vYld?jQ77F?F_9)DeNymIDZ(C+WD*s|TdlWg{Fzan?Mv`q)lwLn&hHf3^BN zsFCM_rHk>TE3!9lkA{vnr4$J?({cM{F^0V4w0V?=KMMG)@Za`%YOZH?wo&g-diYF|*yxIVPOu{&h^{p; z!~NmV=}fynoLcAiU>6(i_M{pH5ZWFe0-7w`AI5rroSkD@z`$|y>qZQ6#>RwiQbcNZ zICoFLoi-RWN1GQpB_ugQNU`m~8|ffL?64#9jsDIs^lDPZ-nSQRSLKHeLzfmEzTfnI zlX$c=jYa1YV1g=d4dflOC#qnVWfMNLxCDZy!Kl7+pLi>NZ1*J!_n-TnIg6-@9V;E+ zRo3pR(O~l5jL^Zt3F#vX-folpjTxXO=^xTJ_$K<^6P9>xVp7(nCi#%nG$GLaG?Z?A;{!h?=<}3WPLORR<84nyxX%lKK|t&{iqib#ZOQ+%FxR2p zXD%hu7k&_t&lVa&^Nn3x%1X%y&|g4uzg%KmZF7K6YJ765l=z@m}O ztD^MpKn(Z!bD|14evM!aMkT9){{fLRnB^F-{^_Qi5xBuHY zE&Z%1FVu>PN9B3@qp3Gb0o3z>8jU%vKBgSby2|42&Vj#H3 z1vrBdo;7lqE&Va>%6}eGqoL!yUBN9NAaQ^Gk$U6*{P-MT72&BT1`m!OzQZWh^7o~` zoVabYi7KO?&rqb1?OVU)XwsGMnXgVg8w8MIaqS=-9y;qd!^}n7^fT}fPvAVHb8r75 z-U4Tk-Tq`4niOl^hB)Y7{~4&EI=QxnmhF0+cD8uO2qudgyRR+*z5qFYd`qtr`pFV% zbu#&ZHGys^+W^r8!2~hW=odaa>TjfLTYlD6i%Ho0JTsy?M|K*7K~d2>N75G9jE}U8 z!z*$z>_Mqw2m)60hmkD=oCs3H0;ISypY;VuH6E^M(+2ycb_x#G?bh?t#s;Mhzq<>q zr#a>Z<_FBNZ&wSR!xNv@Uq4|l)n(0>+LIYo(~l0!Q1>&g&xYMRtTylUWxLq?0g{sC zlB`uUd%^v40p_z{^ISje(+Ybc>@yR}@BHWSiusUUu&2UW$~X!lWPUVCisUu@WDKn> zQ43R>?%5YJ)yO#z%`Z?`J=ZA#p2Ho<8ei8qzs^Y?EIwmF(c90&r8C?f*0s6C{uhZ0 zryokZasf1hWGu6}s(POH&4@+qp1Hg)_!oEDJK!`-;ugkT)e_z=W4UaxDEEIM-fj|XoXmjq-f^dk>&hqB2+=D&9 z(3_Fx$r{7m^%hvO1< zlJge%4dL4B9fpwzTTkuyL(_qf8&8|2{V@HsR7ZHdvE(w*a}2g6H)^`AS>aMoClhP= zi1o_v0VdfIq0#Sed@#v}RFIG2Fj>h)s<}pM;eFJk1@2@kmoQAHP=K_)w{IC5#_p|y z3T?El?=*hitFx-OV36Wv+?vy8KV$00fKCkFn&+*#B72?JH8%8L0KL8+Y23OyvHKY& zh!oNIQmG>am#4h_y@0uU1DY%mgGZDpW@eda?tH_0gF(pw6ONA?xP>oIzLORj??LfS z+C2psxc#%egdZ)6huuW}CkTX?aYbI5ad~~(y>D6>*VJS@|NZ)Dl)n62dvEfvLsI3J zwwC+FjrqVp_u#{tnB!8`>5g&%W<_P=&_x!>m;fqQAjZ&<$;C|^k-gdjye%QxJUdQ| zC4IF5LLsRdvOo@)4tcy!tDbG9J(0?Chd2Rjd879vIxKDw-!fbxrPc%fhr#^Q?+IvJ zMJq$PO~FS|PlCk#{znjjoY8zU8r51BqQeCKMM=l$Se7QFpI$?_mkZN0;ZHac%k@6G zv_NN;b0G1^vTgdEnG0s;eFF(?+R=ikAG>PCBZ#j9JZNBg9>d(dIw~94=LZ~L@{KO9 z;sKkdr{NC=s#un54~})B|Dy-tJBeJe_S8odyAiA-Okq@fE$qa%dd33k(g z3(B7l(MQ~+U3j!UlRNt8P`Efvqw$9`C9F7j78TxO_UW%a zN?9~-azaJ-J$WduFF^26O(+g@?v<#M?-G-_GxgQqz=7dkQ6x~)_?OqHUf9HMqnjK? zLyc=HJ8gBwV(J}HcZgV7+Kw1$jvH|g1!McR;@wsC$uO0P zR$Wa?QaVX}C-JOW{c{4j89YyMaud=o2;BblMk=AWM*1w5Z9@RPcqI>h(WL3QpAf3< zKF$|4xZ^6#ad!r{OTVs(EP=kXbV5q z*Qybrn`=+_Cy&rmQ{)~*2vcBf4ccK9r!Jy?Vgixc`g^58{a)vy9(s zYhGw3Y5VOzFCQL9l^!CTemh8PH}BUHgyuQ?Xz!ug$FCAzbFHj{0#ev)iKRF@Y7FST zTn_>cn6>$wi*r-mKJ$7(t>7=zxDoKEl$XH+H}dvB?0QXpquDOuR%i|XU-~2BwCvQ` z!ik#i!Az1*}L zk2sVgz0Slh8PM6UC!7TY@%bcJ@B&zqWRjl41CVO13^{g#Pb)%l(jZtui)5BcydFSr z08(8&df|~ciuC%-8LC^E6t~9v4^yeF{w_z#;%N+OAj=P}zUhRrb;aFFjC?kisnFLB zv50RNwGfMx#!81LnOpDN(tw?3_(|zEiP0~Bvtze_z|0o73*HdqDl1FJV^h9OOGhJ$ zr7L}R&7Uiy8lOmFszKByr`@T9Ic(+-xfcg7tbmwsP#!brx=u z`e4MDH>;{2>)l}RAI@GPSLV%TH{M`?Q*z0)OtDWwo*zQSMvzyf;q@>1mA*tU8%;T# z2|m#X91JN#bPHba$*g#M=GzFQLB7X%J-g9{`6((xWc`R>KgGOSVK0p$Vd3Nz(tbVg zh2xwn*hNA%lEiu)a^o>lmtxbB*Y|Ami?fHeW*eaH$gvy?c(^>y(NS;r_{=z)dO$?l zH_ylgpbwQHNaT#3s}_VD!@T^Zo$C($3Wfhh6U@l;KFJVSCn~ zD*sP(y!GPViA5Dg+R4HUaVWIC9@L@5u-$}eJ)eg?P~<&ucWPk{@G0VmUhH<8U`jtB zB?D8aDB(3LS4k%Hx~C}j3NZuL5oaU6T)}!=LSrQ6{*`ld^_lc&_-6U zqE@Kv!R2}kAu&W>TjQv}x8;Zg$VBCZ@mnCJ%Ik^E9juoZwW$I~N3f>yyN$Jg_Cj<+ zrRk6`!h|SRl#bmT7sAi2n1wyvajBlL(K5{rd{Mf3?^HuYF%9p&ktMh+N403qVzbi; z?4Pq(*wjZk7~xhgt(`RE&>-#Dervq#sK_doDLy|~E?&KIyDyoOwKi-GK4mq z1H3JlOHT8(o0erjw)lT?Oz`E}eCsN6uBzp3oVwxL5DlIZ%dQ{9Axe%T{h~tRyGzj% zg>LN#Mn}mO5jx&^oy#sby%${Y)AlP09gB@<`LcvRd$Js$zeWw@@OT%wa%iElvJy%7LG<$g$S%5w-qQgfGGG53ZPO zAzZ2tT}OO4-!mHeAn}%Yv@G*iabcd(->`g(@=+87legiccd$HHlL1OGd=VrO*#S%- zijQ&<){FOEi*fRB#ZT6MG6&+!Kj54BVaI8UVi`SzrSQ^m;N;c8L);40ZblZL{8Dh8 zfbpIlT&Pv+An`8X9tc7W5+BYX_7K!3*%81blqXdhSUWFO>M^|DWzsDA{!5oAO&(r6 zF^%na&@ETL^a~w8xECOAzA_6`ok|_Pe|ST_zK7l9trlI5prFjV8v1(B#N?lJoPk;! zCvY&rv^s<-;=4A`CEc#Q+T`|yz1%&R1a`%`6zc5;(RVV^#cd7p^>dRgxYJ%iNka|L zh6lo3GDnsqrS2l0f4xu5bt*o+4M=7tk7~56P&?r+i44>fN>tJ z9q$Y037H3%^X~l73RU<@7VC3)%rql4vTSAHdT)wGR4Baw$f~j#lgkSq&cR%<+?#`a zqZ2V|i!LY7qx|XlxPP`Z&=ut*#s1NUQa=xzll}g-Q2V8#XNl{8i)=$5p zdFyqgU;lBsxjUN^BTRzXHta&uQq4V@p#ll=*A zK~7HX6789du0^n+0QZX0ObIWKzbUWDE9Ik;aQiy}zNL{e%IT?!?%1ohYy9dCp}hTq zTW@CtHFI$b^`_ZXmqAKfhTJfrtZzHKIh#?xCtMsFJmXt;f8ATv%YG zx&|zB2UY@1V&8X~?d8pD@p4QhgUI&2_zGI->A2*NzM91G1a`7|Yj}~F&h}eG!snpn z`c78{zh-ocL?kk-29(!nAZ$5B>g%8%0scfq*NgO!>vDc%2;{XFmD(LkNY4(t^3L0A z$s~3`oy;pPHQ2#1nP;XIi$(u>2fG3TYBxLV0bbVsnWTqrAsfKwJkXV~Pk`lZIYaL! z#WyEnOG2g#vmiCsvckqLV~7r3st&E9VpX(B&u^6pXE__Xpg9Es%~IwY7k$0zp!jv% zc7` z13vf6bxPzOqcimQyW`guY|pA0-~CEE1nsU=8@L}AuUeV>hdl^B0G%#u5E$SlWUs21wFaSF`#St) z8{5Bo9SQ#_!sUC?V$9!lAb>qh=M#HRmx-anzh4gdL1o_1$$4h7X8si=DWg^*lvub5 z*F5V}0bw3o{V zDeceS1-2F%X;#6$S~g%!r>M1&*il;zh!$x|;LuiGIesuM;rq!5v(EiR3|q!>iVuB<&N|7gON##)-h7qVumYvbJ%CgdS+!JD{Q#ya>1{`{!Z+ngO{&+q1JCjm&9na9FhMk@7v`gR{T-1a1g)ROAxCK(fW(6?kQF@nI8cs65hlw< z6emVYQwu7W?njP@0~~FI%2@(y_|D!3l;R2qV8KiGwA%XFL5O~k7~mQAg3-z6fW~j? zOgrk?XAe0602y0Xg9{Y2&5$ItU(%<3n&Z+EdrpG@?!L{!`cE(CvPzi6J+bM?Vbyqs zoH{i}T&LkxWY%m~)b^W{%_OVvful zqS|g~uO8h!_$dQ%+gWLPv28;vE_E=07UpKnvFv|pRjmwtR!oZhEl+vdg^hEu33D(A zSIV!=gt5=2Mwq6cNTGG=Kf_u#cdBK7$TvU!0vEdrxp~? z0@8TzJB()qL7-XmJZYIUV|oG`Z>Wi9D6-S!az3k!nHG5AH1=PW&SUMK z#13xXA9Fmv9vXYT**d-UmoR$aMF;-sSmv1Ycum=O27{bh$?PdUJUfsDaE3)XEirWE zn2Dp^HR#FsAAtk=>TO!r5SXz8S=S{iyY*lIctGBdlT6z+)Ids5nQ5^Bj_R=>e2H(x*frjT!Uum!3*uB2rE$(={f4!xF(lsMc5jGiT`PC}Evs znB*K|>?%G7M@Z(^b#`505OBLP`YL$#LZfTLg)_f-CbM%Pv zxokLGnSgrecP&pH?+Lc0r(hNBH#EogN2X;B4Sutvd6qH@UPqjKzA}Ch6GRXp4V`sc zE~%r3(=pQa)xUn&X~C*CBE4RJ#BHGR0W^s<7cVMDf}d>AT;;oaw_vU>1U)Xg$gL|n z-J^!}!m`EZRsk+%tuTbK8Y@3K#E3>bXefn^6>#G9^UaA;W(`!27CcaW@>xumrIY%;cjwfIe&a_&^?ZNk+i^ItV@IwgQjJ;=&8Iz^}9ams#y@0O4VXM{?J@-!~QmnK* zE|-+Ak@_;sonDz$h0_@urIX9qtmJBZIiTxf*Vds|4|fj)j$RG->yoY~*nO&~Ekbgb z!k_Zsr!}+Dy-HBIh1HU^zR~FRW}@Jbe?C-wSbtnyQ(JeqU!LA>ov_32TuXD>OJ$z$ z>y52dB)=;-wsY zIbJRb2sSBhchpLIwu_5WE1br|L3}6`iB-mM&vRR|DwB(uzV`pZR#CmciE_zab7oinf$;=)7nD@{Txi6c#rNgI@5@t`DQR{H@cY%_#cif7-o3OKs+a_XdN z3dSeh;6blHc-<~Yybp9JEx^8?6@5v|n(|Y^CU_!hpIzIfbE-;MR5+n+nP_K=o{&I$ zd|6bu7eDeCFGa31?xHD^RwMVCB2Ndeh^U9TD;F8Xe+w4{q;bGGpd9ssHQs?Us4w6m zh_T}^dGq6)AH$2Tf#E?ajKM0U{>mk$kTH3i+yZ%fwL?xyBCH3vWqGc;DYVXKJ<7$^bGus+xmvBbBR6K(Qp#pM+Xbd% zK~Lo2`8P9)j8j4$P#41sWP-Q0>XLM%h-t=vccS!yXW@e}*{W#-L;b|@f^nTFxp#Cw zJU$O02xwEpg#c=-JZ46)wB+xcM0i?x8Lis8Z`sXUYF(NEMI(mpz3v}~QEgVCrRYW) z2yv((9n`<`F&hm;CrAz;`!7T{abICiDHFKfiAG*_C(p+{Mi-tw`1yI)?WwT2OkWmp zxwZ`0>Fj+8d*Ssmi6J*Son2UvJzfp&DPCmJ=y+c7n(iPSRVf#TOi$vP>h%AZTRv&* z5#A3Ai>uan%$d-#NOFR47v_YV#^kVg9PG;X%aP&=-re@#CB9VgbWN!{{fAY1Q<2ku z7Vs7B%=cl1G!_?{kKZkJ%2iszOy;=_G$J-Rr-Me!g>dm8QE{$t;cb_q;=&FAI-b-| z@kFA;wHjv4K09evS+g#PFze(n9_D%>ev;BUIL{iQ`fw6;`7D5@r1?C`<05;Jlsq~| z)Ycbx{D8`wp_2GMC~lxS7^aW*Oz;6D@i*;)af@p?_KE(8uTJTm$t9j|c$C>pvmkb- zZ<)cWIYP!u@}Arc8(`R)*OhwfYJc{l(Q#Jm%ofYdhqr58H*)c{d%cM9Hl3QH17q&g z(u|Mu{8w9lIwYTL18(NoFW#Yb`@eJD(1df)U$qCWVkRpnw4ThL1}0Z%2@q zQn!X|$cMIdswk|^^hpE*`q+NEq^5b!MNAy?Egd=Q+p!ojTT#Pv?3W&rwNu8rjGR}I zbmsSVIRAQ7j|k+fJ7$sdpESbcI6nnbZS zvDT*eJ}YI2Lovl#rf&nTY*C&-zFiQ|sM<*~EJ-Dx&R3T_;eBLKL)SSr^W#TO-X#%> zp?gb&u#Y~ys;CFts6O}tu#h$|C}8*j1v|Y33z&1uX_#HB5Kl(^JS)t5VzK`bcr*nJ zTzjr@;X)YbJ*e?7`kL_;QSKb!417u$owDv@<88OSWXB_NNC%JQAU?vYd0* z2$nE`uW{% zqXoU$VkLjuHzOoamu~Z$zp(7rnlX0Hc>R2`L!GRBec(d0;k?ah$+juC2mfP|nUe9s zQISub{y>17_VxLEc)DTSPn^j5I-O90v#cI}jj4h173qOI;oPt)$|o7)auf80G|FUOQu#b-`E3%(}*@eP9hgi90mmE&Xf z@bmMk!@C1@S?us2*J_nT<`cX-6t`gBzV+{seUdHQHopmH1qyL`=A9t4Rel!z3JcF% z5<7^o9$<@!Qn-|Wt#e}8xoQf^eYj?s0Tvc~E9L<-}Kr{PEUf=#t^7csp?%ACP-8UF>HoR>YwxV$-{rPM%n^-^7gGc zvgD)#wL+gy`3ZQAQ+fV8l*i06vA(cS;e_YmQNa}fEs5@w^^=24N4piwWk_=d3! z+V(F9DcQ~}gmtF5i07*u1tnNG4%s#tcjuQ13;jOJJTu93I%O=kIeFwjSKDE)<8~~u z9-}BEj2|YYSu(O`tJCybpy6K#yQ6O0r0)iA_E1PaiU)&F2(Jef!1AE+CHtS(z%C-3 zz~8~R`L}`IOgpa(^qd?p=lZvresUTa2Rjrfge=oPZg^Z?VaoS|!XuCXjUs5!1A_;) z$KT|`mTPL{pf%>rUY9Z4zu0ZoV$rj$=L`w3;RyxkP7 z?v@i=b`M;zx+8&Ft}go$S)TPE(^9^tgqjzp1yO+X_qMMe_}skPI@5KnDn%Em>5dM2 zgZ<=Svj9KsrFHKkVWEXb; z%@Nf;jhVPA&p73Zr`TqWG1K&3LTsI^)UFWd;X^b+_!Y8LBp_N+AUiR%MIZwUmMqfTbD5iE=NIVoI#>*(@x;A5T zUPg7Q!?=+v&djRbCL6gzZ{lt&c4n)Rits;bT){<$LKk&hkZC+k#^UB7X~qR*=e;VI zYVKJ7b|(%<8u8L);m6KjJ4FH5{kd#_V>mkfEIK!9O}(F3X8R6DtNe<&6$Jt->-X|O zPexwD7?In$BkDY;o3=kRNV#at!ANjkqTkw+cw%fmgv^d4db$ZlVnNDHR9Im> zTSV3ZhV*Qph*lWilP5`(%(RdrVYUQ6jXRH`q2tXVL*r z@Jmr()jV8K6)eMd(wv{MIx~A2LVImZXsHCRcXq#J`cE0I<$a+XWXW?@z9^&VBkFLe z3|s;Z3s~;|hhE)3RX?`n;ELZhNIYr(jw68sD>=>BSs<0@lBSi_mN*mFdZh|0>@Ygz z#&9(I?DkZEl!6$PlnFXGU63g)({0}WkFB?isv}yqMuFhL-Q6{~LxQ^#9D=*MySux) z2X_nZ?(P=c{cVz*bML$3qyO~iz4q!_wW?~)s!dm$6n4}t+HZd%BZtY>V9UND;O!MO zmByz_?{=iq|pa|Ye= zQSVS1^gr#!$eW&av^oV84Husr+h}rVPv~smc=gmaluXSu{DRptm!1l&rw%c_6cvPn zg5VXcogR|}m9#5tFY7Hirx+2G9)azeO>v#Mj^hYC=*)yK{%+VNq$vHVgqkI}T}xo) zin*pNd>=gm*DRZk0knMMrUPp2r95WP0Fs8jA@@_n{BIF*th<9eVpxo{Y$p4AqF3EN zO`D{a^54G~maJ(oP+C&0{UZC@@KEn>%f$;=cTnx_|KHl|V#ZZ$wtK%7e}p1wocQU7 zZq+b*e7l#i)5_Yfpi#VvCqt&kcg{o+j!G!Sb^XB23)%Rd)P7ahp!N7s|+VvSgjM7m+(z1@-@K z9^z?2zu#RS{g&qtl#Fbr7de{1!Q}KQKBPQ=rMf|R9)eRaiMH@N@>;H*eTHssN?5pL z6quA%P)mfl?!ShNeIHFD|DQV4`osya{^D!N7-8uyoQT==GU%AgN1hr|In@j%_5Wz;)j$6y{;^g>T5S^ZQCI`u z@t-A-c1-T@KYMa|M<70PKe2Q(brP(xe<>)8qB9(~Bmn_W)@WTN8Mi0FU6I!&ja*r3 zEfZwUe+Xj@i8Lk%Iy+>Z@sJWn2g=Qi9$YE9HdmAj4V!j^Kf| z^0%yl?_gD2~HYI6MBr)cDOvjN>M*MS|P*IaJ?*T z53%RFvlWqt)e*YPskniu&jl~|Eyv#&EvCLkv>j^CUVi}p@x8M2m)?Hw56apuV-r7}8W`60B7S{GR z+2Nhjpr1dQzYl32^*eb(4^Bqw0w)+_MNn<5nj%Y410me{3$7a*ZXM#~+Sn_8k`g== zg+i_%=FzC;QO-)zhaMzK6(MKc6!MQ6H~$e{bcXP_BPk9i05Q+2&YM+CaAX802C*+H z^2sq>Cq(vh2#~uVG9nNHVE`-P+D-n47KDewW7oM1lD(H5Ftt zy@>JzmRd)9Z@NjSmJ%M7$jV8~{~(5ac;ysF%K$hUb2TihZT3Ilw_3*7ak?`p^eJ~3 zt6->FGavi{lvujs!PBCWf)&_lpTL5zXd@k3c-5T!x*E2Z?iB$%tnqlST*9kDrhRoh z`OwZoczC)>*T2idG6rmL(GnmlqsCoOqp@V7g8&(vZdk4FCS-CfG)O`zt`e^QEh|Q4^Eo!3B<`iDeF5qweimXVFX$#Kq=X*@kDI82i#JmUOC-tqwE3IP-x(1K$U`kb?nw1`S_qHTu9gBNe(%&9+Cd|ab+Ndl4#2nY!p>Xjt?hbF6(Y8g+` zw3B5pRXsE8{2ud5{96*KA|#PSCZhFFZq9Nkdt)9Hxv#=@>gg&m_4Cr;n7tMnVY4c` zMW0ZqKV9e#ZfehXAVOL}H8 zqDrI5Io>ecuQ}euD=0=8myA()LwljgNsoDT{Yuj~QUP*pKWEM-$$zi4Rv2j~B4+GE zdhp>W4L3fc`i<7FFZ=3|gbhx9Ua)Rt88RXx6n zmz^1Rl8k5vq2(rQ6B#HV#+WFQ+PghAJhU=>5q^w7Ir71ylPu*-n9N|V$j_2vy>_|^ z7JtHKPDnu=pyGlfwRcj}w~ztFVO);+>w>`HrLTjwA&-aSGD#$R&qd@~rH)iN(82@ovT4l${#? z{p}j~`!Kk>oDp&e6B5piL%om~dIwnTpweju*4H*0(3KCYD&X&2WrG=V%XZy8s;$+hM z6DeukSG~?7N#p9HwvR~xLK~mN#aEQ;D7VNMwXk4Kbms8u21~mHnUL6K zB#qQJO+DGR(F+1=N1+PjU4-4_j9eL|iXWP##(P9JSxqWl7J;XvMZj7=2V`e)FSA@> zUDJvdlMSHXP2rcbYX{)Zxb3CCjzJwSaJy$!`Rdgb)OAWk#fo9;y9ReS1dsq)J} zyv!Q(z5Ai%Dvcwd8#7v`(E0Z;A39vOMHXt?!hEP4ptt(!_b97?T@A5igNxHzPX!9s zzM8sGI4x!dA&N);GZiA2KGDX81G(V&FH-sTS4{=8;piLeiIYp4q!LfRm}8o##}$&s zKZls*wljUI0IEBwC8F=pfR|e9I!uouKnQKiX zJ=0%|QRbY8x-`-Li{~VjFje$;mVOyqZ&bvU7MOV;_KEt|jc)nQNG+Cv-2mE?*;9G2 z_&y+S?>cXP4(;8QTiSJ(ezx1LX9dz(t7y)p{!Ql-U`Zb~=VAfs$UBU4Jg9iMCO5RttYvO&TCg?2pfn!7s6~M&anepek+bRku4(Yi12fHrET?s5 zmh|Zfw^|TAH&@%pfk~K__IWqaBk}Yei?ps~xh*Tfm&mZ4S!uoQRs^;2`H|?JI0*w` zVN{VdYF*uAXv_T}HhMAOm@I0{pyArK&0zw0UF0TnfKMRv>7hr)!e*lXA=!?Xw+g2- z>ODq2J34J8q`C!Un4GQ{!JDoAE@{G{O8>5Upd58^q*D!vpU;Z>wa5+p88!<5xWkqEHD}2iu-Y z>}--s&ngAQb{gY{c*A3Lqwoc|6!Y< zyKM6fi|=#VX+@p}Zy$02oaaV%xR$pXhl>GScM3COCZKJ|KvTVs{c}dNvVtVlBZ)z6 zP_33{PYsb!l}*+m)VW{aYa&gY6v<6&^$)IjiX34Ds5RR6#k#D=?HZ|36@C3OHh z@-Wge_l{~>QCY*z@Z+%U&0DKgMS->-Q`vFH@T+`XJx`7lNxU|FpTv>JSn0uICL5EG zuBygG?WoP&;|Z`DDueSVP0sWAZtR_EMr{YxIB7gn<1i~&NSuoWQ4YGQ{3)j8a~lXi zkPk4y)#9xW)vB>Fv09p#me;wQYxZQFGG_v) zDdQ!hI2A@&Q}#rnMJ$AtG&;Cy!QVr};-!TAWlI?w+uc{Fva#}BghOw>!lRQZ!IzF# zRGU6I#nTrW@AYj^Y_?1GFa|0>?foK5v6qh*EkH)us>ZB+P&gaqT=SZ62ak?&^kvGgkiuU+vQnNnLC z8Kqn|85_lRscW%F6}ja32bdc*`7_3-B@Wjlwc7hT$Y{`fh}U zh{Q};F|Nvkzs=X>l}M?yAPZOEPA8?HlyYT<|Ltk_B!QQSv~>PiPSxPi{D)D(2HYgG zFpEWI&jP;mT&M!=3;2Q3E9AGKqc+Zu6$9plhT8M+>Xd3KE-8D4enhrOl{jj+xAUFg zs(L{L`TY+1S1)*$*b|pq~^(UK8KEIq@Ze zcqqKTx#{mGGvx@KV06%KR075UmVT^(V0(Y4cn>sYUcRz?Cw_k<*(kj8$fhU?Gevp+ zB?8d$h@1g{fmg40@0ao5?L0U;@o3qKE*VXHOCr0+XcoSii%xZJlUW+^;}jNZ53CNV zojtcSD`zmbZcP~-(5r=IsV^Sil%Lep33!|nCY}Y_;jyin+Al_N$_~K6i2Qo1e?`$Q z(M;-ZGQM_u^toHkV{q?VH7+S@CNwQDV?Ii>XH3}-qzHFR1Xw47w_8#N3g;aqMG%?8 z%%HnRX+e{rV`bg9VnRRAA>Ze zSCJ=vxsByREXqeDj-n*>C`q42cZsszCR~FOcR3Enpkb1i%wsOb4c1f)Ei&_Wbm}Z( z+OLl5Knx8m(9eVkp9~Jpo8s4WH8p{5g0J)t9Wo+3t~qP|Atpywh!_@)Sme#}-O5x* z|I&5{LkRi4;7dryIynzs!R1Ztm6W5@9gA&NY)NOzYcpNPl$M1~2)8}KY^F}C^wDnh z6HfVlAkK<{e$|dCT>+^zk-6khU-k+TKww#ZgGB{0k4Gv|`jWh{R*j zUEG0`H4h%S<}k-ZnIDD)z!g5gHCRqP`>mxMjJ$WF_KEfin=g1H4j5o*@K83fNTuT4 zW-X+>27>xjPShicl=#vPMI`sKY7A!w8&n$?qNSt#vETxp4;H`B+R ztR)2FbB6^Fr--l2q>7?v)^~iyoe24Gw~g^8Xt@$0E=;^eDh2u-(N|;x%Mz>bl#Mh} z(uq4sMD445yyNUHT4py90~Y=8vPWOH8w+~9Fw;zoH@2JIrSS-DAw1OLkV+#s)FY$n z+i=davuJW=lO(TS`7PfjBI7VlA^$B)2UL^4&`~PHW{z}!6+y|=1gm_+%LAUVevxLh1t^-kjY{DMS1l`p4OA$5Y z#ZnaWd;KZfG=4mWbWsY0agsx6XW28qy4^SOsr_9SO8z0;M6{&?aHv}zOEQ0V8olu8 zA6+BQyfP1XuP{)wlh3%wE0=lCCRLFKE(uF!Mhwe{e`6w|p4TSN?#1-lX#_>(IECn^|hjV9oq6v;ac`EX| zH7^=6@Q6|Ol@n#K3Cd0Dl8HUOpnx$qqO4?~k=Av<61)V@v&AuQpHzdY(V3wmEtC&? z)OrvLwI~fb45@5l$Z7LEBJb42O367s0ylRwh*252?lC=PqG?OYcjTggT6`vq_XxqL zxS8?s=M0^S{2RtYG1@v6&Ia_44q^D}4JcdVl?gJgBI;jq@0>;u(XhnmBMuT4h%tdJ z%j~APi0}T4aP5nlMVg z@$1q2h&{!GzBnm#elI5tmY-I7848%cS8I93%2bwdFWgEOdqe};=ilWbWm3-$n$MIi zbn`9@eQ8VaLc@!Z_?~YUlb8zSfGcV|YFrKQ(3fTMw+ZBtix`IG4;1{9iWR3&Cuz8@ zqbjYMW!>1`nDPi0h=pgclop>304o<)O;?t$^MOOV!=nil{d^c7MqA}40nr3-&M*TO9+~4*XcEjyv=sADj#!fSN z_FUEleC2Pt`XS37?EIU|exT_8Gp+saxQ~-An@<4F1R|Sz&jmKbu<;pi>0`EUv{F-D z2G8UysCTwbf-BZ!8y)m*=ZQP$7ew zZ=LaZ$HJjk0b*A#;DA8GIWVyN!74uF0pOC<<&^QQcAEczT(%YW*~VA0x_bNc-u9v760<9F1Zn?}2P%V5avd{ zw1tu}OdjsZm>H1aKIDsPF-+60o`jHUw(rXHQkwJ*>WPbKQx)9^trMp^i#qw*$`~2- z{z!p#p;5hpnGt+-Z475qh~dAci_1195`Ud{QTxq%bV(6Wlob4YIi6jNSLlu7XOYqr z-9&bdjgfP-B*&>Yw3JkXA34~^B0+PmzMGLprnCq|22!$_$+%9}}@b z`8aNecGqoOXZMdl32>PU4KD+Mq%|}-9xw12AOWkZGUWdQ4Ly+a1~VwW%XYSNwQ>i6 z#k#|b1j-55coJ&GH3YGU)=Vm zo>MAQp*-{{ph|g9b1EW15!Is_eg}wbA0@-y%>o%p{iVjb#@~%pBLa##CX-Fp|MFtW zikjn9M7MzA@F@?!LPonfCCN}|o9Zlux{bKgC{KcY{;*A zruZ@hM%v0Q@M_Ap%2vFw(u6}F7US=KW$P|||Ff;0k^BXWQeC)KP{M}tnaZQTEfvaw zZd*fkZsAK6j}4km;$$ngNbWiJeANHsH5EP61SC?yla;X$AF3o|-mF;rn#e^fTF&HtoorwwLrSQ&q#=sOmA79+PdSxl0Vy-1I@%fARAw zv;8h0DXX=mD%dGy{E~~>jk3>;K4WIz?iYv)qptfr1|UHFEf=51?doPY7Tu+T^F&DO z?JWH+8n77QP|af)lQPm{%AyAcjQl{@Kh>N+;rw5<84%e2SKS#f>i>?D-{NC4cF*$w zwvBWd_wx8RKv8_SleV7z>o~(31U%r{N5R1BcUpiQwg^hldv0J#icZ(4d08SeTXRlL ztVG!j5@}#b%C0xRp<&@-V|ygQbV1_jNHm(yEH&B#@noJ+W#FEBkw!+_)1stP^U>C0?jc#z*9*26DN z(05=cp7pyuFCl)+Kpl)g(s;nZ%!bk%sEhkZpZK0|s|xbM^LqD5g5uvKPwz3Z1?O-L z&co5HcX@WLuDgT{ZuTT_?m@qfsmxv4fsJm`Y{Q;^?Cn00nwY{2Xn863FXqk@=?xE8 z+%)J_iNu{g3w)B*f47Rs*BoeQ#Ih!`*5tKgZX)7G>*=-J*f!8$7Wm>?1v0tkK9AXxmF$z0(yo=>GY+#;VJHL&6y4mv}Va^H& z%c9uMT0SeWm(CK4@XLQCAEwSx;8{dL9_9)ZFckmQ_%nw+I6S~WO?5Wc-}+3NPrB0} zbhrCwH5~Bi02(53F!Ef6=x4qz;Hbc!82Sn|ih-14*7W&Nri`>()TLMGBGg!k0${P~ zH(n;K<#I}^^-8Vus>sb?{#+c_;IITll7{x5$H{5Ek2PPUgBjQxdq0<1CA)m85N&pY zUl9{-l*6TCKb8#PF63k(iTxet4-Kd6xSgwI88OEroc=#TBbjpd#4PsVTBx|o(3kau zT+}_JqcnxXl8#->+%;%=^Y)0zNJgvmi5D~?pz$zV%u<)qe*ftM1#vgzY=+FW-nf>le}})*Duux zyRO>kpQ)7VkTjd?yw@nC(A5}VXpZEnGW_1iewz0_!vp?LMyz+FVIzKpx@7YPeun*O zwuYs7h0aq;LWva5PO{TTtb zp~(Tb5KT_6Gd63C`xzuGF{!hXdtq&&eDq-}3T>=-EQ!;!kcMXePhz`8K{n0SW2zi= z&m#X)j|S{x`J2cgJ@A_q(cWg|PXOWy340_H~F{|~E6BjgerfhpI(h1{Q zma^E2iU?+j>#k zBKS>TM31QuMEVw?v|&%9tI+K9fp3|t_Q37>!GIrwwVw*&Tc)fK`7e4naoWl(fH zvROW18nm!JT}T7KfqKH#=Q>J9yOU_AlvME2$Osy`lW>=T_@zm{K(iR_qL7MwKR92} zuEd!5b*5cu(cXv(#EA=rG5>hv(tfkeQ%@d`SD}-dgs~~@CR8ixQD;_8@!fGFCdq)` z!MfCM@}@1h`R!nvbxq#;JugCiRS!$R9p8pK@aJ$HCNB5!x?YK<|@M zq=5IYmIj{KL#6jYY%%{Pkj-xw&{!ipbdiYL~+?wQ3ysu^kA*fP-qDywXmN z>|w)lNNJq$RWW@c#ZP9X=sQ^SJM;MR*uthjBiZcjF58QiC+Iacp9WswF6+P+kM*l= zpw)}zw$J_y@WUB1HKtI>D5S_49=oGe_jX~_wm_zP#A_O@HQqV|;H_A{JW65CIGMQ7 zO}9+ifX?@{w`#Ql&-Wc3&K@`VJslH6bBDNS-7+^H^H~ zD+|siorWMDiL!Rnp0YcNWeTjYK1Brd>qqf`U4yp8>Chbt!-={#{ke8=+!( z97>wQe5n9!T0zVjO6~ZxQ{%dimAU$tJ&nZ&+8tUmY?E*k1z4g5L0f|_&|#G4VI6roOC~RH zXSG)tmD+)wRp{MEl>Y5_<#u@-(;6n@%uX#3_VkCDhiAxZT+i!O@hl~0g;*_(ZD;X) zx}@P4{FwYYX~d$c6S`AOXa(jbAk!}b+<5JNt!!zNKRA5ygqA4YSz-OK`9ix6J76Bp zov4?ZyAC0_VU4!9>qL@B>%CerW1A7yTgM=mMU>k;XWUM?HOQ~FxTC+N72KqZjp0+j zw#X#)EUb$I6^4gMWeF9y3ET&tp{_BtI6W1h;dj{(_?>aAJ+n2A10<^c5J;7VYQ*g{ zPsN?n`*KU#Uh5s~z*Jn$ndEa36RR#a)$<`U-dAgP6bUrj#W@-ilbjB28iA;tLEff! ztxxaz=Of+M_q@)#j%s!-$w;3wK${{xKt;(gqf-$v8Y#`_mfn)5+Vk8*?_*VNbK z!%$A+{R?L6m&FS~lJpTrQx_Sh;tHI#R5zNu0Htf;s56vQP8R_dVZweWr(mxhMX8 zxmk~ey{6Z)U_WuRe!?oSRO>idt-1X!_)~>u{scjQLVzNSRQ#sy`_0l01cs|M?n3HB z?s0F_AlvW$0(!ol+G5S?GbTg-bm_KH;~@3~Kh1$F9T~4jIoUhfEPkp-;Er)@$W{&K z)_&m$?@TZrRi-`5N5xvNN>J3n#>9o^OrGL_|LHAfwo?KOl2%RQ= zuz7b}U3`Jcr0*761~)ixTs+stv-JH28VIq;p$%KKrM^c&^u8wT->&tOv~a36$0(ns zUUUWFbAJ3BuN}+wK=hw}xDDrFg4^ zT3QniV|EJvzoOQDNCuXTR&qgc-TrMmJIo;tE z8U-plzFxB`ri@H-vnT&N5%P#+STur2AkH|me+kW+NX*N>K%jE8^r1CXi0M2)TbwTn zDZlIbv}f(%COpegafH2D%-YR0Cvlk@Mf@L-g54<^^%G^)_NaKhQ8Xenu;M`bZf%i8 z3PE|5oH{@@-z)tTu;JCSI4NbX%^uR-ZlV&r?2mHT8RazKJkVZM8xiS^H1W=H+Cd2$>$NS2 zJ+z&m#wnWGG%i0iqz%idJ^3{i4!TNM+^-7y5Mm!KEiIPUTTF~693(}=c@W3gIG_EJ zM|^xU#OleDo3d>6RJ@Z5pFT#i8Ef1JcTVb;b;U@7-BnKebgL6RVCuqgvy2r*v58Pmc{8_X{53Hs^^Pq4O9?@W*{PdF?3?b{1D6f5 zB8&`@S&j90K1+DernybbnUdp_ys#A6?^aN5?W#FI41aZ~V z%UjIG$Wf8gu^%Bd4sd=!MEb~Rr3BVu_%-#-Vud`p$m4BFIRE3977eK#G z-%7t?TMiT$}5@D7+V&W5+K8q&W?@0p)R1B1jR)hJ?IiAR+7)Kbm4h>|cym$`MFX5ka zK)ZI9CtN=s9rM z+`5qw?m672gm z;SGo z#75)1{y2_pa36k>i;<_2w()={OsBXB0KZ|5x^z^7KTKdrWb{e88t=Nf0dc?`kV33h zoJx}yq>Y!mR_xTe&4r$$*YfCax-I!UrsWR&gX=V$xb1t8O^nWl`tPSl}G{p&B;2jKSeb>=*G@>&~2}L zap*S{wY=6`G6Gt`ciA$c=tSD zly>zD+tNm^|6!m?ir^841T7C&g64UT*YTG}uq%evaQJ~QhQy~;BDx~)K*5Z#qY%&QHRRIp;u$@?f{}5f^ zP}U=p9hte#uos&9IKboNU>M)4u^A=A{4J2@SZ(fZ;CSMIaduRD0XZ71mDbuAbEdBj zrm2N2I~6p_1U`QrYKxlN1uQbdZ4)ygyY>iQV&;`fx;Z|qeGF%=i>8jya9n{sp((u276)T?NanPHqMuZTLv zVaoe}w5GnhH;o2WV;reJsEyM472P7`uhOPkQGzFmji;(Ld-xr<>U?=y^krp_KOdG| zvjsa#eHpy+Ga$6b)0zR_<9b8?;V?DpBgh*1EmSCV=f~55@nb!B9K2OU5limhAQs-4iwhno%vui3)(F#g>O_?3$jQ*3fb#S%;ajn>UAjUQ zIzcj~D)c68FbynZ-M-nYF&d7d_k{F@GJztP$Nf(fpCf$>?#upt-1z5ppnDR37ntR? zMEZddUYaRYUEm|;KtZ<9zCH`FVP~&bORn;y2_4tpblhQPZySvNvPfO9gTNmfg^61p zxZlf3uTz*5Ma^rzF_g&ob$k<4&(?`bgniLNI7aFs>uy(gh|*`9F%>f5THF9uj({51 zKxG#b_aZ`0r3X@d2!qc#p15Xp#u~Lo)9;r4SpjVGkYL7--&3M5S>-({FiF!N{jhIl zrL@nHam6c*W{`K%FT~klQjzsPyLle~UC*8Vj60tiAVOOmO+ioxKehi}i$Jw}vvHrb zLPRjD9Y@GmE3JUJ3QGo@L$5{sQh~tV6BjdDl}CVIStwz>Cr`!<=O1cg(9OpO8H6#h z%n&L?irqqs$)ZGhc;4PH&qh`KhkwbUjZ~{^HXgp8;>{->5z76JWm83DpkK{3mr4;a z?W9%nJAL$m^^ZN59F|l=KpPh_Waahm$5hXzl<-l{(%f|_=|ufTQAl6%CB1~#Y{DMM zY>0x@WVIn4J~}NUq*I$aVj$1y@XK@k!v@YjZ?1O#WI>f~E>jReV1~^v6ru%2Yoq}0 zdo8nR#!e%Chjl}#Dkt-<2ilTw{$7tDo>oAXMZmrcd@x=hC8dl2-S0EWuJ^eP=R02` zJkgCk2wrWXgA@}X0hktSPV$U9efPsMW2jW99G2JCJj#pxZ19;hW_cD|d7t@z!b!D8 zk;JSNm4gz2Iy^$?CUVo9<^jK>4Ga>(abs^C#~9fyo}}we;xia?W?0}POiTm}5_|uR zS>f8__NKF!U(c#Uh6XPY zCHOnC^K{H_=Kb-ftnQK3=sT%>x(xn|fTMUEd9&(5NM)xD6eGnsO+j|=nUKz4foo=+x$Vb_1XnOn(>&KG#`8L(s2ejSsEUB zn!UibSO!!ee zh<^SA=R^O5?tmw8s)(?T8F%PSuC1cd0kStGQVACRQ}cwWZ3Dol!br6NiwdeRaD}ZDkmG>CMoP zYg(>2t@mZ8X%Pt+D#_E8)myelIyhg__L{Mn@r7q^){P(y^wh!m*^1tX@6NYG6u zeD3N!oq7J+X#{)#dbMg`{_35U=F09Z2pABTLDO;B$kY!ic$QNsb{%N`FHFww4i@0FPN)h$Yg9Ry7RHwM8L5e@izW};pkT@E9FO&fP z(9U20@tAjwH{GE*cK^9;)1Ol2Ei3Qo!9z30N0l{Gn)KBHBVlP7r)Feo+2mWpx7X&B zA6X-p=H?8058e6q)s%Y*zh7MAYUu1YwN6@sWd9cDJe<#RzXs+>q0&HZ-(YQYi*HeyAG;gO4!cE_DHq8o9 zz^R_ot=wt8O5r9W=^yt~61cHkpc_?Anen@c5S^mV;(g9xu)AI5_rV~?pX%*L^AJ=J zn1%opKt2-r!`4H+!k+y2O!XEjF!p(SRg_KY7A6!}!`$CeE&rEIi?dcw|73(L2;Zvi zhd~!W)w%Z zX;Dq|1Afy0E%mog<4Pc(Zq625(`fw#&n*$o9y#|Kgw?Z!zpu(jAy@f&^bKOtj_RMR z*l7yDuDMywE}H++>uSPc3rF#}%A$;v-02~~`? zbNf%T6JL-M$f-N2m0)|NH`9H}fdI=F9=`n52$!hc_?zCtf?Pd&4m3y%$F`I6GjHHX zG;oOni2jymE&I0P%``vk#_A1bM3%Zq}ycVy9!~dIvvpX75 z@cL8LY_&D4v@I@Ed3Az;O<{Z9s7mtqAG5+8G1l7bw?IRZva@P;^(u0e){RM#Lu^Qf z$koJ`SRD+#!XfH?4pugQ1O5z0Wo=xA8SYU#^V114+14*5kmhs4KFf zU6_XjV1Ie@Y3>OUkFN+%ba+#}f5LAKeDyvD%=OF@#z-QrCUrkH`IaLXwC_uR zHtBQ6k&b^Dc+#oH9l^ta1qB1u1}f*eCmW;mXJCE2GcGOf$rcqz9zEtmLVE){R0P)= zE0QO!CzhN*K<(aBo8J4ieuR(9`#^neLE~vzJ!X=1%3JhY$W72toVp((bxGA)Gvus(8S`fVc7 zNuP)GAW@qwZ#Y}Vrfv?xk85bB&zY%2PFGn$3O2bO8%}=DDtu$Ea zhFz-P!@QHSEu)&&*YxL~M!Y8?RX&e*TMz*$u4rmT32BqK`Cy)<_Fx68A!t!$6r~`_ zi(gYwh#z2WnouY_i&4)c9iMtg=p`nAZMXyZ*!_}W{#a^=8A@2#N^E>Jp2U-E{B{z+ z^8b@hpqk+ze+}VL_LZ&1Jgx{CPO(#6QF@>p73-A}zd5r54k;D0Es4_OA;8XvN?u7Qmy!7%d(mx!P?^nVvP3{vd8=ha%m2Q7O@dhWPyu8Rk`NMOKL|!L-bj@FZsVS{G+X_ zJTa06Qdw|^f@Cw+$?hh(=7u!M(0P}T+JG<$CI&5fBUbJ4II7-j`$@zH>a4Qc;G5jT zskS?qe}5}_hzW`xbEAoBYaDOrknh_E%^H^gl(xTCk9gHB0E&eG!U5oxX83)hx2~f_ zp2aVh_ZoEsHj=(81dL82-?F?JK*={}=Uq6Zg0^U6NmE7$XcHF9Ag7txnG(9!eyHK` zKb!Tb4F`9+gB@yUB2x#-GIU^whM$JfIKM;Zg8=u})Eu_(f#=*c-4ktv>qhP0(j4*N zyztZZ%v4L(p)s>@A$xT?V?~La%Z^swzMuUoUxo=`k{+yn5uQ-qbQyl83T90g$W8X9 zsv_WND#Gzwi}eUSF<$a!Uq6a6;NMvb>tzh(NS*O5|CJ9fg!xuBhJB%F z2yR*`KDpr*q3h7D|LtEA8n)*Yx${vSrtgfJZ^Kh+^icL<$1-c6)kMNwI@I?~tne@M= ze?2ULtz3m&c9n$U{JgxWf_2hXhvN_U6>?T4m6Ps#7w6X<`NjMsH)|JH=dfh)1^$5+ zNw8dav*(i>kg+O?_jHV;TZfp!@P#HDoqV`_d1cZ2-GB86;UyEbCKS~SX;*?@jQRgD z_LgC7bzQe`sZ)x(l|peX?iSo#Qmn-(?hX~)DONNDC@#f{lj07+-Q8V-oP@i*pZ7WE z{jTc+e(WpRd+A(bjX5W4?B6Ng&v|#;rl}(ovH1IlF}07ZxgrR9O#*OX(KL)zc&RAt zet9}4QL}5Wo~(7tC{Za_aY*-+Sq#p}FPzXm?7z4=^T0$iJjIK}NJ$Y9(d>Nn8^nFn zAdQpxJp&%=Ay)E0he0lApNc@bCc>KE0O7uGfZ;boN5O=}zY{GQ+iyTZTpL_q)^qq) zp>Hc%H?9N2rS5_IHj4eNjiaWpL+9rZZ!r#}lSzU662 zzDe<7xa>*C-BAgrdEZPx4Nj3$gCqO3cf)%l*c-`GG1jb|PYD_dtF8}~t?>n{0U53> z$v)Sw5|TgCJsqEvr2Gf%lP(;78FDvh-EgL4(%op{9m@_FHN5fda&r}LhfNtK;1yB}K}ot@S*`79%^oi>zUIQv5-zZ%axzgg8D zNR9{fhq*3mPD`kR7~6l?axGhw)q1A64AymrV0xV4K#7To+n%-s@z^isy#(dN2>ou) zij+8u>E7r6Wi8$h7R=>JP%c#IEt=;{|1nfTk+#gTB~rWXnpmF{4Fn) zEv~T=tZo5r6~o@Ps`iJ*xTK=drsppNn) zY;q~dmZAUsm4DdI>K2&Hlz7qO_fVTuD1)ua!aatoeNS9eWlvqVd(~Wyf^x_sB6Er;{^ z<5p9&`iwSu=pG-DE%q<^@s)*YFNL~Fv`H8`MTsMv9B#>O8Y8>m?JYfa(j>bZhKV=a}? zA{tKLq?qRHF%T9QY!}!S@;sxO&(5?Sruj~TF}f1F*gQTOVE6jLuT$%juuUL8`X1f@ z$PmnV`N)1p)oIfkHl|K8QT5d@5V16a>6jsZT7rtho| zj27}1%Q0dDdmFyQG$;AUsP0wKr~93) zxLzSvuQEL0V1E|#Pf()q-PnsSee^tn+|d#u75_5@!t&;G(gR^ji};6@C}~Kma3!Qz zU3(_x=aOHIY9yc?hnR&ztCD@!<}GCqzuvW$O+lT-0KZ zBQrA^UtyyOMhR{6F}Ej5ckm2OTjKfLDey|JXICPb6+|)wh7zAywd65@K4|N)(;LrU zJ<+9!X!eD8hMP+BF3Su=q>&5uY?$#=7U%{HFg;G2N*XY~n1P&IhMVmryNBgr7Hg@8^xe{XBwuKM$N?{QvrS1)r6Xj5FrFE&XC$hwCQ=K z?LXMR4N1S%eIlz+b1`?o)Hr)c^-_mx!D>rt!xvMU+xKDD$s?~+ZVZ>21DEjdG$cV3 z59$-E43LKJ5RB)|S2OCO(-AX4iz64d4m3`c+ZU?!(0*FYjg<<(-q4bOg{t1uSftv zWtq#-_9e&!KUB9{TbP|(^_zbfC@vbiBO&5ubr5SU{jQ7I1W+c|Y3AL)9Iu^B*%Q$V zr9Df*vm*WJU)J}3yR*Rdg8j67(bK6;AsSPa?y!l(@EB+I612y3v(9ndVTy5KWFPg* ztUl|L2p5hkfx3kZ&L4Hyp|0CSJ}rG^UM?(sIhm#~|3P37$9j;UXpm(0lKVI02|MWN z*CzSgMCgD>MPs>%FsZxhiEJ%^ssfl$b{bjvObM1tZ$1N!5x06^gw40N&=oRHsSlZ- z;Rd2I=+4Knstz759e<-fUMptBE#BpCRPtXY7)5^6T?A{bpnmD9@)9ohsA%zZgpMc< zt0T~Q>W5aaX^!Id_56)`(0b^Qlfz{%GwEDiz<6mR8<&S0%jxEz=I%7dkw&>DXDT(0 z!xU+jbH@jtZgAAl*D=%+x8C=0g}`EbGo?w7p#hz7fddxpcFJGcc zN|a2ora4-UfyY(T0`$@=wsVPNC-&4ugjGjCbf zpyhU&_QQoB=D=}?rS&H4Sb(_APMpd%f;EXKO29{U*uyDE;ZCUm{ighx+4WgyRSC(- zEHv<}a4fshvGVkZC7TTn@wa8sJ}FhO=N$K~|iwH&>SW#LNeJuTF#`y^@ZZ)zSGU>=-p z9@=gmzBWJMMfCD1JJ=M^=f_sOdAx5DL{y`w&l4?h)!LuxsjaTCV2@uuacN{zISE?Ko6cP& zcYGj{uDFygQnZDKir)m9NJ)xA=u=5`HAjzXGRAjC3t|bCDnzg=MBh{Z%HP1oGQfmN zUz^@jkH~MFbAzoIF8hJ;PsN&}gc=>2O9<S) z?9tX8P{$q6_u2$&xCa)10x@RTk4iDHOB=X-94NIMsLw6PJ?aX#8d~g{31^zfb(`sC znpt&QcxN(^-lZc?rAPmrf!svp78VQXNx^63j{8@bdi;m6UoePuWuJ-ZhVlJo zbrhxu3MUAT`yQN1@^pp$W5U}crazp3JEwnb`1{z=fLNmz01fz;Js)g)sUMM={2irSMmAguRa% zf-4YHVD~7B=6r?r)(Sf@wWg+HEu3GIcJKr`)&yAA1VYBk;BTqTG&__}6XXzgP0%R6 zP9C>xr;eF0ijh%Vk&IZ8?O#EAW`{&$_ejd_iJ9HAKUi{C?uyRm_)G=oH>mo9HGA$o z07t+c4&U0G?%F5^&~yR&@96nzlJ+P2F$P{g2LQz2zhBcszrVZr$_RX9j9e?!*5RsD zrK5#bg_7e^nfZ(hZ_+_wrTYi_REGO-Y?5WZ=hVvL6=_`htO+xJF8a{vx1f|c=~lF; z`bLrK2gr{_Ucep#{Tf#=J^D}MM5&#&jUN3Bwze{6NS^F})_4$X>u4&!zyP{reriZg zZTLpgkjm7MCcu!eneefwv^nAzYS^6?EnD<@;7-}T{WIE7A`7rISqVc7g>=(B>3 zLFS$o*q9cw$98~39O=k2Uf(lgs;zg`#k#v=Ap6W`>Rf}TG14G@DC+J@;t_OA4qoQ% zAcC!uSbuRG6UtNFF9SZuAohNb_HyGW3F=8Fs07Wd)qA+EUc2P4vcz4l1r*RlyHw** z;=bW)JVOeudE}h$H%deB_F-TVJtV{;^7~$!quV|}L_R5OvX0JD!XR2VDCGh@2$u(^ zNvkb%k8*xrUL#`&-f5ZnWp6)S=A$xQ;UcO7@nEJfcbJ)?XjS(8@y!*pt%jYZyZ--g zYc9{9n_>JJq7D3|gUv&3fn%M6`6b+M`RAo9Dx=tRmbtptZ`XIr8R8VYPA@V{c7hC0 zcLi!(vx62|rfEz_F~vODO~^xO=jy$3XRFl@CT(vW+kpda@nPToGY&mwXZ`It;g-|W zzE)9RvaskxWmO`VxVheUSG112|H8}F4<)eMTH&MTW`h34NG5pE`WWCmSB71BDIeb_2XhV^f}zWHAKEGH=@^grkk&$I|XK zHWUKu7(rWwp4RGvC7a8ZM1I0Igwm@!C3j(I<24IPd1r^*yLU+6x-FHSsA>|5=>qks z02stgUXfu&!M0n;9mB1>@N;GNMG4XstJ?63Vhd?K!kTIsPmu!bXP2Fg8>Fp|8Y64a zx<^bP&IG}4^~md#j4PMZV5h_x2&FnPId1%bI2wdFD62VVvagn$30m^xkN>sY{OF@A zEk~-S!et)$N4U@uqgS9@tE@af>VUX*5hZH= z4mGl+fN4Ad+QI`rOq(~ln=`+{Nn^Xba_-GkjLmZ;tvAUt4HsUc$Pt@i*@(CnH#z3H zF&MVi7=^jm^Lh)Ms{XRon7*9TGzofoGaoXq)nKIl@xpylIU=TGR_>8XoLgZrx$LW* z?}iLZ^%+;W8WHPqr>qSLyB(LvA6-v}&7J&;yUvJjW#~g(5Pe3d;?U4Q?r2vsxueTo zEPjznh>E6qL%%ly{dh%XF!f&thBhZ%1-w7huy*fc>xsXE%NZE zsNicr>(2;BA;P%joh%D-SlKMA2zF_ms$U>25vR|*!n%;ZU&v*ZFGj}#Wgp87yZBK7 zIsJFaSSny>w85=jLl^4tG0sbQL2zQH>(V*yozWoLpJ`gpER#+9_>`IFO$2`C8;(Lj zY>3&7;>Q}?jBhyP@wE2)3`F+oRF?9_4|>|1>0hNNZstP2t53$J1Ltg>Y=jHAUXvf! zd@G91<}XEdK&c^4A2qaoNe=Edo+aI++9sh)K1t(Na2}dz5}Ie zn?EYwNhJ}&eCQ^K#l5yT^n>$A*Y|e7kM_v6f5|6AK9?MQjanpB9!*YbKNTPy}O zBOBuiNpys*kN9NCvr}p+Bh+%rv)Qi@l*r)L_PUc*xerPuv7edCHlldhLg%cu=nz>d}ru_A#q=wbx(E zs2)m3gv(8h9VT@E4UrXsdrM^itNQ}}C&zOmzkK>w7{O*9`bT;hrtp`&ZM=3`6)MEFP3#;2`dA;k7pvf|o5nk*4fFLJ?0Oumc16LKdFmQnaICJLZ zrB#xlgFe8$2#}t~@wv$_hp@F6VKs`%X25s3fk8>r&+W!tx(Y&>;UgrgX!^%V%6Riv zSRZ*Cmr}FJVhfQ$fdM($r#|0efp?-o^r6;|NO}&dXp!Dc`-rk+2N-jmmICA3YPPrD zOie05zt0>vvcwn-)_sgtPLq2xAa~F&3E(%L{&W^Lx8h4tl^HW${LZhe(J#a@dzc~C z;00lFa_#CdLZ=9R^b+dWyv^^Px(ufJ+CB+EtBN9`lZa90GxKFJe8h%^^4e_gU7_96 zL(kH@PSuF@aITI?l{vQ-qsX&hN4=@CsK!85J3=Yp{WROy`Zewjj1tYMXu}Syt*wGY zC09lpJi(g1A>9a24qCj-^g7i#!($DM+!$nQwMNeH%E0#F$;h#?ve+apg;Cj!ggO1; zW1^)#eZ+La(RfOI2$Njc(A5pNgEPPMORj4WFRou0J%Ze&4_`Q>i2@)0Ay7a&(=hKq zYaqJ3^>gnp0yTTv)b4eUONgQLM>+{xG_JcP_dl~l4n{3beynQ)sb$L=UvJA{ zDDiD}b!mh}t`4Uo?m-yWU2zJI7F|_pIx=XRB}Vxc%|{#7iq&mEabGQB&mx5HSr8F` zNe$#Ej4z22{Q2Zp4GZFNMD^iE%$}erk{g`)>Cy^P{8!3#V&Kgg%Wfr9m1J%aqeXsQ zRV~9QUWnMdnigQ*>Vu`(3Wl+sM^cd8$vC7}sqVLAGm(+EJj7ot)0xr=`1EVHGM z#b{lJqVahfY;0i=Vw9su7EY4}%`MR(IscfaLFYlQEsOcyX5aDNffV~TvO)vVz1Q@2i5m{kf(}vC7xmz z4eG^4-!UmO%m}+P{xcLoDnP18W+|<&>`QK}e1MiI_CwFH{j7K$a{AKmiBwy-;XL`@ zIH}p1wLDS0)y?E9B16R6UdMEb==lRWt#1Hj*H!LRct8dsBvzF|ipOrJWHd z?KBt17@8>a^vOL=H~+=ygW7Be{wZ7>)^5>>d__T$shB)Tk!h%Zj&C&qP_LFsr5;qC z>W8=0P%>g#JT`miHBb~K0g#jAH?vsfK~(k6{GO4i7Q_xDM-vW>Fw8uW4Cf;D zw5%Z5<<8_%buMG=GJ?m##Z*ZMVKrRz;0%LP%-tOtb9oz^+Jq*QJ-AKZ&!kHE zTJ*TlyPl~O(w(RyQ|T4TxcA`f>5V^+r86Q!Za^Cn;-@RVH^3VH+XF^n$aZ>RSFh4|<{ zaCcww+C6$9KKu{vK_4~h?}JFcA22!`1m~Vhu*DEBE(t(DSA0_cjRIz8@F2 zga#$+2%nJsUG1p|6!hF#Fw|*}b2TR*w~siz^njE(kAZbJW!`i@j{&^T7TLLpark65 z8uXDpG0ueO)Y(yH9{WTku+AWCu$o$_e2cUeuJWW<5?DLhq57C=b$!LY?({hGB%A_f ze{y&eZkGZ|chM3Kmz3H_B3Vbhqr?@Chx(pIcib@zv=uOM<1ITPS_bb@2mzc+OBdBX zIHgvN|26vr4mX0ajJtC7J2-}%X>QFF69qqnqWzAv#mTlt#E4LRn654;3QOM$oz%WW ziV(`W%&TK`b5#S*UB`~KTQKr{>`Y})*TE9L_UBs>93PcQN_;^yKKp``@zee-d-o06 ze;Dv`(rNAzk?SYZisv( z>r7;}X9N$YuzE|piBO#LQe!SwJ6v#*mGTduO0Lw3)aDrD2avSJ>HG$-bvpuwb4N!m^rszRBnss5kq`N4{s5J$K#Od5+ zH`;TKu2j?S!x{z<+Q#>b*t{v`c)15 zAwy=-#NN#vl9A#nFBJoT{{wtQe9d_1Z<;7&=EHJ~Gz#!hYW6AR$jtiU*4vJsXMHzE zf@iE}LbmYI@A1IC>@Mov53jj<^4VM;5N!hWVwCMK?ssemFn4XT>>P`h05B!~ZhMa} zN1L$x#(qg#?X>&Q{TS_(U6wXsps4iH1J5ZmYdi*ONm39^;F)kQ`3PSnm0$!}*Zm9+ zESI3Gh0ctq8H=jAW%jkt5iv=^P%XmS2bdc!VM}vd^8|e~zS-g*+c1RP8zqpTQ)eh1 ztelh)B^Pcj=nCDa!min-=GLsO83Y%vL!{`Yq?wedZ`fu{)YG=EWpSNj+@ve08Ip?>Q+RF2-K1OSJC>9 zYV|#a{i|PnkDve7eTYY)IEWAJVsp;FDdA!xKDh_TSYe2}AlBUm)xq5_ul#aemV$L9 zyW1GV_piYi($cvXjo#w0++EyN2gr~^AqYY@{}{Ql9if79-@k}*#t?;GKbs!MCpLcx z<!`IY zk(Lk?19!!*vMkUkK2k6H%;yQ6ro5x`Hq*GsoDvh|O4a|gM z;KPxgj|ioye|SgNzOZTmvrI#9>St*Y!`O`tSCFJm{+BW-=cs{kW zpZsiKxh$NzjmPdYSGGCV)HjP27|K+ILt%3r;urk(v-n$TF@D+R)Gm?jLKXg%N=yiz zHSvYNlt6;XXfh*8C!~U3}r!g%z^j=XnEi{0itCIAHs^Lpj;w6)IoPu6D{XiG6 zZ_b-jyiGRbX@Nt&5NB{Apg`Aw84hhBjHY>Ibj8+rEpZDZavS-~X?1GzAwqK-_GQtE znYMJECf?u&F;l{gp;nRoS3_?Ah@z4W&&00QK>#KJ5t3IrsYwdkfq{pvS)== zYeNHma>=aHlTDFa^X^H)ehqG!w0T!W*!$L|xV!yN3In@jGe$h1U`Y)(^fqeZKzJ6KTl17&F zzffP##yeaeHaWhNV}iD;N(UXo5y#pX&zz}&aw%~cA_|BEK++|Bm&gExI6t`%y?KfF7 z_p0oPFiOhUp2Nx9WbL_b`=d_1m51XG-0L}4-S9LR>H}ij>t1D_sZCdztJ(u8E1KY5 z$(-DJ@45E4U+q=(9sMjT-rta$#7lmj;dKt#Rg=3x zD`=^S2v^SEF&r`I|DaTT`{B`WE4)zswksvXSi#Nh1{58B3X~g90Gam2_1thDD&C6- z!@s#lCG)5M3uqxqyN9Zzdl*5K^xq8cKRU!%9zsXzGy1UMbGP`j9f+z^3a1c+aACk! zpW@;;!+Xi|x^M5VfFU_JaCjuYC)1Tm=iKD*jRy?(G;jBOhXvwCAIm6;s1dPCMnTuq z5_4SiUhQXBZJWwMN_^@BuYta$K~yKJWcLcl@D^`Sg&zLLX^k)VU-(_LU|~jU1^d}~ zh-u6G;SGgk1(%L{nXuTaw!U41r!<>=frl#v%l2w@a=-XJHUg#@y#tMDr%eZM?ve9RUY?~OLTD! z2fh^^U9nh!1qkP#LDrWKi<$Suc{rRb4V-ep7pSd1334dutVJH&%sgzxhBMR9Wql^+ z0NXO+p%GFak>C~IVLuNkugr#%4~(`T<+|9S^fN!x4A-{PCH}n(LB+v|#}b)u412l1 z7~!OPz(c464be;7!kopRIy32nX(eHE%%mViD0~xFz?2#u$XiKptWI7k6Y3$A1K}-i z*LR`PwO}(kv3K2Nh*seU&6rWkCY>4T)ep-uBai|Mh-fSR+*@u{*KnMoZ1PWx5 z464n7j4DJFObX(8QYv zJUyzJzWwl`-LS&3!ZyCRmUS;%JIPO1&&a(nM#8ByY|=`;tetfrP9k+xsnv9mBQkYh zvVZiIrF?2Pn|qe=RCo-PI*DcWDjIr`BVwzDZ~f5FAp4&i*d}L*KQKavFH~cv+@D=- z@|PxP(3R9i;W;9A8!MWvFL2r$Y|;^FJj-h#zrt*WCCiZL=puFR6b<&Dm6(ciol#K6 z`YT7eYP6OoD8p)!x`ttIQib91K2Iu%H)v@IeLPHwD*8vzf0jOcQuM7}_D3AH$N%G# zA13!KFba6}vbBG6LK{-SjAb_=hrkVe>6gsMFUWfk-uTZMH|z|G z(8Ol=8)DAJ%vq;wr7bypo^Fz05Ic_zA$PB?b(U0XuL;c^uqWter1n1SQ+l!)(41iz z&ro`3ZfcSiubGxO#ruZVuprpL+}b4Gp^emrWeqRiXn}Vo;#NHIgv5G9f2`}OY(~bt zVF2!J%yZMPHEkOaCl;72Lr?jz=%+@cD#}6WtTl%l`qXCZit)mGJ{-`ZyeVJ)&)m^YJ#qH)fyrgf9|mFm z<;`b~tMa+WU;QMvq;m^O*4&*sw6AvCyUOOWy)22O(8F_*qo4fQQ=!yR5zo7@*tGHL z=BoM$8CYD0Y(un9v#Qm1Gdstx``jhIqRq9AcIxha0EdOHs|ftOduh>Nf&!0t25HUc ztjq8IKtTFEA6-2SzI)Fjm*=GBx`ITd>9gjVJ*U^@Gb9AaR**F7DEqv9gt!+z1pa=e zM&8++k#|l<9K_ez*{y@}JqBGdN`{DRVn zV7UGRF2#tN;5U6-)H@TZ7Y(XfDSFO^2*=)a>p z3Gn^SMZv=p)K<%r>FJ+eFv{FFKI?xA0G3z+i~N?V4p5hv1^TL`?z zYeIni{s6;L+TOtJk1W&l9R)&cID64m^!b;)E#X!7AY1D_L^240H>ht>O!7 zX3Ly6FG_z}upaf;d&Ce+hP4>v>IvUc6eju|%nK;pTwwP)hXQQqruH86(PXZ;5Oxd$ z&VYa?u7<23vb^i|lmBTT2|QW$O3$kJsaflxp_5-grPw_%|YoabEwNcw4~Ww$zrTP^GPm`ebDnyEZ#=u~z2wajB0X@~ZtAbmnt?N$-&R z6)VaI^m7yLaY@P(&nh`z7dT-ifPqOoDfQogy&3P^U({4b1{flS3$g3}h`0}q_9!8O zqls1U;Hb9D;C*nE(X>)k`~S{;#UyI5fa-S1vfndG^6T1|G2WCi7DvLfPKo_D$y6f_ zWO7`g(|K4PR`JSpHE9xnRK_Ah!q0&)&LZU18l2Yo&-o)78tE8rg>9W!?l-NG_X2^&^$i zSumxh>)J$IhitzY>{VkHlp;=iLUdds?!|c-#--{Qi#<8Ql%yVZd>=K4X0O7L2D1Hv zi0p6yxLp{i*5>yUfo z866~b_?`~0k-=K|J6AoF9pUtvsZ|cTmMox#$H7t_ZyJDI&^jIq++qAF9uLx5jbpD= zZ0ln)yB!+p$&94rXwHzf)$)Jf@G{aPkg8&_CVsu^QWWsUnQ~-QiygE_Ajc@T0Vj0V zVeY88-eIALSOtRZCciM-OffRNF#_&*%=4KQ*3Za5x-97i&`Xe4hum98tcQrbU#-F! zbQ?GGV~E>N$u?CmA!4FF4HzIetFk`GjrHKa;s_3tBAff{T6ofPFGo`lJ^a1Sf`|~9 zbB&p~E3HY`>AH3DZx>X!cU&-t`Nq^7O^qf~gig*7;NM3`;n4e^;OT$(HoTk%>BtC! zL|pw7Ciw67WL=>AOW*G!4d!Vf)0X&WjViY!)V{CEZ+o_q`Pz>JoWBcQPCmY1&mF%z z+$TaHIJ`yx06nKoE25rZ5+w#2RaXv=5TC6fQ}|-vVVxr|_8{7ftlQj$xKlgH z`%E|5ZNlGU1MW*i(0oK8X{oN0u#vTmoQt`BI#8#V$S#_HwqpsMFsHcn+=Foc{R0~I zIPK+!&M77B4r+Bh)-(GXU}78YO1`HXKQ7CYS9!dbw|*S1aD;o*nHUHDUu@3W&pi=i$>QQZfe11ACI{)y`9tt z7{pG(n+E{bg2X|8`>YVulyQOQ< zS|-3rxByS;kV+Grgs137nDN#vrCOOQtkvCxh1k#PbrE%Jx7&}$Vo0tDMvBVj_9zW@ zk|wT>D8|)oTBj9YOxIF*MG!7oZJ-b?i`tAk>3x<+U}im!K(pEGM0cR4unwR;DKP1 z-R|t_Uq8^azBrn8;Y?Q7O8jcLGw92kVzn$gZTpV?#A9K7mj4Lhs#u4;UyMwZu%8Zz{P+iA;h7#b$! zhH?$(c1}V7%$~^B3370`fSawm5W)4cq@7KbH1?XX-TBtqy;$}RMlk9pdIwYkX7$#Qcp$=4)b7d)P#8BWq#dOc{(qg-X4a{?cB%)=aJX;QMCVXnB0$-dEi*-=zM8m4b$>2{yNf} z9OOp&Lz`b`Mh@x+Nvaj$Mm(usNz*8vj zW1I8`Z+XjC(aUaej)Sg+#O4W>jXhTTUE&pE-fkVQR{n(8pQ(`N(6wtE>-N|mRaYy- zjtJ^+*GbJ62clY-dIT9nwi?KX=x_n`4h5GS+0+V#JC-IE=KWyr!7D=A=DS7{2%ASB zY~N>tg*^^gC?fJB_e<}ARC2uDIi6d`EpELC_{eD-?6N1_P;L~$luzNz{L8>0%r zr>|!BM+oMpe%Y6{pXyIrPXBT(2{*RhPdf)~CMo`SR$>+&v!bM7l&=_QkYCrnaZbS1 zvs&q8$@0995MQ<^gUw=YNBd)XyCu;#`L=48pRd2ZgFR8X{%L+ZDl}i$#nxWBwTXj= zWj;1sVSvL_NMCwfxCOjY-S|nlHK`yNW8in|`MLtM>;=iPD{nuO$=rNkP-T!4B2E!4 zO^J0z%&+KPj0f8*6s0{9=x+!)BgHwwyfVnRIaGh6-Y#;+lKEEH_n5!aD4B+!dMi$T znnQaGT`4;(>$V#GCf8UtV*9I)@Vj3{Gt2;m1^b7?-)J92S*GiI?*s8|^-)NlK*@CeuqP!}us8*#AN_jixsc%<-7_KIcNigG_BvW4Un_Y4X!kBj^MaQH zq~%h!X+H_n7`^_qZv4pXh9Wn?Xp}uj^HIQ6cIHGlu2bP_wDA)k+jnjx&%^aTie?7X zHI~OGuH(@>dHb|a0l-k^!N1ql&oT;;;?$yRkbGA8w0*aSB0Onq&9Y?Kb2u#DVVixj zNbP>$g7M%(7a`bDY&7ot{$SGNdgbBL4Lh390*HV|at5OD{W4yG*oM`&coyre$eAjnl<*xhMK|c6%M`j$UJ+ z*4D)S7THUbuPcI_V}hB=d#^UMFYP#OBY>HW%{>flUr8Z}mgS1xity2aPv#D{OGDCP#dA7XIR*CW5;gBLhe*l*+6$l07DugPf(alD7@N3Y-YfbaTpb>=2j#)aJ$G>) zVr;%X(BH&>&+|CFI%JSitsJ48KQ@)@p!+B*;Ar+-(U1PFq50K`pR&t}J^9Ii@Lhd7 zDPYi-;D*M@kAiIB!ck+UgkaeqOK9k&h3kt2>XPNq^1O(vJ$Cxm0XI@T&84T{jpeCk zy;m5uzO1KaE2$3vV)1Kk*L7K!Tb1WC3TdUT%Hf&m8?V4J!%XL%QnUv>1IAT~ObC04 zmY%Tfwm*&bopjw1SaEa#MJT(F!=R*q$rvGWoc+TRr$FV3^UdcH`t3Ki_N=rk>?e?A zifG>`kLo!b>**L&?#b^QfgZe2JXv?N+`gv#E)C_W>=jYex0RCJ{aQT|D zNt3vh6&sgHQ9C^V^2+;o&a+5)DLwABZHo|n2)OS!|_W$B!k)=RPdk{$lwXeEM`b7tU* zlr}wXi)WnDCZxuLpl(b&w=`6UreBw};?`f)SNM^~RaFf2frp2bIV`GihTcjN%GQ&= zzNTUEF|h8%R$_{QxxVmsqmlmL)1O#V74r{n&SM)jbw)|Xl)xijpoPH3k-UGFgWpXy ziI!H)9{y^*(xmr}u*+-DD7_7FTF>ZNa|v455Cec$)6Xy?*~!mvDA0{xJ8+ zn{&H)nhoEtw@RG`{lbOoyEhM|(T!53RjRkm7Hp?w^9!dIR&O6pX;y}CS$CgWzjDm+ zjeeAn+^spdr}pk&gFBAgire-vgyX}+-?}@6Mlid5Dkx%8H}SyLmxc@g)(PjkN>}`R zM;ahW8_?Bw_Ro)A+J|}fZ4vLqz0|E+eBtlLwXnyE0*UfQWHvyItDAk_JKkmCgNL_o zme=)p?=o!{$)ZuVJ4(EZ=Wvs-j~*U<#Joxey+sF<%ik^B@%Q!iGSVz>lY)6z@JE-0 zNrIl)lMA&5yRklRizWrT9V_~w%?i;yUoFmg?R0%Xb=@IZLfqhf-RL@62XIutrpcS= z?e3ySau0vr$9CmQHiEr2xu3)@N_Op=lbD#eShZPAw&s!TAw_%J@_G1c^#PSAt<6io z;mQZU2*?iIwaIeFfPO<#YmCBaQnm+=#on8X)KB#!eo2UjxiPfRsYbUG59YTjDeHrX z50~)(9ZEtO6tURy41Nwv!FSRS1`e7P-d_gjG=0zZeVJZM zqRh_DUYBw}9Fd#`qi<|+=Z%exIlPI0ZnottN|6xyE%jLYjpguvg{sl4NbPqoX6>M? zntC&Ho5V8w+XQRAz2r&sBr%!l{gE!W`U%jEfS9NR!s19QD^ z0~!hsRS{jDT#YSA+*0*A`>J@}VB$=R%(5hT9rUIOQ}&o!uvloeOFC}(EC-Dyd`_RG z2>ui=70ci1cyU4SN2yMSy?exD+%UPvX!z}^8e0=}p0834zW`6-cTe zj1uI!(z7iyj}=8R(%ak1OmiKt_3cRCD}Kp-KJO+SUga5lrc8>>lAe1{V1aM^yyl|z zVSsYV0nD5FHv@Tj@?ug3#w?#d;eg>ayYMb@lO)j5Hp1(#W|2Ge7B-&?+oxGR$DC5^ z9Oqtvd4t0YkNws+Qk{wTN-VF+{>NilIq5tsO3q6VA7OKEb|IQ&`1llHAJtU&R8kJ) z<$8$ZHBt#*oot}|9aO)0J6&&v##hqKzVN>4szUe(BiYG5K?hqH0I)`=hk<$gilc0! zlGwWb=ko|5jw>D`T_idHd}-`BG}{i_lO+{Kgs|zthrUUxSY`e@bUA95s@#1oMG1!K>Q%h$-?%fsovIoYW1HwSz8)FU|%s zArbS(Rfo%v$o)GbFWA80E~I_n#?$Lg7(R)n-KQl! zFRg?{M~X%z(PLRvgOGWExxu&nXR*pQf^-H6t2IA4U}6m7gsc6TH~XOd9$yYM6`Lh8 zZ@QSlLGF$LUr!(9d640qeRkGoy8Lx9>ezum8-z9QE~Xi#8$3-hnYuK(_RBoLDQ?zE z^ip*778+K9A11dW(r>ZGO`m)ga=gXnC5Rb`4*Q%ns(OtD9>Gkax+GD~XQ5r4gElfk zzR-FiI|R~pT&tYbUz`{vEidgKkoO-Ro)2DM+xfOWBh5mfq@HJ+ zkZ`kD^>Z=>8gG1VlOUj+rx4Oi9T1V{xto~I!)_N6R_-_x!~agBAcF9P+Soh zo7X%_ZPJ%BF&R?uwuSjLaqYYK5||jjc#T}husX^1x=)_G1cPMGZ^B2ul&@0(cTUPg z-?2Oi9*(?@i)y@|TFu_$RS|6bHfdIDpJo*|`h!-W8;Yxjm+2L6>Y2w(z&xMGwh(lb z&1)K^Hsy;AdBq%|$9S37>vuF6JB06#>2q><`UiOMHMGJg_R0)S1d%MLvqb5VO&+P? zC?Rdd-k8}^A-Y8H9KB5viN@vyAN%1;fkVwR@1>t1N+|*3cGD-*p@+JlPfLw;@r!g1 zepvf4=YT^^E*&vUhPMRZifel@-Rc|!spkp}_7UFmAh1nw0j6cMV;1!Ax`%SC$ur14P$Mpxb+5rZN zqDtf^{TAj1@xsprNi>rZ5`?N0CA0`khPSUD%;7{)9H+;!+P@E(qZ2_@rPGE30ejt; zVcKhUXjt?^#cLd=rTpL6wu{WymCa_UK155vxtI< zvomlr!#pQX-idbeR4d8^tJvESrT?F?t!!Kc3jMVr%tu`eh2jqR8ip*J`(xE1;rfRg zy3SwyV$5)~{5iv{)E{RxGu&VNGx-8CEo%C6V^LnW*C|uZt`lA2VWW{6oYR_lR8#Ti zk@g%=UeOL^Kk9Zcja7XfmsMd{t2*E6$>$#!Up~Lv+B8abzaErn0DA}&ZJer z7e(DR`}A-7DZgGg$m~#1dnV9bK?^{2S6TK^r^d7mHxEiiU+q-?cPKHk40v;fpZ&*J z+m^-sTdFbTdG^Ki={jmLMnf}ZSk%A7l`Y{9aGtH28EP1q{FR~Oa#+XA*-f#1Z|I79_TB}&Z+n&5R15*VV~tTQ#sxGc1- z!ap2Ib^ll?u)y(!)FR%6-W`{?7HRjS&D__#W$~6nTOOG>Eakc?vL!AX=-M>x|3};! z)4G;2o$EWt=Rc>6YyR?O^R*k6={B$}+$|g! zu{D>Z=5IOn+|DVu{e_yFf8!J@A8SABAnPz-U3eyM@6^e=mL{#?JGZ6jxtQJeUI91N$DC;c@%j(HNW?)snzulcaA?VY*?qbtN&u|gt94Y%T_9Hz5Wmq zb#Em;1jV{eucX21Lf7J~=2YH>I zONY$^cD1~eTKSuG)Af0vWErSkT3lL?w`j85w+D|uOSoBkZ7=ltodMP!8*)j_40!X@ t`+K#OHx9~y%IGxynR38Mk@$b?djDI4w;d0W1dbXpc)I$ztaD0e0sv&l5sm-= diff --git a/docs/images/wordpress-lang.png b/docs/images/wordpress-lang.png deleted file mode 100644 index f0bd864ef0a72930a28dd8e58685437ecbd947b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30149 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 diff --git a/docs/images/wordpress-welcome.png b/docs/images/wordpress-welcome.png deleted file mode 100644 index c9ba20368c55c34b18cc6a37ed1e5d3d37aa1a60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 62063 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

O2I>c(i=A{R+)A=E;y zpXcl`alZ+?j#-#-XQ!gJdvo>Yjp0t~wVJ_}s}o%39R2>q&TEfbm}61*AjEMnmv0x_ zP6TvQaXmhJrOmyrhJOa~4h7)vPTnHB`i7V!^MJi6>Nsa?29<-)XlgQ+Y|B7BKy>>O zmu~bTso3{Ct=59gPXQxhoJHF4YL_tjq)qp>i5GgnZx0xCr2wqBW-)pS9GK+ z1Eh>+teqa*zK_a^qM?%M2be2VQTil8?QzeRd!*la0ng9YE_NPY^B*nRc<(WHM((tm zt5nNBf45^2bxN{cr)5_qoknZsBB!fD^98HgQYi-`8>z;vfp8c@MyC0-ll%phZ3T8& z$j=7&g%R5UQRaSj+XJYpLgboDmX~_OcQU0wtaO;bPPrLGukHPm-QjZW>?kAAu!fx} zmw8cf?ZKLOS4)@!~E>cU5gd{Ut5PDXt_x?WadTzuys%nd*naq6^&6v01u>VMjI}^-s7|0OL&jT~Znf3X&w}}sXLhVe@#see&s5!Wi9T)7eN!Q4Z zLOC!_@GPH1tp~^-{I-I%fa*Uu`D=d9>WxegtKi*}^%|=+iHdTKr7qT`omLUPVt8KN2ix9pItb0>rFS_9#B#xriE1P<`X37=kl2Z^9 zOi+DFpQ<9j^?vclR_l`k_mSQZvX2MFqO8C#RCaNratTM*4x4bz@moHOD9|7cdpE|h z83Seb7O5nk)zRBN(?UdPwlV2D2okJsThm$cu{wWO6^B(%y5VnPs(6(eJg)dZYF_L0 z9_)NILWhDhg12qPDd+{92u`C!j!UjbB&HWSB!RWhMSnta1{OXh7vS)D9dB7?$)HK1 zD$W2b3N+NTz1Lrxo_lgVHySq$A|(>SXQ5BCh&nF$om4u?Eq&pDKyJcw#O5NqCEQHZ z7KKL9{rezQ{7z`d3EQ6rY7Xn$PgLx-U&TXMo%ExQQq0E6QHTZR;tGA9BkhX=nM(j`=qV>9~6gf>wWtocaa{+*Bm%S5pgC0D5VXX*H&>zC^K_4$Jb#6h(!_~hRnMC z2Zb!s%I$NXONb#)M1p5EOLam~rh|INfy~d#Y9j3O+mYcz(8GZ45t^MMWZoZD0g|LW zdu1-uw`Y9*NFaaxp$Pk3`JZN{#Po-_6F5r0MdP=NPjdOYlI$KhOvK~-zZbn9ST0cQ zVh@LN&;ftO_z3V)w=9}~5|G3iuW(uSBJgTHTApX(lTJu>su3zu#nCnkQ5>2?S5fyjm z2hmKSdLBH#LaMhfZW_KhRFth4+N2Igsk&7|x*c4vDA|?6`!UIA%a2q2X=Z9c`XY1F z{o|Q#-4@AMC($zn6fDMCjoli7MX1|enQRh?fDbV!ZIsn?&uWoh{BVtZtkT~sQWRtV zt>ux%ynh_YbO+6fNr~YZK3Pi6+r>v$Sb{b~GnwwVM{)k#h{BD5Y^iu#k8w#nI>06%WWTO8WrRlY@#&M2^}hc? zU&vbg*FMSiBU+(nLOd5#2^fl@Fr)xdvX0jt-QraY;y&3)OJc#1@$T~ZfhNu4Yb`&| zu_t~**KHf^Jr+Nymeyg@CkdzD^F>%2EiC)GZfpW37dmqdm&WFwcULF%&2C zje3{vd%K!4S{_)Rgb%+rrOmkpx!!d8u9O%PwIoFPdjP+A9vwFA+0BCAdN4Kz*RVg@ zCnm|H-EUv}qy#caG&YZ8Gf=6J&XnZX5H-;*|2hsV3SG^M>s<~9cUP)p|M+$hcGUE? zwA69Ppm-(T(w7jiQ6upJk$Ivu&DN*!=Q^4Yl8e`Csh5bRsK$>+Tp<=TPd5r+eq|EWiQbW4ax{GO zlq-G=LF*KBfZCyGzH%#GeJP&OXxB9TK&@tCxs3`w_TcB8+f}9lq^14S6U3}06gJl& zn}gb%3+es59sqLv_DF{uZvrK?^#LVyH_A4E_pPb(qYZdMLch4XiSv83e?rk08E4Wl z(F*j=vb9%*4eI-Z0)Vf1H{2nA^4^zbMN;}@T%lYxcd{|>W7$%Ur?ZR_x9N%}3iA5? zje`>(ugg`io&x zS}RTEp|~%){c3#L>;s*(1o16qj%}Pg-T-7KzOD&toa$8N36s?DfXg5fr~9|z`xeSw zn2wy`=!BD>3LocUBmJe!ztjAH&ECU>kEij>Y)a_0sTDRyEM$E5PD=e3eZo0JX);!i z?VY7Xi`Z5PnSD`OEMh>Dh%$}AzzFQgV!nxy^>N;-8^X(sO^%UhCK3YTPg;NI5;K$v#*NJLt#pOtxPs3#IxkCDPasFPZ8ENLrS&aN0h+Bvct1m91 z!UPko{f@_AyOR+J@y)H5rHD1|Yk-=E`YfRM=A5&p)g*3mqnVd z!E-o$iuW_*V-o&lNQMOFM;-h2DF}vY&1E@#nrTPiw|F~y?8~EzhXsaYdG5wWDL3B| z+PI@}CZk@--wmX1*1HGN@yizM4#qPb(W3}Bja@cBMeyt0GnCuHma{MCoNPUew#cu5 zkwdi^_ziO#zqRi;&8O&XtMU<}=FHn(Or6cUy1_F^!l<|)esD}y1zUIcq7=Nb`6jHh zYYe9R8Bq7(tp)>sO@JY+`F%B_7}VDsqfF7}BV}OSsKZafLZW-cJ6JdQWs=Xs$0huAyS0e$UO z?PKqj##-;p-oa80mdC5d&t=b)PXPv7o!^j|YzRR~v#9o+1Asl4KLa;+oY?V6doI&= zpV2m(+ojsxv89NmP;-`eNH>JoeM|JuD01=}C9jC@z7t1?cVl`|;2$q#m;^q|IMw|A zwQ{{am}V}_w2kT|>H~PyC>kF?=i44Dt6uv9mr~^0*pAk))a>dHvBnJs1B<2L3>r0? zm~Wf8HJ%%ARK6I8hgjw*_jT6#g>0ZIprY*x!y(%xk@d7xGaoUaV4^qv6tkOL$!9H@+60YZZy8S4B_>83;G^d))Dtr}e3&rzc= ziUTRVpS9U!yQ8=>^Kujd)302ez5?5LkXU@ZZ`(oy>?fTZC>XN}RwE4`Fr@jK_s7vf zBtRuq1>AEWB~=QKri914Z+Y+q#svs&Jbdz|r#t9Gr>x^~_D(4y zY_WCi3X3Ha2V@Va0G=XvXXnAXM2tZ^TlZ9aKtPo zYTwleLD)x#2SWsiKZhw6f9;bIWv{L(9UPF?JU7PdvHJjM?aH@omK`F4Dwn%O&mlKg zLMj=ipMO$hy}7;GRd#iMqqe^@AGa1odfnnBlN3Ug96*lK9r7GYP0KvbEjIovUFNeiE;1aNhk&34+ z3VsFbaUCr%h|Rq)b;t{CF0E-Z6+EaECb8tORpIhM&})WfUO$vL>-3o|@KxGtEa`9fq+|699t3{q>P;sH+%;QI$NUuvPx|qeGw+^bwJ_F3beNovc`QbD5NZU* zKB0By_Ad?fzF#v+z>;P6S0&-qYRz_Jg5%GWI5k)45C!;2240EzIpm`hNk^0xZmpgVw z=JERx0Pxir7X(Cova5OX3ym%1(vpAwTN2d1Y3<3j0N>|xknib4yaY4+_#`IpOS^L# z8e%|C_2`fNu!6QbJlV6lxoIZjEf8#c-5roAl!QJ z)kZtvl+(-DPM_zihDfyLC@yA@njjT7Z zn3{;`O{JEdEjt+Mns78lvlMSW%CF8{oVJ&q_Zx&L2pPFdTVCi*qih010EFe)3X1kc z6UviS)bp%|Hevdj@57jGih!ivp>=j)8c({e{0W#x#d$|Ua&0;pcMQhO*@_7D(_KZk zJ2dbYp<6BBV zO|UqU)o1ugo*R$Zx73Drc@!>)yM|6KIGG*6}-wtM_CLbfdpHa5T_VO5H3j z8kQjgs|L3`i5VDV&-1l4H`Xi+Z}2f$x$-{N^^OSTnldyQr7Q|Ac@Wjc1ZVW9Rg&)O z%X$KrCEaWFg^Ml@Q$)YsUHS(1d3q{If$@=gw`Z$#;r( zvsOT%8nB93my1pc+xR}jw@$e!FYF82@%hECSjCXdT^D}0QISfQXLMn5e_KS~>yxoI zK_B0e->dhM`t;B|R+0q95qM{TSv}FUeD1lbLdcN5g_c1zefsEJez(^o^!=MbGtjqk z$PBR!gi6!&r&9KfvRP-Q0Zq0g)2$o7LZkmPx8va%*3zgi*(M<`q168&8736~(3}ZP zTLz80{+sK8=wIo1lk*b#?Y-jvRA2TXlu_~TY7!=*q~z_t^n9sl5AiD?H9b9C z3woHCqyH799xOB$DoQpfI;@426_c7=49GL8pD0d^Nu&`Tnp726jF$*rMUK%6>jNX} zD=}!e8)k-#G=kFSQ17h5{)x&@(_WHGUiEgJ{mjLd0ZJ+t@}dco?x2W}&htIE;#;-x z{bg)+*8c>jTCt3ur?cD#uaXIx^cKqm9mBxC6wm0_x@oOip#@Uj3FOOseSTxa_$+!l zl{IwO)#k6*u9fuzB74|zPLyHxX>_vDcr5vLEAwL;SS5cPej?D+{UuQuH2w`k8_-$I z>F{&?&Tq?ydz?Hf>%@rS#_pb5M8qO9468QTndPKZYjccTrqT+bc-5k>%6(Ox%=`9^ zHw-`9tAipBq)Q%|vN;+fn0xNuM)PolI#H``#?QTqF29cDN%wG$Wa|)QZsz!yL z%UW)A+rcwE1L6MtSlTRXVE5hRUS&V}+D5>^xe!%=7yt&ER!zm{_M&(cVYD!dEV7(6 z`uY_|jmbbv%iEF>#Qq6`vmSNj->KlJq%saEm*pl-o+^*pTlc2fl(|1EJHG5LBOlDa zJk@S+LNhRIlz2EDk1xR$4_^cTVE>l&_g;G;9y~NtlXBl<6_ewb+VxV!=V!g&(}>_F zrTk>qXJ8b8vflG>mw_XoLG;dv&nIoqca1jpt>{V1 z$0oM9lLz(uTk|6lVisO1k0-sgpHFE2+RKOgz4Z?Y`uz~uSN^KYP}@%4z(ye&Us;3{$?*2R{hE8D4~Y4xA0 z@{F}?!nxp%?fvx_>A8efcmO^nwKcVH9F7U%vp>e^dp1 z2x=~W-vXt9LG30R2?qW$9hwXoiVf1=0G$1o?k{cnzj5h*jeVFe#s7Om`uEsZ4Ef~a zvrIG%g82)YkYO#HoH%tWwCo3n)O~EA=nHMO-CN}Bl9G}Sj8d5(VG?5nxsuxjT(K8{ ztbD>2V=Mr8u@}rR{v}?E+{qta#w@kT?A8=fUL822Ecb{o@gPq?M-r&zuZ|ZR5VQp& z=IK`awjj75TfIss)+(!UyflLOTgZEf$l1J!7?KT6+IoPDWq)@ZG2jE47$1V2mzO|5 zCZkAUIxLby0<_yhkkK)N+eXKJMw?ys&lcFNS`l3D48NHoA`Oh7 zg=UF$Vdo%XFLxpj2baf=>!%d0oGwJVE?kzrra5ma&`4-8QG8x=z zwe-J>sS&&%TUgj2%D69{Ff*nlw4X0Ylfoy_UN|&Zp2#CDb-XAVe9&2}R@R1tYRk)R zB6Q2~&Wrt`)VsaVF|B=9k&P!2d`GkFwx$+FIBs2!y{NO|9tv!7l4VZNFQOK0^%+}w zV{7=1&d104$@9A$4X`(0aXB}A{*y5!dt#n(n{BH8r&*Et-Tu+W@pB3D?RY;mo&@yX zBFD7Uxv|P`>Pbe4 z(_L&}xW;QYEWm~0E$pZiQe#>*+<)t^ylGYvmR9c-Hu*z$hJz&1ar^4+kLz?*&Ckcf zc3D_qUXxJzBXoXD4v^#G*w|>2Ii=bG&W67JQ z{%%t-sy%akw`vJwtM-Po4iAMYjJ$|!?1Z+c{-BgeZjHeh$`TnhSk7^^^j;a?m!*8^ z0W8#b;G@ak?|!9G>HC33fktI+)2ysmR``)+#0ru_LU!WlEK;Sie<-Un>tU_Iz2{4(`n7!hpYOem})dZVADj#S|Of*qlt#T z+^15KKci7tR1ia01~TKVPg6>3Yn zr=JuE8=tM+aKA-(jFS^#s6QgA(a}0G^tgOInf3^8MUtB6j zC^DU>@cKNJkQbc^I}`Lz9VTA8S)q&GMDR0B6FY-#Vnc=%)(DKan?UFU_hUk_@~;+6 zOjEw-#mmUOa9aO4hESP61q1kNgZ`ho`68*mbO@Ph-SP!Uv0sM!f4UV~_CKr&28%j? z0{SkOzaKk&g@{earcZH<0Cg(=8**-D;R}}j_unIDhhUxhin=X!h-F7Pdp_as?c+Zd z_zVo3Qw}`TgS>d~l{2OyK$}f~&aTj#Uh}QP8GGYaZNdllQn1E8y?mpzm>v9|$u zHUkT^1NkDKDHEk(tC914tuD1=f54mK$VGi1WD>?@49#Xy>*(6dU2R(~mcDxb7S{sr z(i{GX0?SAF(-5_3j8DqZXoerSZ?=0OS*gRZqft<>Ymu?7PGv`g4wumjyRmxqAzqGl zFHs&xhi@)$d(+=8Mbl2rd0N^DLa`-j1o_D=l@ZFwuclFnE;Ac;p#*2V4tRx$bP~2<=n4h@WKVGBK3yz+d(TZ&f7Yx## z@yN0RV^gmpYOQo*QjHViZv*9l1Wi3POK}Jg0&ua;A3LvAx3+vqqN079gvI&XV@~^P zr^Y10rD~*=XD*>EZ-ZjA6%^gf4}Y3Re%&4c>%p&%frsDlXSFb-ytB7@c;{^O7RLD- z9kuTHWNaCkC2XTAZlCFENu9j&^tzO+DIZVNQ{YN>eQjk%;eW^tOwClEBsC__D5}D~ zq7#E&RO10HSS9Q$A3`O+LM#`Z=KX)SydTDM>?BmK%tun1b$``nlsntzeo@yCT0)&vWHBcg zX2>5|L%C;BWOoYVNe#oKoS83aqxj)XlqD5$UdG*;YO-9=q0Vl1vydPR=Yy{6wlA&N zaNKZ7TiGn4yZG48NK=AIGkB`ZOy8_3MGCe0!w%}D#1z8%8vQ0!@h+f{0b(cJC2+MP z1d%hM91Tl9!qhwDxvIqY>^CWf0cR%g0Mt47!HHm1SrJEO8iw5nd9ESEV7+Ju4HZ@uOI0$6VW!QvtVBA%y^@5x_T>nx;8V} z+luyxL267b16OufIL2KOsIOoUZ)0mqs-0zfAE4DA=Q2h;k#XeX^?B`qv#{1~xE9wZ z0HHBjv9$xq?^hg94QMg9!PV9M zbbZ36yFB#xwKOi|z1Q5I>mic3zYG9+Ddv$vnb@x3{_S^4z|o?<9dF>q!;I(Xb#?9X zsk%%4ObSW1ruRKAlfm>JL20$++v3R zbv`mN-q33@kaxE@_SCLdeydc$-d|=f9X+}H0gYFG)4nuy{FT=B6BnJ`gu9WDQrbn= z#e>DN5;DsrlyZQgYFQnZab?_vm_A(NM(`~8naN;=GpeEpW#0ZmtS~TWH`V78(qKWL zN4TFO5R0dD%gd*Rk|c7{wSN%hmh#|^jCH%41VyE|>To$pueIkRC7TwpJ2}+-VH-?& zQ0V6y^m*U%Y=8RGrW=lamuW8qj&V+L=M1A9u}!4Pvx7AJPNmrhl4c#(Irb&I+_>Bx zgvM3K7IV6+OZXNOv-pj^lpMsp+gCxlzTkA^@3~siF?%-|6-M1rXdZG1BEreD9{(6c(Ar{65v3UUrRa_Gn z%Yi&27!rgCdU_SMTL+DbG&;fqS}Qb#2ND$1vNHV|cJhe{M{x1@jYotYHKjerSQsNk zABS6Xoq?A!_(#Pv)j56b~03iVE}s#jU>n89jXR?6N!DuUfuQ+Uj+Se)hj9Fol|qyDWZHVwpD-WZ+b81U!m) z+zQ!VE$tib@)MlY#R7kP8_H8%DRhG-DAC$cq9naJdG;H+z8GRdYCEz~<1lOybQ+a+ z5`%x<%Gi}l)+=28?i)Y*c+kZ1kI z9=0MQu57Zl5Gd(Z96$D=KZ$*IB8TxH6v+!K){_n6UiSDW#=u^TI$dD*usRC1%V51C zgEdn|MgvVL9!z3^jqyP|O*NX!?#@BSu9J$d{VW&}2 z=TBodqF%2EfY;G3Ex8wQt~Mvwk4a!*c@Id*+!E$&5KYbQB2+=T1%%5KI}M$1_EyM+ zXOIhkUUGj3g+!lg3E~S6Q|C!Cxqqgw+T7RVDQC){q$qkXqI~BQi29otzHmvE$Xr~1 z8s_Y$L%S=@%9wI2mmg+XC0Tt>bDrX~jt6t7;-O{p$L#h2nH;gmpv+=EpH(cPRFy{f z0m~DQTc!Gf8OBJ2%%>H`s5ue5=29MHB^l=HJ8S6TbPFSQ}BI9r;GfP^C54wZFb8m~E)B zu`!}dk23Vtc!4H3;@scp%i_=EA=|}4)W&X9J>$>eW~-P-%|cCqEsISy5*SY!)x11T zNuXAi-l=SM^NH=lvIm#W~%SKFhmqsob z%IJ5>+CEnmjUt>hOXm{N{?OpkJ2+Tc;54x9uTXRLd=xrX!+l{c+XdYm{VrwAdC4J{SWNhp1*h0D!eV3@|oB8 z&U4BoQpX%xCSA`%)?>>|GRfn2?oix<2yYW8C!Y<>djz$*)qo7APE;%1zNv2T^9!LX z{6~k|3MUwQ`A~$7-{PuC^~vR$%`QKdTF{dqq|;;m?D(0Iyv3k&nuofk+cTEqOYGYO zU~M#hrERqo%JO+Ocd9$Or_6^x&U)7}wHQ)8aBT#&yaY~F{*@tBI=o8`ZxkNBP{UYB z0v+-ib;m;f zAhBlCl>sn-!=G?I_*VW@{Q2OP^!Xj;w^h2j%tvfotSiAp${w9$QSVmjX0=Iv->A&~EJSBf@G3Ji$|K&YM21foE z8H`EyU~2I<5H>TaQa*jMrOv#A>C6o?$Zh~499%51OMRj8$>RKIJWBF|nKn6A9R;oS=S{aXV-L|d%iKiPO-$@cb0RMsq+%B?iCpxVplFhz|JP zEgR2IE)`fS8f#meRlQ8RPHZ34Q4{Um;k?p3c5L|bn$X>ZA=H!7%KIF?fNIOqTH*cQ z?m3+pj#PfUeC1_b{nU6;>$ND7RpV1AKPW-dS%`8xjf3qS;bSx{v!0lOkcrKa<+&^7 z>o>?I^b(JcrNWWd;Giu2N#_SMD)R?WW9z5Ayx&{}<*vwlTMYvDH<^oj z(RT{#I>M}LZ7W&Rg1D56hZP;BA>E$A!A$eV(hQR5 z(T0#pcTX1yH>r|~QCYVDfxbSHm3mQoM{nL~|y^faA&oAYxnhxdQH@-*}NV)?EN$H&a*7tdq8lEI=oHqY!`LDLyW;f-tu>paL@DT z+t;4cT<(i_#KlU_RhEaxMb3v@;O+ci05qltU4#EuR~br;Q8Un7)`0e5hD_a2jXM{Q zW@ek)-NG{p?N{^VPxCouaK1g{?_ns^{8hL6hBh zBhW712Ouz!Q__y31YLHZN)rwX>Znx3Mg&AYn+mI`+;o zTJ3N=g2o)6jfDnl8*DAT*uAZ2zZD91vPP_6<+~kBamSTsoUO1dpXywfISp*9U*rue zZA{s4pNn%X@>TH)PtO%t*s=v)iqR~(t+=bDdHEui(nb(+nS!TUt>+5mIB$otWFooh zkMC!!2|)$@D*XMa8#jj?>yt-Ueagyz`dY@5n#x2LqL~jHSoE(Z7vPZpmha~~V4r1L zY)+9{nul-cc@LqpE(n?~dVKguGi#>|F)K~8vhUGkvSV?|qJYRJAAV$vR-)>Wu_2UM zzWNd?WaMu6I=@0Eadzd(ZAtggc`{!KaK)}@dqBREOhZ}au7$`Mozj4-PUBW}>*lp< zgDYl?^b+7jp@N^dkyj`mk6%hyoU$o^TszD8v_Jhy)Yr+1O%fUWm1@5ey3@r%U0=;& zT;>Fp5>B~3YCOLs+%c7fIEY2)iyy!b55~6UYyeU{e3Jms!`RRltwuvBSVdQ3qjYZ1 zG20mfgeJESEM2X;q2->XlMsQDGKPoHBDz|uwhbH4nUM-E+a!H|N zFJ=G)7(NDfIw4p9r)I3R%{237{krf$g*RUx)Cz`rwJ*2-4{2`!71#5ui=u(x4hb&7 zH4xl_J0!Rh+}&YtcXtUMJh($}4X%S*a0oj1+#&h>|L44W&wJ;*b!XO^HM94g?%LJW zUsqRs-TNU@&t~wGlap7e-d($dpx*0RFIz7P$dX-1-B`kt{Z3uaT9!f@aZIH3(`rRE zR_Yl%Nv5YA`6WRL>BB;!veBQoR0v%5njPW4X+z1cy20;<$O)c|WT{ohip`dxPph4b zJ?C1DYm$Y8jv8q}AeIS?KbXJ?WU zotswZ#wBe>owfsH$qwpf!+Iz@e&|d{!w6ucWWPzPK$3mwfpOl5?5F6orDEC_w*K zcBllclHwB010%;{2enNF$>bSX>8pXRi|p2q-^#IXFE{S)73;Ne6K3#q!zq=|jeJ5# zI%9&=ga(NXLeGx5Bzh@VFGwn$T=6$sNkNhO2}4C!V+y}OUz7f9q9;JcR=0ym`$2Cc zfuT4Bu}oeo!`Ijw2zcP3`2?j21Ih(I(nz8zb(S#tV+#x8L%K;a!MEviH6P{bBE1I| zuf5F~wUqwNN9AQcIp}1jZkCQvPNtv5MeJ9W?~xVGI;J1wekWapA1oTnnPZt!Wn!|y z@zHl(3;1{iCF7g5x7CeCTuQ4Cx9zTgW~+p(v<5h;G)D~? zy%sxR~U=li@yd{yM+c!>0xf`nmB36z=@ z-E_TspG{RTsZZAQvzSfWWw`;<&wTD-b)7U@F6E~xZAzuF6$ay)QuixS?$ZXFE1kz{ z&=uOjVYZYvlG4{mjIM$VD~6v%seMYB=|FDI4oSf-A>;%|^?yZy`-lOp<)gi!umT7e zbh9HjJ9?l(bfA+lM6uV;zxg=_Xoa#V*;6Exz6_xO@vl3{*zqmU6f#6-FGh$L*SQlpTU-MHz8;Q&Fn~0Kb2PXd zwcCfI7}lFH2O`Vh-jSQ~U&hX)v=t_p>Wn_aZf%cjrV|!MDL$sm(BsLfiG%o^f6L(4 zfG$oYy(OQx3C2-Mtraoks;^ntkmCO83l$YrN>Ne07VFb|rFQ6Cxq3Nxrpv^{gn*FH zOO~JI5WS+1&4i0@)1F_X!-iC#nyyU|@M_#Hr14ihVjLMF*h9=u%#T)fhTZ@C+`--Z zIqZhd^VPdAvd~ratD*}hhInMBy9WcMk2v3tsfhb=dgMU$`D7;mTW!rdCY+8BgtV6` z9t{b#ANyl{zE(OM>RO7pl-XS*V{TN!81J1KOtA@W<2*3g2S6$dgDSn2l13dKuF`Fb z%gh|TD5w>(j6OU&lQaj2sjA}6SLsGFX*X^RC(_u(96jL;XN0VBy`nrk!SxL`?TuRzxPE7K@-E3fx7+BZ+c{;%ainl4CIIxbuX}sm^R-MF*n#b; zldX^STeP75FH_4+rJOL&+;Y@i)oTKE80S+_b82J-lBJ2u-5fwIZ3tnL;8WUin&Tlp@XMD2$#2#QQ2FqIB$tWH{ zm-KERcrT2h56Afz5q(t|dOu%(jf%y&7&s=J8d)~wdPh3xV2KvXa?AjIUYxb<|{SE;UGydr+W6|DG6~432{ywkrKF=Gj&I1W>0kwt-qcTZW-eQw$ zh1m4-1l@S#rj;d;P08iA#ZCn0)BR>62{?023J8QGK+8NB%@R%(p8@dVlZV?p5#Y6m z)I#9H-~(lQI9CqSp)tgiITtQDTWv`ZE!QurI_G}E6AN-BsW_5GrazoIr8V4&Ultbl z|Jtndl=v)aqdUJNZWfQ^&|sR1IGKR=6<$ZbBfvKe44f}lM>mYtE0Kw&L<~M!(QmQs zw_`RSmx=N_O7NT?CEe8UbBLn-w~|0@sl*h*RLnB=yu7^43gH;YaUbdE>9;zaoSbfN zw__c)`og)!*|y9>E4_v0nIdYyeE@eJ*dzpy&;x>JjWRy|`VWgkuj2K=8kj_z$*Y^; z@xUa3&qkqk@lx@O0#~m#ymUS;AFtHH+b)qq6hR@(h7lx0AjYcIwq70aF;U+R`PpoZ ziXewU;#S~U5d(Uf9iz;9<-IK}3{wF#tJ32!o}J9nW*15ZP(ujs`qK;5LQXgo@skvMi?)dZ_k}hydr6>ZCXmp_ZT_BO@jm{osmGG z8j1(Zlo7#%8Bwo$=b{ng`I(;U3)qC(`Y)T7m8HHg_|xiXrvEo&KPbFxuFAs z5Ge3$+$iN@R873WdODt9bC+tB%5c}tegW;5mVFtmAzvhd=P5IPO7tYArr~Q=Q*wl|A$1mQGhDE|GV_->~I4h{r#s1k3MuBKnPR7sOTd(#t zziTD@3A28myc7L$U1f1-F4NVc800ZOa3A5M;G{6b{2*WUPfRQ=k?>jc#9UoDbKyTP zk+>}Rw?ci?O|G={i+uO1VgZ>YS+gn<;C*Z)4*3HHG!`@#9AtRKfQ~4deEUI2Xkr3f zet~{-8bFi4AG`gVkcSwwN^HjM26-(wxVdqy3{SBhop$!c89y<}?L4~mlU8L83}j60 zNp~{niZi_d7iIw2X#8H>$`?M)`xkP~AO23j@p*-z z(pMqb?U!1}{Kg^Glu;w++iDAxDiyMY}AuPDxtdo z)*ltW#7=hD`*E%_s&3R%s0tC_g%pRu#S3lRviO*MaDHQFOM31uJF1A}7Ce&D7t}8l z225Qx8f+58E?-t0jM4AnB7WFkW4~(?XYkF?lVy0gZaseNxjt0%;S=ltSXr;1-#zk> zPK;ZScq=^RBDocg2x8>kj_hs4l*$?&Z4-BP!D0MJ#)M2b;Nm_IJsU?v=c*9J@`Vz$ z9W51TPBKQkR_?@ZTSo7n(otPW6`yIXmQy6XjH!Nn@7i=61?$FTvbWIZRx&;^{^V#q znjKSi^YeVTGc5hF-2Cmuy5}B(?;A#tWfqd zK+{QeC7ogzA_ne1V(gslT%F9wa*6AzJCJoaP2KlsZ@hxpC)U@~$B|>YUWdpuYUH~o zaJN9>;i}yFEBEwSo7}JXk(E>oP`;*~qwA}>vIL%>OG8=^AlzLVWC8T7l0w5NiTr8c z+O`~lH_L9`O)MIphmJiPjmWe{LJ=lIP5XL8=gxm4k|&OjR)0`S`56`LHGxxg?cdW7 zq9?QfN@*)4jiSsxdCHv;EuY@2(}I|o-}y^(LCm7?NXpw5Sb@W2^h$|W&=so4Y4qOE zqR%S&x0d{4yS3@m%sw8=>KQZ&45C5%pYAZ^cu@6o?1U!b3>ZZ$9NfmG8)$SW?D@4j zHZzlj({FkK6Hv6}yt1^kf8+yUTv)t*=|fV-$GZ2c8hQ@g^ts=newir_#1!d=y{6MT zoaVVs&XxXBcb(UI>~6Heg(1w20IvibrMW*PDncO28X33px-ocs6lI?g#kABXA>u3F z8vg{lZ|(hFK)}|*x_kvA6Rk2l8aK2+9uab5pI_#naB)z;^UPp6_lry|65pzC^q#US zTqCBaL=Og8-Yo*~ladN5vA=~>SF?qc5kqpw^Ur<3Q0qT9-Iq?ey zKtl*a|NH05B`@=I85xKryiivDpPzodSwf_H1^S@C7H|#dMiKJZ-*phFtI!@%I(g5d zqh8W^YGQ$bn75t+MI0@EH4Ti!NBEe0%%RH+L~3D*uK}qtRkSUzJL6;S&a5bE(4`qU zDr|*^hv#L7(z<&OV->awV^?$a1sFzSAmzCt_SFurlLmFrvE5k%R~Uij&QjP~&$T&{ z5JGxi_WSCNm}Tsy_?31?R{2NzPo%b#Z%LO)y+#y`Sk7$xLBkUyG^Tek)HuR8!qqrI z#%BdB@54HtK)>G`yp@$ANI#)R4R?p3e$>o-WT1x~l;t}uE&oV6&}bItbR zft#FbC0<^bQUsW~4KeWt<6`>TMf--*$Z=!l|j)GU0dpgFU)}jS}0cx+C*F?0nHKMBn?n7b1UfxPj z$c%e&3wlPmFPMrp>I$}-_W*44kZ-aPkJTK z<(6Q~J&tXNtc7A@Ldx20wgw$|)~PcuMV)BB*-&;mNinro`;_x|zLEX_@~M)i&vMGx z^~N0?EGyi=Qzi3%1>80O9 zqCR&Eoq$I)@8sa{)xdWf`+7a+!p!twHg>juA&Xbrpt?z3c*m~#u@<9oB%upW!8}sC4?;viKqxBmqhk~Enf(QHnDazxxaEowr zu?6Q8XVWVj#{hzv_<;y)Y|bJ+!hxzC8VDGK-m9l@ZfM&#I~J>{vq!wyS*@ndId6K$ z7TL6Sh>rXB`+^b?a>UeinZ~2W4W4h_@z8u%gp-J`^txy~h&?&jM;E>)bPj?w1P$l0 z$;tH_n4HOmGB-cKrr9SZ;UvR~>dU;1bq2yA8?{VyXXy1sb41;KsM-86L@Al3l7yHj zSApCefoz{DG;^VSJVZ(}comV&+f#p2bNk?5B=l^8{Iwr?dC<)9S~l~QspoTATj#CN zVAK0r)phW0A|T^1)kRDXylPSyjj;b3LCImqspWLn9zLJ}|9cW=P2=xQqNeh7QE&OK zXoC%$tq|y5)&}{&hK{fuKyou3B@~U=KJJ61?zB!!lu(dGF=lR83?KS?T5&^Jv4cu? zBnu;jzJNvA3Zsh^42tqsa+HR#8@gaUWqe7;y)+BhOJ(foZcKl|+uU<98yP?ewdofM zI+L>4HgKGfx+KiPz#+y8;7y7ziaFsipA&5O92|-hvS57*m4w^w$onx4yb^`smRu^I zs}ox0J?mrz0ROw^{nB!bJiDcbowu^oahi3@JB!(8fDz?&zFleWA%RaZP`nQtgCZr( zUqWYoX?6OLa^HR&7qVcSSafMO;l~tyY9`T5{8Y$B9Nuu27g3-d&nZuE&Lg*?HIFmj zUg@T$%$icYbltWK*(%l{UbB;8BJ3!9rA6W%Vw_-zaehdh#6;OzoXsy3%D}@}U>DDI zxW+=K#_LxnA4}JR*+;rBF?7_iK9&y$;U;Z)klqTQb2Q!<@VCA)`Vg?(V&q)EaP=*> zB=>Q7Wk&JYX451!mXsgZs)Vp^bth!@8Xg0FvK%fCDuK(jcKOa*gjY_%Oc#_%P__Jt zg4PgFEi1`rSUjanOuLktahDlEv)4MESMwWA`|I(EFCzZ51zXg+gGK3dTkYWKRz~!* zrr={{}f$CA89K2ZUKHQ@F6bXT2oOXmgJZhjcwdx78zuU(rVY{XkC>%v zf-3xmyV+X`pn)!K2eJibX@(9RU2-QzZ%Ffd&8q*lsw*pIzH({WJrl3lOo?Y=*5BSS z~tBf(lR_e3zqZ;r2dsiesi$zUfizsN5P09~j?6mb5x= zqO-oxb?=MsnY(|fk&7)-Y7YA0czT_QxOBpDyN)0@0lRGL(V`U41?BiqTEXd-y1kBP zseb1(*?99U@T|c*w=M`WitXbDDM20VMP6veuB$-VFgg#PQ$`|R1VT2*7jeB4Wzrt` zRI_an85izDv8#gD_!BNJr%Q1>wF$BeXv9cYEYp?qI+U-_gggItIy}aE{Y9a)gCJ*v zD#BOe2(a9QieFj}+_=fIf12S7Uv$haJQY+uJJ^tWCk!oiIy^MA;-bHg-%<$p-aOpE zP69Kux01kX9F53(2;;c=qg#)kmFtasT)c!l%dA3Z;SCRE*xaVQkMpNx0o*6Ct*;!q zGa}Ykx|K)irQ$StV01V)A1KP}^maNu`Jd!TvFXktBVgkX4`Xa(gF>QsYA(5 zPkggQ*~7y%m_&>DUrEOWkW*2UP2)_&&kM_4O)T zx5c{GvMz(v$bYiwoID+0cA|6ol4y96rm*<&Kkh95%Df%!Vw$ab|FqG#biD-9>YUIN zffj?l8WExO)1ja|W`i+?W}b>qA^oeK6IhU zq4_LqQl!p=O`>yE7i*p|Ud(c~%F~y}8w+A_T=nQx$#=vS#6JwjJIjjEYVIQZ_b`M7 zv+e3wGrpfL4VO*g;ENxX({Vm$yUBx`^p{5k&v>OgRds`ld0Li5g7T2|ku&Ji6xo)i zZdwNB7f!kyQY$SO6Q+yPGSsLAC%oU;h4JyZe%Xr$(-VKY+|=Q7AxUnQO(BGJJgIz8 z5|s5u)M_GrhLP&KYIF0U#a#jBJ*Y{j5%Td(EeTXm(wZQWk$ySLh1pZ?yuUPkyGEDo zzs<|?juK|hMVBG`;IwP;q@X}w=m+UUGlsTv(1r&P2)chhQEi0!+|@Sv$+_@3BYy4% z^d`@K^~K&>at2ajhniUTLG2f|PWnQ3O~F9bM$-Dq4!Fs>Mm`8^LEhvz!U!pb?PaR{ab#g)X!p4t?kfv4BoV7vsqAQ%yQ`bMQgMV@w-JxFys`ZBgP=^g&aG z8&T)pv7`aDM_5VxdILYno8NLHwHNL9D+2zoI-dY{;lucj7X#hp=i_b0sJRm*==%8Y zd%9uKzp#E4SbdMwQ^!n}7Q@h7^(zw57B3+xa4?==ikoP;cryB-y=c=N3N(E$BxA_! zBhG7jidHS@Ekz_CWwv{tNSqA6Nk(ZFxOU~5 zQ3H!zK1G|L_NDXl=T4uvF8ymCnX!q{Ec$cVV}J*9#iC0pcp@B~i69&i>vZ?1V_QUd zWUb|T)v2)oeBE)xVi8TjJ$8a6{JxdMaT;}jZS|UF0+a0Q;m4T=8C?>VcD(be@AH@E z1zoTuXFwlWrbpc~a&)1)hL+geM_|t~v%XOCvR1D8#MG&0)jDK*NQkS1=;1LenC_-b z?`Ld6arc;~kCf#s6SygQkzG}lG-PgO*leOtzMIEOW30yhZg(Ha60L?@b51h2M?y}W zIzd6lZ9#AWF>dj&D$q@N(_Z=trAP<3{gvy<&FL)&MbS z?X>o2)o1-8@(Ot_K8Byc#tiLZO5R4*aS^+V_(#)I@6a>rv5(EFD-z7Aol8JBu*kLU z(oUbO>zz6CwX7SAE$+8C`8h9?js!I|mMU)3zd{Ba5$lcU9`+vZ`No@;PQ@oKytM_R z)n_e6O89X=Ojv92Lj4#p1uZBrRL5q=i_~2RJ%}o#;aDVVpq}wzX>CfWz_sOZXW^Hi z&xIYbU7PpghV4;wm|*o|^)5KeWfi?z@MYE)gn@|yENx^&&e6b=n-N*kpq4>jxNLe} z=#-~>X!$65pGY@uiK@phUB=X?2_U~ymP2Yez~Do(%svWUV4Vk z{?Lg>e!tsZpdq7qrrkQA#P#m3yQ{Kk^7JrJAN8+{*nt@r;(CI&2$;i9k5Wld6mJgd z8S(iSTYDAQMei^~5dT1o7#QQw>RUolMbn#}MaxAd*0GW_J!E zhAwg;mB>TyyQ;$z-7 z`95$Nzkg%9(rj}^4Z?!}IK*N31l3`Tf%MfE4Dt;%(Ht_ZTWNM!F8*1|*wXYzIWLaQ zZh#V$v}80!?UP1|_=`$kv{h6=$tx6lXAVOxBTYk*HgTdYv)yk<-Cu0ZQKtRuWc(p8 zH|k2=98Gn5v3d2A2GLsV$cRiEaw z37^2HQv81in+m5NSkh zDM^Eo{Ch(=X#`e_h621YU7;Jfc)C!c3#$wdX2sDzX-2w8hC+tgZ#ygl_ z*L4#eW1{$HCpHZTD5p&1+(_aa42YPMm?Ar<(kgK<+nXnau^q}A?#2J8-v;o zJ;^A`<*Kr;fh_^Uot($$@Yzq~L;@h5F$!(;;Ie)-h=qMse*>}Fzg8>-_hT?7jyt%< zRy&KR`3jMf9#QPJ-mS6x_oCZrPDD!n1j+h=RHAKOTo)7&aN$%akA2{*#W{m%73hKs zA3rngFG_D|{bgo)E&qntonq+y`jA_9tC};&<%kCo_$g^Dt$b<6YC(v#{xK7E|4duD z3@7Z{+?xEueL*xHN)acE#Wc{=p*QFUy)4AH9og`S8e~_L_jj^83nyS>(`gbFByGFy zh#>Up{EWDOk}X-daASNY+GO*mk1Ihs+EDfDm!4B-OF?BQ+Am)7BaTOeI>%ti!ij=6 zM;;0uO+OgjQ|G#ySx!#=i=iP*K>>DHVI95S@zG%bD#c`gYDwIzNT*2iA_+rydLFwu zqI&t)zrFoMYM=g><}YnVJeBMV_>4i&k%{=#da6C!D|iOaC8;Q?MIZv?Ajnw;XdwOJ zTxm!X$&K^Vp{(lUM^UNfSX9o}Kd|spP#7%43gccE#lb8v9@w^eyVCv^3o{>CQqYtD zntB||>&MraKpUqp_^EN|+8qqr)};QynHNa7`ydK2M=E5LX1F+vBd=8j!g5;)@IEA= z4t=NNaP=xEnuP;URpW#KyBfL+*2WZ0o_m;@oi(;YMdfCD9-5Jmp1Q;pKH4uuTWoYe zH+0^hQr>WUQQbd=Kq-vMw=$}K)cFSY!;S0jLPqvVMum3u+OCdjKB4yo77k0hu^;5e zW1|vz_ttJKcr(4=_60Kp;FQiAlgqyEc8_u(NRb$ zdZvG|{eOi0|0T#z&Xf0X$a~3_e96FT3~VTkY|a@H{=_(Tg=%0?o?4T_YH$0By0JV< zop44Y;LH7kZ~5~`m98lG`s_E%?{F4hypr+!yec%w3BYUQE{rj%t@89r96vGsVE%vf z1uZ6ZLm0%s@JX{?m9*XO9k%wC-X7`s+KSP{xQ3z&>MuF#=rMIf_3NHK*W^pfOOsK% z^ERh-NKKK?BYnKTfslZ)_}6n)W{OEc2@`wtfr3wB`j#IJ$T?jIr5CNoILS2}Ht&o$ zF5&4JM8h;N{sPONfGd1&SlV~CvjR#SG5ZqNW}H!xrM^~hfO0(P$)yg=zPCKnudzM4 z<$#2{S1xTVKRidy)p&NLbaP2%qt^8p%g1<=0XDwi)U&6oE)}R`V4Uu8f7N@qIFsac zwG>q!@4Kw>8+bQBW6@1r#UB3vNX(5`&HmeaDw}$7?VM^T(86C0T+xft`UOP^&^xm| z6L@^C^Ie$AGnjJ_o5n~PTmN(9A{m|wn^f;#To8?+XxB`A?v8wJh^|;V|dN%MvwzL$Q zY|OF-C(aA4<2`sEwrH+W^MUZT%5MU^ySUHwp6llAm{I$MI=`Oe3$oTGyRfbCj{wEFV-dbU_f_`7}hVj?@ov3Z6xIusaxiV8jFPo9(AUV`fU~X zi^U^KN8rG?!f`dqq4`OKNeMM40y^&MAoVMB&RwBVfi-6(pxpEN=jEXBlvVIur?hWi zjJ=Xi`)cArNvUrEo?poYj zuwFz76&ctiX%B+0NK>F;CHj%eEI@zYC%e=wFb&!K(Y_arXF%Sv^-cE=I>vO(A9B4? zX8os69yN|fe3INm>TRFde@-9hg*u}^^3`&W`?@hvQ~)S;D^fT2J^%n)pc`r}8{F-> zZ~?FK>e-AcUd-zB)~#xJu*C3e*@Z24E7gBN^?S1fiTC3dLsp%5Y<&IR+9Uex*}BX1 z<({H#u0`@){E2IS@2+xiUQAm19+MvqWxTzZ7?y4~+4#Wa2{?Vx7?}~fPQZ8U?gn;w zBGFfzm4+k{56TH%U=zx<6v}bVsjgM}?xAMq7RWj+eA5}0Z1pPWstg)C!q=PU#8Jx| zmT}K!1)%xZSYyvmMo(t^7Z$3558KXhoYHDnj+u7Vt-`O1eDlGG<2Oxf_8 z`1*t#SYHAN{@eID*2QufQ9J`&LLv$49b15<2^q$BC1qCfOucXbMM+mpqy%sx2(XX8H61kRfg-u-8 zTb)c?-AIzht*a9EH6ab68d9j-EPA*l0hjFTzPU(oJ>u=Jw9y>KNQfLb2i-D0^LSD6 zEcx4z-8XEa43=UG$;ECUBJ{`m8QHYAyrJXc3LK#U6jCF&!Utk-qJS1Fx5iQ0=_T)n ziRa4tr&N}G@1*oBmNoi|y^9}5T6UEszN^Oy-b1$4@okaciS);vpFhP2vGX#(l;h;y z0&gko+M*JiU0n-45KW@_)Z}^t6@jwA<$>z4X`_IjQmta2Bw524UM1~j`JG!U+u(x) zN?pC~U24v*FW}X; zi##i%=R92eRK-*vn^h&mEJ-|{IL*x(Oe@DkX*`~Ges6MlI~`wq zG-X0{u@FuZr?F~}{CfQ+aT^I{zItrSh{|UKUl>?yf=q_qW!ZMfqegCuF8ziXddF#< z%w1yf7440~q)IjrJqvI|P*MIv&kZRnTkqY0OF+5=Fo>{1eqOqS&tmu^t9&_k zrR3y9MurO#12vrH+H!T)Dn5q^W13=UV1D1dUh4Rlug~<1JDMMzK540#-bEaDrQd*L zKZZ4*pNHO$sj8g?Vg;T+Q<{hIr z?$bX~AVlXn{$WLOeN~y6-d7gy|NTpewuBsZd3i_#;<-~EW7qApE04nwA1>ZO{Q(}< z^GV6zYeAD%eCEjuV@(g_l*0QH-fhMfo3*Byom1~KTLe&8bRx_ip0I7#p&4<5z^0CG zuk=A=Qt^+w6|RIeI*@#i6eSZVO5XGu86|p9`0oUx8YXDpT=C1BV+MRGxe&>&m2NkWnK2^0YoA6xJ)Y`5GY}2J6&&0}Hc{#UJ zrWzi@WMzfVc#tQ7ma0auu1(EknukNEY%sSJVgOJu9!WT<_%xmn_9}TgX6l1cO8IR^AIOqcNNrvIq&P@jLg!ZtOqP z@H^Enn)Ai$+L^iUD4BXvV&ajzd*Rl%*JQ??@beFb|NBW)mji=})M+>TqGeBzu zIj1^ZYDOz77v;>=b`mj8jgwS!WDVo{~@BP{76a5|QcaS)6}4 zw2w%v>h_?U<|wI0lO0SwdBi(4!^fdXl>8}LQwhb}s3ELB;fF~EWO6Di@8Izp*DJ+- z1OGU6o_SJ(iZd#p)A`yeS4ddA|36LpU%HPF=l3NLv4e{NnP&ZeUGe`OF7SkYDdDfL z^#3Q6@gKE)#Yg^+2*-aM{%=DW|EV_5^Z$Dx&>=!V<`@2dCYtb*DQsAna#1mc&dxAR zdj+g=$U^u_yXc=ZHNydWr+L<6RQZ>eVG;$cyF!v`?2CBQ`guGm(MH>`zhwi`q?!#W z-s%Uw#{Y%&iuVCSdiz}fV-6TLppB)sZ}g+-2{rG8oFTCg?19~+&YL7NBcC?I4Kt&= z8}Y0+ElnTvnQ!vnH+4_=Mao;3k2_*?rP|eaL2z%+5EI z$B8uU4{>1wz7n|_&00;OL8T3Id*1f3w-+Yg2V?ugBb$wkW#J|vWR7tOk}mGdl?mEQ z!Fv{_A25GnUi%(=Hx%vz1y-zTqz0ZF1cwt+l3+7jVXp^L!Al-hNfyetEbZrz1%BUk z3!_yF<>PYW+T6~ze*MgJzx_@Axio3-=A>zTgze7g>HPFtngpBxDlQfa&8fG3f|i(` z0<`g_Nq2ySme8mRLND&EGMKU*M}xZYIK%{3m?cm^kZMZnP2WKeyO^ZkxZv%1@Y0Tt zM3Lz;4IBgWE%KK?y(tbnG+gC`4p3%keQSAja@XDXg^GzgO(TsvvbdFgRp0B1KShqa z;wuBukN9{mjV#_^C0pO`H^YRhRMdRvM;^&}0^9%yMzeotal8kD4xR_txeNqj%@aOV zb$qjUgla08z>|P0L*MkwCyV<$rD#u!Gm9C}+R8f{9Qf_5EA$xHu+n*`BT(plM}!vQ zU~Bm*kg08Uz_Vd$-K{UPriLy2+V}Y~cM)zWl}{puiAz-+KCU+AauW(d-l?G&T$QwP zAy8MVb7dT}VjNO)`VQ+PLH?_x?St*R=)HSOwe^u4JD@K2+5XoRm91^L6 zYhn<-!gnIR3@a@*(yqXmc7SDDc)Gt4;xec7;JMDJopp?z?$<@{vxZ3tD=e5ioVxh0Tu(_lB5F=ygD(ovPA{+1sV4lDOb{XYburgFqoDE_ zzQIfaD_|%)_RPCZAt3mj9PQ^OQSlSkPhxIwI@J;g#&jjOe=2k&q^f_2PQ}!{l`_s6p|h5gyhN!qepH;EjIx{4Z1N# z&bP!3cNR4b+>g`&bCu$QT4@I>nG7ZP!=JtCeXyG(BOW_RpLn;9T2pQTzE#;)#z8jH zM-^TAo;rfekRaPMK11{>Ah@}5EmroD&W==5I?BazOj@Tww-rIW<~yrrzWFS#G1#;@ z=Tm9=wzx@fAD3a1?<4k~C`3}UuLDU6c~*Kv_mvbm@6MwXiS^mXQ0LPK@u+lPb~+)} z>{Y<(yM#yBp#Lq98} zBzJB=AvN0MZXES)Cs9$RFE$o2!6;W7N}AC1fY5x8dyna?Y1)==nR%3U>+uA$p2KyC z7zLB9E7{eWFr)i%^=O|5Xp!oV>!Hk8P?~%-NMB6tLYhbo;de$~oLAB|%veuY zvg=hzF*OET?Ppg>7XmX0v%cc);)R`@j>N`M;;QBNvpyM56?#wc5yW}Dp;*7NpN_GL zV`aMj06izGmlRkW+96Xqy9=@aQ#65MlKMDsr|p}?alDfeyV-7z`^nD2k6$h`UMw2R z{>zt_5RczKaeO7b-=C|#*PH9@U{9X~BtGMM3r+rO`h1ZW$CG3vCt0Q8#(3l+VPmiR6Emg6H_>3ztfp>7j)~O9mu+1 zmdU!i1-_dva2uQ=LZYz$4RiX-yFN)xJ6j{GZ??|{lhG>Cj*rEt>X*&56$vC9*+y({ zYDw<4dQOoynH8&hJ<6ywZI84UFT#X=(#) z?|F)_?)B|tnsMu#hxok16ld@wBIVSF!f3?SL;9W04qU}<5jJW=Wmlqw+&2HayS6Zb zH!TC?xmL|KS|rbPeLsuZ*u)Ahd7ypZX!w66nl1>s0%tlW>|%2yj38kPT7X1)IcAm} zknY3<;3J5S9m)E%o%<(pwnNSNXga(SmYCWZQfgOo3_xJUC7Dl@?@HXZee-*x^N|(K zO_ru>Cvf#*d)JKkA$)2AQHQC_MJ=(syb^gYgL%0Fo-IB{wM{|3Hki(zk30DLdQFSY`sVFugkKYZDA6m)eCf;_U6s!=tl z%{t*u)Ns%vVYuMHox4u&0_-HWf#G*mIpl-?s{x-4x-cVMbmzbE~ zyS`G^<1~nb_x*GB!qb)|tU7NFJo>4u2mXiEO8e-h_Ug@A=`ESQ5!mu}l~YAAxCf!p z6a>B0i^_|CH%?-jsJ>SRQm9|l^gf}+lE}DyoA*3IM{xgg>SVqJQEdx+oGb5$l;#Ye zZG0s#;SOo@J)F|NW7;BW^^t{j;xM#94B`h+HK#)R=-*?=8dm-2b^(mns6?HI41V-6 zYrh+7P}VW+psz^5=cMjPj$>-@N@_qQUX7~lv7R2XxlnssL+GR6q)#VM53V=5#2+yQ zOpwS<>{AWCoSz~BGc7UE)G2Y0EcVp-1bOZ3bk~NK?Is$wY0vS|LQO6sdm9cwS0+-+ zEzbbvF7gYp<3Caj6s;SUy~G zS?JT)NF8}kNkSrhRqM%7QDIDZ?GphPnqu)y)g2D{vNlWlK#K_(v(>q(s^)9+P;2Q9 z74I_rNBFodQWdgN1BYc5pQwSHD8yF^}4Lvr+9yoWfDv4Vf%Pbg#S7q9$1EEJIWDO&=NlL_)~U9>J#4+e?8wb(7)fJ3;+F zgh}u3vnsuyFndXMCu6oIIZoG7X5pKlrt7O6;o~Au7h)sbB!|2BafN}Jlcesr!k%&; z`@+$UZ9_+?LD%gz-eqS)5El(F=l+{bYi(Vd;N6n=aZNE-d=Ofv}_uY z`OF3LZq>Gai~uj5<1b>Jj0|xOl0l8pGmMoJb*}Sp*U3jd;O^_Mfb8C82G>=*<%>oM zHFlGfrhuxr@w>@-Eo65RXeu!Je2(LzvvyP4R8n>ID4wuoB>2#{bZHw4yGjE@C;M2N z4I`y2Bp{}~#P;?m5l4cU?dtoW{&#-N!_soaK7U)9DqjS}$596TuNp@hY5NONVZFQv zL(#!~k1oy}D*K~DE?pO+vA)57z{ohgD)}elLuC;X*SA5~bSqaQVUC z3SyX7y)1b~ja(ds1cd#EUx8bP`O{X|D2STq{eD-T(BLXh=;Rc~5tFop)_=jtiJo}p z$0&C{`*Uz%g2AsIr3oFc6^7UHA|F1kvBqo1W&;)QoPt@|5fW^&_#qe_dBE5;0v-2Hv(d7Y8UnZ^~Vn z@xG6 zEG@=Px{K~iAdF0Q0K%2ek`9&B+iW?%*gbfK#}wl7qD$z)6?Rckf)N{7(Sl}d#gDWy z&?jBQfnO>pNIHT$pWnoCb2xEkci%ozL^;2e@PTKKw#aq7(xgCsB~{{Ry1@i)ZQ*Tf zu*s$PLLjv>LPQ+fLYb*~4HgNDRhd3{INee}zi)|-#vQSLRCyw=A7zK9^?-s_l-S9gO z%OdlS1wB*^M_4N%C;+VIbW!_kvFlA4aOr|;yMB0>pS@R%ssBODZz2mE2>}ayG%r>X zUF=+~=X1qBGeJiodzWNR-HRrKc#fp8Lws{4M)*T#wPUWmrP-eey8&)Z=!VVPiooI2 zB0E(mx>hk~NnO=E9ZgHl0=p%U3^491rS!XXy4De;gJPKeK8?T_D34D6l`x+zh49G} z2#a-nscbvVAz#r6Q$U6wy)SK<+?E#14C^=@(DLqDTjAs0O_8shG@zj-7gI zQl7*Vb}KINp(CAU%Z2y#>2}8bbgTL^QB}p+y!s^up?tvyXYbBEoDB#n^Js3WzA~8W zE2r32mrog8pAl&eC+q@Gy3$?dDAAbY8j}Wmue*^mLUxp679G}ZBCA$7IovgJ;00Z< zQ%V#LJb2$9BT8IHM`Q_ErRR(y9(^9aDV8Nn{j>(*R5qT}GCF3DH6={oc1V>eVSFsl ze(Y%cPK%jIuM&~s3t6u52sjyV#``$wsN2{qUJ*8t|E{zvWEbP}26rP%aTO`ncg zDCHz{agy)dLI3KiWdC#6_3UB-?$$~q;us_Zd{lkozM!}#agcDK`%#x!56DB6>+%Jj ziw*OVH@7&-DrvEh;qpLjHA=huP3*eSn{@hcT-}}(z|SkL`-)HTJEY*hqXk!rMNj=j zCYUY7r2AsUth=_+-i8jeXQ;~?S4E1Hoz|9|1mnBjLJm4z?cLhS@AI)O;;NRv*^P-e z@|B@tcFMf2cyP%*rSt7#N!bI`^52++uTsH*TRx;1)q#tyxzVwoMyJgDgUtpVt1P;I zBGbz9e*Yq`ex=~Gj8xp{q1m}6LAp`&al1Inh|!vl7bK&X0(GXVw z^2Df8!u~7d_9ukYj`KfZ=cI>?PZ$Zdz7p3=1x&dkN%;oy=2g%p< zv44L4K1fQ&^bW!R%U%Rrl|66HiY9-2>J8e}&-@#$dSM;ts~vYG`5DUqDWxKi)=kqKJA|3o%CLEy1ln`MO#RtboJG)Z%`*iX9Y3 zgYBg+c*f^8p-yE{ahWtaN_zxQ>YdtblTeV+MqcW36*oSB_-=6(L$qJ7b1 zXjU?n;oR5CRg#8VpgLs<_d1LADuA}yL_i8#8 z(H&OsM9hielKjd~E+aS){uq6r`dVTQ)VRp*{lYSIR%~Eg)6y+P>35A(eKGe;0@}wj z!Ssn5iRJ?!U&U8K(J=sGwoJidWE5QCl2 z=OsEJv(=Cpt zF}Gaq#WOtu(ore|mdF|KXAwi{DJCicJhO&gp`hD~@!&VQ*a_5!I}kRFGUG41mzf%C zQPNxH7T_Qc&+c9x?;ow-nY^{^-&4gzciVsGx)eN{Fx5rcBvf|jjQDDTHiOq4rdOJg z9P>GUwrzB$w+?mobR9YVWR^6>lPy%+Znu7-ENCMn$&zw@Z$XcmLdTM^B1cW0@hSt> z{_!3*kh~JPMg3!A0*{0UxblO{K@=38m1JgSJP#Nw7BIHu08V8s+#jBRYEL*PAL}3O zNPNV?k0i)l_X3?~L(>Z*CDy7ho?co}zHPjV6?S)j)9(Cpic+TkYN_x;BMBsp8!qnj zw3F1-UR6OcValqNy!FXG&XBPab@Z^!#l#!as(k`9raRmHLZ~sfxCh;Mmbq#|5$H}h z!I#`DU&uTl{A!~VxJ-;vE~FTBDzYrXpP1JcUXF|oNGd!CnBg^ZapGi@(?SnUOopHb znzsBXzMDM>j%X?vza8)tx;o%)l6YTYg88&`LgyxmofzpRWN~*v!}w6DZmWW@_T=16 z--EW-u%IeiW?TK{QPhl6!q7tWM&O|Yinvn6(2@W**>g+$xN-`{cruMF%aJ=GNQ>Jy z7I#3RcZSLsbJ7gAoFF^z(kGHp{6i{*Jp zO36Qj`KTF*>!ADh916~1uwnGMEv{UX4BiLvm)g7PeWT$i`Hvgs(JUSs*U|Mdvqdp` z9i`!r2DWe6ETni?KRPK+o`=NqV5Nnf@|x@QcaWx^SfYwN+*Tu8}Dbu`&JIyQu7V8dZ#YMu(GgT zX0Sxq%CpeJ7&e`xyP-L&w0utrS+zTVmY{d-&xht&=Sm*|51%|I;|wu6%PgO4r}K?S zbVGWn~H*%>4la=jsCixD-xVP9paa= z&d2vSU~w7H^tJHZbW$e z+n46eN@mI6OJD4Nwy#3<+jTx~US>L)Aj1cq2dS!qk8l$}Tm5MTDBy8p7V70M#IufQ zq;R?7gw7ev{s7I(_Rl_jpnCG z&kc{hQXQ?S{c!bz(fo)?X?FfMT$d$wsQz)St)#sZ5p|cFI_i!V?rY;Y7az|w2+#b- zb2m}e*Rs@;PoK6YQ-o1ENCD}?I;pB7NRF)hL^1oO-7%7yU@QF0>dt1~3CZ7e6HG`G zAUu7SZmu+0cW3mYYYFIY?CF&l|KZq~3w%&GB8GfD>y>g zOZ)BjHIZ8bbYsgYjx?SQbA`TL=t<9A)~D;P8C<)Co3(!$9nHEq*}UKNjY4nfJ1es8 zMlLdVzk1Z0&s=(Glq8x58H-d8RY}}2B+}w*5{zpfJo;HdpTA}5U z&t&90z0P#l`59&SDsWp6m+&(L&l9YEzoYi8>zR41I?qEV&7MYDLperTq#-Q2++0Sy zbuJTPR^DI&4QjKLaFBU76j>h>@#kZ%$6Q5|&~~tjp{$O^+v*jEE4N^f^|N6O&_~pO zF-Q@8e9`oEx{K%7TvTe3nf!%v$M?iwam-^AF(&Q(84hTA-+s2HKcK%ksvT_O7O-y*K# z+7iXc__OWy-PM^=pjf(>6JSrJN98V_;9okL3>AEbuuX*#Ess58qHpy%E^p#Xy2GNA ztYGuHOWTH9+bBj6X3ueM1BqcC7iyI4n~ChzxW!mfSiQ!Pe+3ymqw(-R zejqa68@aj}qqw+-gX^)w&o^6Jy)qN5$sfwju0pVQ3wRAtq^i&xKTvSdwn`@q+%Jid z7*hxz+|L+9u6e5P5d=KFkdINhBB4h&D0(`r${Pwk@&6g`v8PYO$7$EExcy0m!ut1N z%4JdM*NJa1R_p(-knxJqI6`|vnYQuv-Db<+H=pc+SKU9;F8?yE zN;UGf_<}S|h&D5OiO)1_S^LlQdhv=fZdwi4E-im zj}r5>s{ZXn!?=$sd!Cqs#k;+8s~e)cl>eT;wWg#cC_h~~Jxx6&vLIE=j=4u>`z$k9 z&Nd@A>LCCjmMkfUI7TTmj`c&?}k;&4^GZSo(y~lpNT|6I*5K2 z&$VakU%}j67OA>E;leLr5(>_}eH#l0hF_@c+wS;gJ`xac%@olrd-BH6$zhmG2$%h8 zz{@70%PNN>AQ>s|=Cgi$R^)Gdj#%nxN${uiBl{6kG(=<@kLk7|fR28`fUU?0r2VDa?@M8~Ffu`7JL;CqOc@8&TXWu9XQl!q%vtFCl_mS^&;2}>4+l#N#*9-o9(d6cyy|3 zj&N+^my8sIaY~%D=}e2V3ngD}SQyREVA$%3!*W$Zt$vARmX{0U^czv5RE-*r|0S|a z#})bezR?VOOyAgR*zbuj{o$F0y)<6$+uKS$CpwjspCYx*oo%rr8gqw@+O{;R4FGlkRtL@7 zmZNpG@zS-(bqn5w>5vyK&15%~i$rxqk7;7DNnY*WPVWi@$3@{ESXYuFrhY$Z8tX6* z%s$HtH~M1xrM_mpC{N2MSU@{@vZrjnG@xk3VJj>RGj_3-(hvu8M%9Ra74xt+IHT=S z>EgQ5nY?^V_*dAW7oil((RTRei)TTor%Vy+^M=C2RE7m+?-FgJdJYAa`Yc$N0<0gV z6==y-AhF+cnGJ9viTdWz)>CySo>*)Fot({)i-%kjinYR-4aiZ036 z`Ap};8_{)p`%GS2)igq5*aIV(TEV3E=5U~W{wtVg%w$?0P>mE5)Ua?|S z3y`^81GTxN$bMK{s?73Ev`+b&NvQwY&tp5)!x8SWpVlSka;OvfJp_fKy)tdk7Eu~0NAFf(bf+Wb z;~y_C{BaqczZyahwHzu%^9$Dvq^by;)i{38Ohh^CA6mcFrJk3{hV2!LV2z;nJq&Tz zCCEm5{A<+g`;e4?H7NDk4D}_d>rWeXjdfaVx^5=sVyzETBAwqyjMgJRA&mK<9Lu#CIxwM9!Ev1( zDZB7)o@Yge3Flu@E6oEtzPk{0tVW$=N7-6>#2X{y#`HuwLnBSCco1Hnv~k}jh)bbb zJq$YpR67KunJ!AeTRYSgG^Z8F0p-bA4X-}I)2Gx{f*Qm^RH$EkbCXk4)O{BIlQ8767gH8VjZ45qrHHn>a~WS42;>Q~#6}9kukNH0XFImE!csc6DoGLjmzZ z{Q!-{25URqOO)I^b#X?hi3kWXINPRuP77hLO=UMkYTPNQ2rS!%`R=e?^^k`3|XczWY6_^ib#a_od5L99{yw&u$KY*OgaONw8cQO zBFTvOFge?D71jAR0&{tOjp9SF3A)%SD0zvEmp<*5b++lw3j)D8asPic{=9AeQ_)fa zpt>9ggqfey{Vf;kXz^-}x9=yvz;)Zd2zc`rQ*i$I2corBK3_inmD>z@VCwKg+@XTJ zB(3{f3GO=i>a8(VS#Pp*H_RtDj~dtJvxZcZ5tzU8zIiyG95@lnrG+6;iMo^9iy!jJ zzz{jBGcLYGS6f00(;K(Am);o4ck|2JY7Tb{mZfGgMY~6-NYXEY28xNA(rX+)fkx*W z45;(d<~gMb)|)iE!*71C?!DGR1BV(wa$C5y@Q7ddCM~eJb4UjPXseioRzKF!<0DPT zs~P=f_bk^Oiu5`!y>Z)n^Z-S`WMt{0a9rxI6zOaCqZX(Ou%X&k1NN6ur0Lp5A6MzL z@zm@z8_x-jXLd=_-S#$@vX@K_J{%|>L_l8uof{&EyEjM;Nf4QsS=xlEIYd#xqctJ^ zZG{h|OPVgW=2h_2B&C{Zjfe5o>{QX0aMZd{;i-sPgNBEJM-k}34H223ejjncAXi*~ ze)t^&to02XC~K=q4mLDFlo6a?>41=YDD5O!K#{(5aeICk? z^0&2b|MRcp2B~Xg9D=KpG#Y}<7brz=_k8@0ApV)kfLO{EytghAGC`w3K;i~DIKPWx zydr?}{P%^$ha%yodRmwc5~e#&5jq}Ly>(8s{U`hX=FztyQeUvkm3X~H5>9M@%^Ct) z$K$woFo}rcoPTe@&E_g^@S|`Vi`sQl!4u$W0anrp3WV^ zP5v1);y_VJ)EJLo49XbWr`<%ozstsdRVpgc)0Y!$z+oFzW;Rh$~b|CdFTyz4KVK@|FjjC&r4vtda6wYJ@%1}8Ea4E+KUP6dw`th&zN1OfUM3d6~xp*}i2 z&bT52>pjLRt`YSkE+Y!o&=+mkDC!Y|9Cg7kc3pq<;V|-4rAZf@4;VH(3c(Kouj@Pp z^FaLT7arC0=|S3${7+ckv4~k&6GJraZ@HPDfRB3@_z6)8&1VSoIAZs5x*1JnL+Ijl zTwuI=xkS-Caej_O@GxFC6n>@fW06XcYeC4Z;PPYYj4|}gY}2F}N0RF{$u_b?^@*;9CQ(L0vhtD}g@8xzTzw&>H+U-yo>~eD|KWAE7n7CM*}=L%D1y!7qp?7)iwSfn zoVxM@*j$Fbn_*%4o&&g)f7_Fnx)AY`@=A{e~)% z8E}xoDQjBbS0JcC_=PCx5m;<40nSy~oFu9Un(&E3G1r{Lpt@S6t)Ijs?f=~Ytu zRZDwIK1)Njyc{>(eF5iKQ!m;Pp)pHmkOgS^5CIT=ZRl4r-jBYjdzTSGW497oP4z00 zbLymb9mkTk4`_A`(s9?D@`A4$bkAF6ffT3(%QuQ{eFl&5I;R?lLBldZi(;HOY$$In zZa=CZhHyjr9Dx-B`YA0BpZ_!W07O^L4nHPho{sX&xT7cAp{#B|r$fW*H4DhhUSWd(jQ%^QfIb?4ass z4=I6K0rDij(9aWtF212bQwsI0qO+B*87W5>ZsNzs5p^*xVW(kHp{iSt(?*XEmwGRf zO19QZwhx4z&{f6rqYm@q%XBxYd5uV4%Q?xpWm|bRKC(8l(Xn3i_9|=i_kMLErbug< zj?2il@(RBO^un8J0qVJq8`ScU|Aey3ve-^&uT=lkV&*CqOU(ku(%@DX)j^Iq1>4=l zi){F%-|^*Y0dug5in6mw1C2n_wCOiqdx6mLty(O=gG6x56*p)!Xh*^Fwu7P>^VX#z zs7H_SZ!B$+j!|@EVPq+q#`;LBpjo>?hG5?MT&AcBQ8aqNOO9Lw-4Ih4LzfJx-biSA z^JjVj-Kq(Xw>zI^1XCuoMzj$>D)eGBXeNuq3Mr$@OQ6@&dG9|)^c;+>=*CQT;KD+dz+iEa^$aqrQWIuHW zp1~Tj56^vdniB+k?^^^dR{BNkML{3qdz886d|5oyF|&zn>`{65zWlv%+3bwW%m#EA zLw{qu-|F=VyuLfRRDB!LFTz-hNfyN;ibqdWUvA5Lz(EEe))oT$F`|J-4`Iv0)yXKo z*w_0+#lxzIq`9kK_{5G7L1&+w6G(*8Vh%H#yYECpJ2r0T#L(E1Qk90`ekhdhPqAxF z0s~icQXrrG-BS4vY%F*)CViweizvSvQ_R}?KoFXcKDx$Rp@*K-s)x-g5k2-gFlo;f zZP^uh;^4P!hm*|U?v=A*VQ?X|hyJpKQNAM`3@X}#M7aCp#Q!)(`N-j!841Ty-8b&K zv+wBVM)CurhuWU&Rx59lXOxX*G?_J7Ve`e-S)2R2+6^1g%FMaD>j?g`yNgyRG+pRX zg(%2HTmsbdRBKX<_PL$(z9vCUH5kPw3K zqRfbZp<~98cf2US4-t2=1;`l89A0HX{rtPt%X_*`C~kC|aT2+b#S^uoRxa;4ycu$& zwr!C{L^v2m6zR>maOe6oRNgs2G{9GVM8F7>XO=FglU{yVGYeSt^Y<^?Ci`|?2(vMc zymd1koiZFZ^?twntACPnx@+#dB-$>gmKCon1_T>o`gA=ZI}CIo)|ZA-@88*J!vO1oTGlL{gQXfJDxW1 z?#*q_-M?5Mu93Y(TNs}d^OJc$4`HU1*Uu9>_3|Z z9sI*H_<^)NC(BFGpzaAtMiW!}T8>fR_ZBEgQo0-aRtNNZcZ@{Vqk z8SwzQ%csMBSVuHz!K!sq=osM{Gl0k`v@VylJmZrG;VrU(O;myppXyQuZ60)>28gNB zOya%*R6-dQZ44wC#!g0Tyg{>A{}3+5~4bP+{(@WdqH8vf&iRobh}#C zcbK5(^A0=7*P_v0;xXZevYrW+ahb&GQ-6tiUD)RSbF*GtI{ZI7`S zI(h+8GH_xX{pXl;O-5NNo45@1;A=^+*<(j%r9yxGYy9T3G_^iP00WIX;XeeP6GeW1 z?xE%6nX|&m^@*;mvf?hb25E(?zubeZ0LyE}dF30KL#|h9_uA%aPC|0<0GoK@@9^Pr zSr4~!-S6wTRVT{ca(Ib%88)7=@nc74K!@0#4fYPPDKh-1fZZ(dZXLv_{VyfLaA3Ll zKvlCW47P+=r7|xIIi-%RNuY; diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index f1b71079..00000000 --- a/docs/index.md +++ /dev/null @@ -1,30 +0,0 @@ - - - -# Docker Compose - -Compose is a tool for defining and running multi-container Docker applications. To learn more about Compose refer to the following documentation: - -- [Compose Overview](overview.md) -- [Install Compose](install.md) -- [Getting Started](gettingstarted.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Frequently asked questions](faq.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) -- [Environment file](env-file.md) - -To see a detailed list of changes for past and current releases of Docker -Compose, please refer to the -[CHANGELOG](https://github.com/docker/compose/blob/master/CHANGELOG.md). diff --git a/docs/install.md b/docs/install.md deleted file mode 100644 index bb7f07b3..00000000 --- a/docs/install.md +++ /dev/null @@ -1,136 +0,0 @@ - - - -# Install Docker Compose - -You can run Compose on OS X, Windows and 64-bit Linux. To install it, you'll need to install Docker first. - -To install Compose, do the following: - -1. Install Docker Engine: - - * Mac OS X installation - - * Windows installation - - * Ubuntu installation - - * other system installations - -2. The Docker Toolbox installation includes both Engine and Compose, so Mac and Windows users are done installing. Others should continue to the next step. - -3. Go to the Compose repository release page on GitHub. - -4. Follow the instructions from the release page and run the `curl` command, -which the release page specifies, in your terminal. - - > Note: If you get a "Permission denied" error, your `/usr/local/bin` directory - probably isn't writable and you'll need to install Compose as the superuser. Run - `sudo -i`, then the two commands below, then `exit`. - - The following is an example command illustrating the format: - - curl -L https://github.com/docker/compose/releases/download/1.8.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - - If you have problems installing with `curl`, see - [Alternative Install Options](#alternative-install-options). - -5. Apply executable permissions to the binary: - - $ chmod +x /usr/local/bin/docker-compose - -6. Optionally, install [command completion](completion.md) for the -`bash` and `zsh` shell. - -7. Test the installation. - - $ docker-compose --version - docker-compose version: 1.8.0 - - -## Alternative install options - -### Install using pip - -Compose can be installed from [pypi](https://pypi.python.org/pypi/docker-compose) -using `pip`. If you install using `pip` it is highly recommended that you use a -[virtualenv](https://virtualenv.pypa.io/en/latest/) because many operating systems -have python system packages that conflict with docker-compose dependencies. See -the [virtualenv tutorial](http://docs.python-guide.org/en/latest/dev/virtualenvs/) -to get started. - - $ pip install docker-compose - -> **Note:** pip version 6.0 or greater is required - -### Install as a container - -Compose can also be run inside a container, from a small bash script wrapper. -To install compose as a container run: - - $ curl -L https://github.com/docker/compose/releases/download/1.8.0/run.sh > /usr/local/bin/docker-compose - $ chmod +x /usr/local/bin/docker-compose - -## Master builds - -If you're interested in trying out a pre-release build you can download a -binary from https://dl.bintray.com/docker-compose/master/. Pre-release -builds allow you to try out new features before they are released, but may -be less stable. - - -## Upgrading - -If you're upgrading from Compose 1.2 or earlier, you'll need to remove or migrate -your existing containers after upgrading Compose. This is because, as of version -1.3, Compose uses Docker labels to keep track of containers, and so they need to -be recreated with labels added. - -If Compose detects containers that were created without labels, it will refuse -to run so that you don't end up with two sets of them. If you want to keep using -your existing containers (for example, because they have data volumes you want -to preserve) you can use compose 1.5.x to migrate them with the following command: - - $ docker-compose migrate-to-labels - -Alternatively, if you're not worried about keeping them, you can remove them. -Compose will just create new ones. - - $ docker rm -f -v myapp_web_1 myapp_db_1 ... - - -## Uninstallation - -To uninstall Docker Compose if you installed using `curl`: - - $ rm /usr/local/bin/docker-compose - - -To uninstall Docker Compose if you installed using `pip`: - - $ pip uninstall docker-compose - ->**Note**: If you get a "Permission denied" error using either of the above ->methods, you probably do not have the proper permissions to remove ->`docker-compose`. To force the removal, prepend `sudo` to either of the above ->commands and run again. - - -## Where to go next - -- [User guide](index.md) -- [Getting Started](gettingstarted.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/link-env-deprecated.md b/docs/link-env-deprecated.md deleted file mode 100644 index b1f01b3b..00000000 --- a/docs/link-env-deprecated.md +++ /dev/null @@ -1,48 +0,0 @@ - - -# Link environment variables reference - -> **Note:** Environment variables are no longer the recommended method for connecting to linked services. Instead, you should use the link name (by default, the name of the linked service) as the hostname to connect to. See the [docker-compose.yml documentation](compose-file.md#links) for details. -> -> Environment variables will only be populated if you're using the [legacy version 1 Compose file format](compose-file.md#versioning). - -Compose uses [Docker links](/engine/userguide/networking/default_network/dockerlinks.md) -to expose services' containers to one another. Each linked container injects a set of -environment variables, each of which begins with the uppercase name of the container. - -To see what environment variables are available to a service, run `docker-compose run SERVICE env`. - -name\_PORT
-Full URL, e.g. `DB_PORT=tcp://172.17.0.5:5432` - -name\_PORT\_num\_protocol
-Full URL, e.g. `DB_PORT_5432_TCP=tcp://172.17.0.5:5432` - -name\_PORT\_num\_protocol\_ADDR
-Container's IP address, e.g. `DB_PORT_5432_TCP_ADDR=172.17.0.5` - -name\_PORT\_num\_protocol\_PORT
-Exposed port number, e.g. `DB_PORT_5432_TCP_PORT=5432` - -name\_PORT\_num\_protocol\_PROTO
-Protocol (tcp or udp), e.g. `DB_PORT_5432_TCP_PROTO=tcp` - -name\_NAME
-Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` - -## Related Information - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/networking.md b/docs/networking.md deleted file mode 100644 index 9739a088..00000000 --- a/docs/networking.md +++ /dev/null @@ -1,154 +0,0 @@ - - - -# Networking in Compose - -> **Note:** This document only applies if you're using [version 2 of the Compose file format](compose-file.md#versioning). Networking features are not supported for version 1 (legacy) Compose files. - -By default Compose sets up a single -[network](https://docs.docker.com/engine/reference/commandline/network_create/) for your app. Each -container for a service joins the default network and is both *reachable* by -other containers on that network, and *discoverable* by them at a hostname -identical to the container name. - -> **Note:** Your app's network is given a name based on the "project name", -> which is based on the name of the directory it lives in. You can override the -> project name with either the [`--project-name` -> flag](reference/overview.md) or the [`COMPOSE_PROJECT_NAME` environment -> variable](reference/envvars.md#compose-project-name). - -For example, suppose your app is in a directory called `myapp`, and your `docker-compose.yml` looks like this: - - version: '2' - - services: - web: - build: . - ports: - - "8000:8000" - db: - image: postgres - -When you run `docker-compose up`, the following happens: - -1. A network called `myapp_default` is created. -2. A container is created using `web`'s configuration. It joins the network - `myapp_default` under the name `web`. -3. A container is created using `db`'s configuration. It joins the network - `myapp_default` under the name `db`. - -Each container can now look up the hostname `web` or `db` and -get back the appropriate container's IP address. For example, `web`'s -application code could connect to the URL `postgres://db:5432` and start -using the Postgres database. - -Because `web` explicitly maps a port, it's also accessible from the outside world via port 8000 on your Docker host's network interface. - -## Updating containers - -If you make a configuration change to a service and run `docker-compose up` to update it, the old container will be removed and the new one will join the network under a different IP address but the same name. Running containers will be able to look up that name and connect to the new address, but the old address will stop working. - -If any containers have connections open to the old container, they will be closed. It is a container's responsibility to detect this condition, look up the name again and reconnect. - -## Links - -Links allow you to define extra aliases by which a service is reachable from another service. They are not required to enable services to communicate - by default, any service can reach any other service at that service's name. In the following example, `db` is reachable from `web` at the hostnames `db` and `database`: - - version: '2' - services: - web: - build: . - links: - - "db:database" - db: - image: postgres - -See the [links reference](compose-file.md#links) for more information. - -## Multi-host networking - -When [deploying a Compose application to a Swarm cluster](swarm.md), you can make use of the built-in `overlay` driver to enable multi-host communication between containers with no changes to your Compose file or application code. - -Consult the [Getting started with multi-host networking](https://docs.docker.com/engine/userguide/networking/get-started-overlay/) to see how to set up a Swarm cluster. The cluster will use the `overlay` driver by default, but you can specify it explicitly if you prefer - see below for how to do this. - -## Specifying custom networks - -Instead of just using the default app network, you can specify your own networks with the top-level `networks` key. This lets you create more complex topologies and specify [custom network drivers](https://docs.docker.com/engine/extend/plugins_network/) and options. You can also use it to connect services to externally-created networks which aren't managed by Compose. - -Each service can specify what networks to connect to with the *service-level* `networks` key, which is a list of names referencing entries under the *top-level* `networks` key. - -Here's an example Compose file defining two custom networks. The `proxy` service is isolated from the `db` service, because they do not share a network in common - only `app` can talk to both. - - version: '2' - - services: - proxy: - build: ./proxy - networks: - - front - app: - build: ./app - networks: - - front - - back - db: - image: postgres - networks: - - back - - networks: - front: - # Use a custom driver - driver: custom-driver-1 - back: - # Use a custom driver which takes special options - driver: custom-driver-2 - driver_opts: - foo: "1" - bar: "2" - -Networks can be configured with static IP addresses by setting the [ipv4_address and/or ipv6_address](compose-file.md#ipv4-address-ipv6-address) for each attached network. - -For full details of the network configuration options available, see the following references: - -- [Top-level `networks` key](compose-file.md#network-configuration-reference) -- [Service-level `networks` key](compose-file.md#networks) - -## Configuring the default network - -Instead of (or as well as) specifying your own networks, you can also change the settings of the app-wide default network by defining an entry under `networks` named `default`: - - version: '2' - - services: - web: - build: . - ports: - - "8000:8000" - db: - image: postgres - - networks: - default: - # Use a custom driver - driver: custom-driver-1 - -## Using a pre-existing network - -If you want your containers to join a pre-existing network, use the [`external` option](compose-file.md#network-configuration-reference): - - networks: - default: - external: - name: my-pre-existing-network - -Instead of attemping to create a network called `[projectname]_default`, Compose will look for a network called `my-pre-existing-network` and connect your app's containers to it. diff --git a/docs/overview.md b/docs/overview.md deleted file mode 100644 index ef07a45b..00000000 --- a/docs/overview.md +++ /dev/null @@ -1,188 +0,0 @@ - - - -# Overview of Docker Compose - -Compose is a tool for defining and running multi-container Docker applications. -With Compose, you use a Compose file to configure your application's services. -Then, using a single command, you create and start all the services -from your configuration. To learn more about all the features of Compose -see [the list of features](#features). - -Compose is great for development, testing, and staging environments, as well as -CI workflows. You can learn more about each case in -[Common Use Cases](#common-use-cases). - -Using Compose is basically a three-step process. - -1. Define your app's environment with a `Dockerfile` so it can be reproduced -anywhere. - -2. Define the services that make up your app in `docker-compose.yml` -so they can be run together in an isolated environment. - -3. Lastly, run -`docker-compose up` and Compose will start and run your entire app. - -A `docker-compose.yml` looks like this: - - version: '2' - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - - logvolume01:/var/log - links: - - redis - redis: - image: redis - volumes: - logvolume01: {} - -For more information about the Compose file, see the -[Compose file reference](compose-file.md) - -Compose has commands for managing the whole lifecycle of your application: - - * Start, stop and rebuild services - * View the status of running services - * Stream the log output of running services - * Run a one-off command on a service - -## Compose documentation - -- [Installing Compose](install.md) -- [Getting Started](gettingstarted.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Frequently asked questions](faq.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) - -## Features - -The features of Compose that make it effective are: - -* [Multiple isolated environments on a single host](#Multiple-isolated-environments-on-a-single-host) -* [Preserve volume data when containers are created](#preserve-volume-data-when-containers-are-created) -* [Only recreate containers that have changed](#only-recreate-containers-that-have-changed) -* [Variables and moving a composition between environments](#variables-and-moving-a-composition-between-environments) - -### Multiple isolated environments on a single host - -Compose uses a project name to isolate environments from each other. You can make use of this project name in several different contexts: - -* on a dev host, to create multiple copies of a single environment (e.g., you want to run a stable copy for each feature branch of a project) -* on a CI server, to keep builds from interfering with each other, you can set - the project name to a unique build number -* on a shared host or dev host, to prevent different projects, which may use the - same service names, from interfering with each other - -The default project name is the basename of the project directory. You can set -a custom project name by using the -[`-p` command line option](./reference/overview.md) or the -[`COMPOSE_PROJECT_NAME` environment variable](./reference/envvars.md#compose-project-name). - -### Preserve volume data when containers are created - -Compose preserves all volumes used by your services. When `docker-compose up` -runs, if it finds any containers from previous runs, it copies the volumes from -the old container to the new container. This process ensures that any data -you've created in volumes isn't lost. - - -### Only recreate containers that have changed - -Compose caches the configuration used to create a container. When you -restart a service that has not changed, Compose re-uses the existing -containers. Re-using containers means that you can make changes to your -environment very quickly. - - -### Variables and moving a composition between environments - -Compose supports variables in the Compose file. You can use these variables -to customize your composition for different environments, or different users. -See [Variable substitution](compose-file.md#variable-substitution) for more -details. - -You can extend a Compose file using the `extends` field or by creating multiple -Compose files. See [extends](extends.md) for more details. - - -## Common Use Cases - -Compose can be used in many different ways. Some common use cases are outlined -below. - -### Development environments - -When you're developing software, the ability to run an application in an -isolated environment and interact with it is crucial. The Compose command -line tool can be used to create the environment and interact with it. - -The [Compose file](compose-file.md) provides a way to document and configure -all of the application's service dependencies (databases, queues, caches, -web service APIs, etc). Using the Compose command line tool you can create -and start one or more containers for each dependency with a single command -(`docker-compose up`). - -Together, these features provide a convenient way for developers to get -started on a project. Compose can reduce a multi-page "developer getting -started guide" to a single machine readable Compose file and a few commands. - -### Automated testing environments - -An important part of any Continuous Deployment or Continuous Integration process -is the automated test suite. Automated end-to-end testing requires an -environment in which to run tests. Compose provides a convenient way to create -and destroy isolated testing environments for your test suite. By defining the full environment in a [Compose file](compose-file.md) you can create and destroy these environments in just a few commands: - - $ docker-compose up -d - $ ./run_tests - $ docker-compose down - -### Single host deployments - -Compose has traditionally been focused on development and testing workflows, -but with each release we're making progress on more production-oriented features. You can use Compose to deploy to a remote Docker Engine. The Docker Engine may be a single instance provisioned with -[Docker Machine](/machine/overview.md) or an entire -[Docker Swarm](/swarm/overview.md) cluster. - -For details on using production-oriented features, see -[compose in production](production.md) in this documentation. - - -## Release Notes - -To see a detailed list of changes for past and current releases of Docker -Compose, please refer to the -[CHANGELOG](https://github.com/docker/compose/blob/master/CHANGELOG.md). - -## Getting help - -Docker Compose is under active development. If you need help, would like to -contribute, or simply want to talk about the project with like-minded -individuals, we have a number of open channels for communication. - -* To report bugs or file feature requests: please use the [issue tracker on Github](https://github.com/docker/compose/issues). - -* To talk about the project with people in real time: please join the - `#docker-compose` channel on freenode IRC. - -* To contribute code or documentation changes: please submit a [pull request on Github](https://github.com/docker/compose/pulls). - -For more information and resources, please visit the [Getting Help project page](https://docs.docker.com/opensource/get-help/). diff --git a/docs/production.md b/docs/production.md deleted file mode 100644 index cfb87293..00000000 --- a/docs/production.md +++ /dev/null @@ -1,88 +0,0 @@ - - - -## Using Compose in production - -When you define your app with Compose in development, you can use this -definition to run your application in different environments such as CI, -staging, and production. - -The easiest way to deploy an application is to run it on a single server, -similar to how you would run your development environment. If you want to scale -up your application, you can run Compose apps on a Swarm cluster. - -### Modify your Compose file for production - -You'll almost certainly want to make changes to your app configuration that are -more appropriate to a live environment. These changes may include: - -- Removing any volume bindings for application code, so that code stays inside - the container and can't be changed from outside -- Binding to different ports on the host -- Setting environment variables differently (e.g., to decrease the verbosity of - logging, or to enable email sending) -- Specifying a restart policy (e.g., `restart: always`) to avoid downtime -- Adding extra services (e.g., a log aggregator) - -For this reason, you'll probably want to define an additional Compose file, say -`production.yml`, which specifies production-appropriate -configuration. This configuration file only needs to include the changes you'd -like to make from the original Compose file. The additional Compose file -can be applied over the original `docker-compose.yml` to create a new configuration. - -Once you've got a second configuration file, tell Compose to use it with the -`-f` option: - - $ docker-compose -f docker-compose.yml -f production.yml up -d - -See [Using multiple compose files](extends.md#different-environments) for a more -complete example. - -### Deploying changes - -When you make changes to your app code, you'll need to rebuild your image and -recreate your app's containers. To redeploy a service called -`web`, you would use: - - $ docker-compose build web - $ docker-compose up --no-deps -d web - -This will first rebuild the image for `web` and then stop, destroy, and recreate -*just* the `web` service. The `--no-deps` flag prevents Compose from also -recreating any services which `web` depends on. - -### Running Compose on a single server - -You can use Compose to deploy an app to a remote Docker host by setting the -`DOCKER_HOST`, `DOCKER_TLS_VERIFY`, and `DOCKER_CERT_PATH` environment variables -appropriately. For tasks like this, -[Docker Machine](/machine/overview.md) makes managing local and -remote Docker hosts very easy, and is recommended even if you're not deploying -remotely. - -Once you've set up your environment variables, all the normal `docker-compose` -commands will work with no further configuration. - -### Running Compose on a Swarm cluster - -[Docker Swarm](/swarm/overview.md), a Docker-native clustering -system, exposes the same API as a single Docker host, which means you can use -Compose against a Swarm instance and run your apps across multiple hosts. - -Read more about the Compose/Swarm integration in the -[integration guide](swarm.md). - -## Compose documentation - -- [Installing Compose](install.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/rails.md b/docs/rails.md deleted file mode 100644 index 26777687..00000000 --- a/docs/rails.md +++ /dev/null @@ -1,174 +0,0 @@ - - -## Quickstart: Docker Compose and Rails - -This Quickstart guide will show you how to use Docker Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). - -### Define the project - -Start by setting up the three files you'll need to build the app. First, since -your app is going to run inside a Docker container containing all of its -dependencies, you'll need to define exactly what needs to be included in the -container. This is done using a file called `Dockerfile`. To begin with, the -Dockerfile consists of: - - FROM ruby:2.2.0 - RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs - RUN mkdir /myapp - WORKDIR /myapp - ADD Gemfile /myapp/Gemfile - ADD Gemfile.lock /myapp/Gemfile.lock - RUN bundle install - ADD . /myapp - -That'll put your application code inside an image that will build a container -with Ruby, Bundler and all your dependencies inside it. For more information on -how to write Dockerfiles, see the [Docker user guide](/engine/tutorials/dockerimages.md#building-an-image-from-a-dockerfile) and the [Dockerfile reference](/engine/reference/builder.md). - -Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. - - source 'https://rubygems.org' - gem 'rails', '4.2.0' - -You'll need an empty `Gemfile.lock` in order to build our `Dockerfile`. - - $ touch Gemfile.lock - -Finally, `docker-compose.yml` is where the magic happens. This file describes -the services that comprise your app (a database and a web app), how to get each -one's Docker image (the database just runs on a pre-made PostgreSQL image, and -the web app is built from the current directory), and the configuration needed -to link them together and expose the web app's port. - - version: '2' - services: - db: - image: postgres - web: - build: . - command: bundle exec rails s -p 3000 -b '0.0.0.0' - volumes: - - .:/myapp - ports: - - "3000:3000" - depends_on: - - db - -### Build the project - -With those three files in place, you can now generate the Rails skeleton app -using `docker-compose run`: - - $ docker-compose run web rails new . --force --database=postgresql --skip-bundle - -First, Compose will build the image for the `web` service using the `Dockerfile`. Then it'll run `rails new` inside a new container, using that image. Once it's done, you should have generated a fresh app: - - $ ls -l - total 56 - -rw-r--r-- 1 user staff 215 Feb 13 23:33 Dockerfile - -rw-r--r-- 1 user staff 1480 Feb 13 23:43 Gemfile - -rw-r--r-- 1 user staff 2535 Feb 13 23:43 Gemfile.lock - -rw-r--r-- 1 root root 478 Feb 13 23:43 README.rdoc - -rw-r--r-- 1 root root 249 Feb 13 23:43 Rakefile - drwxr-xr-x 8 root root 272 Feb 13 23:43 app - drwxr-xr-x 6 root root 204 Feb 13 23:43 bin - drwxr-xr-x 11 root root 374 Feb 13 23:43 config - -rw-r--r-- 1 root root 153 Feb 13 23:43 config.ru - drwxr-xr-x 3 root root 102 Feb 13 23:43 db - -rw-r--r-- 1 user staff 161 Feb 13 23:35 docker-compose.yml - drwxr-xr-x 4 root root 136 Feb 13 23:43 lib - drwxr-xr-x 3 root root 102 Feb 13 23:43 log - drwxr-xr-x 7 root root 238 Feb 13 23:43 public - drwxr-xr-x 9 root root 306 Feb 13 23:43 test - drwxr-xr-x 3 root root 102 Feb 13 23:43 tmp - drwxr-xr-x 3 root root 102 Feb 13 23:43 vendor - - -If you are running Docker on Linux, the files `rails new` created are owned by -root. This happens because the container runs as the root user. Change the -ownership of the the new files. - - sudo chown -R $USER:$USER . - -If you are running Docker on Mac or Windows, you should already have ownership -of all files, including those generated by `rails new`. List the files just to -verify this. - -Uncomment the line in your new `Gemfile` which loads `therubyracer`, so you've -got a Javascript runtime: - - gem 'therubyracer', platforms: :ruby - -Now that you've got a new `Gemfile`, you need to build the image again. (This, -and changes to the Dockerfile itself, should be the only times you'll need to -rebuild.) - - $ docker-compose build - - -### Connect the database - -The app is now bootable, but you're not quite there yet. By default, Rails -expects a database to be running on `localhost` - so you need to point it at the -`db` container instead. You also need to change the database and username to -align with the defaults set by the `postgres` image. - -Replace the contents of `config/database.yml` with the following: - - development: &default - adapter: postgresql - encoding: unicode - database: postgres - pool: 5 - username: postgres - password: - host: db - - test: - <<: *default - database: myapp_test - -You can now boot the app with: - - $ docker-compose up - -If all's well, you should see some PostgreSQL output, and then—after a few -seconds—the familiar refrain: - - myapp_web_1 | [2014-01-17 17:16:29] INFO WEBrick 1.3.1 - myapp_web_1 | [2014-01-17 17:16:29] INFO ruby 2.2.0 (2014-12-25) [x86_64-linux-gnu] - myapp_web_1 | [2014-01-17 17:16:29] INFO WEBrick::HTTPServer#start: pid=1 port=3000 - -Finally, you need to create the database. In another terminal, run: - - $ docker-compose run web rake db:create - -That's it. Your app should now be running on port 3000 on your Docker daemon. If you're using [Docker Machine](/machine/overview.md), then `docker-machine ip MACHINE_VM` returns the Docker host IP address. - -![Rails example](images/rails-welcome.png) - ->**Note**: If you stop the example application and attempt to restart it, you might get the -following error: `web_1 | A server is already running. Check -/myapp/tmp/pids/server.pid.` One way to resolve this is to delete the file -`tmp/pids/server.pid`, and then re-start the application with `docker-compose -up`. - - -## More Compose documentation - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Getting Started](gettingstarted.md) -- [Get started with Django](django.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/reference/build.md b/docs/reference/build.md deleted file mode 100644 index 84aefc25..00000000 --- a/docs/reference/build.md +++ /dev/null @@ -1,25 +0,0 @@ - - -# build - -``` -Usage: build [options] [SERVICE...] - -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. -``` - -Services are built once and then tagged as `project_service`, e.g., -`composetest_db`. If you change a service's Dockerfile or the contents of its -build directory, run `docker-compose build` to rebuild it. diff --git a/docs/reference/bundle.md b/docs/reference/bundle.md deleted file mode 100644 index fca93a8a..00000000 --- a/docs/reference/bundle.md +++ /dev/null @@ -1,31 +0,0 @@ - - -# bundle - -``` -Usage: bundle [options] - -Options: - --push-images Automatically push images for any services - which have a `build` option specified. - - -o, --output PATH Path to write the bundle file to. - Defaults to ".dab". -``` - -Generate a Distributed Application Bundle (DAB) from the Compose file. - -Images must have digests stored, which requires interaction with a -Docker registry. If digests aren't stored for all images, you can fetch -them with `docker-compose pull` or `docker-compose push`. To push images -automatically when bundling, pass `--push-images`. Only services with -a `build` option specified will have their images pushed. diff --git a/docs/reference/config.md b/docs/reference/config.md deleted file mode 100644 index 1a9706f4..00000000 --- a/docs/reference/config.md +++ /dev/null @@ -1,23 +0,0 @@ - - -# config - -```: -Usage: config [options] - -Options: --q, --quiet Only validate the configuration, don't print - anything. ---services Print the service names, one per line. -``` - -Validate and view the compose file. diff --git a/docs/reference/create.md b/docs/reference/create.md deleted file mode 100644 index 5065e8be..00000000 --- a/docs/reference/create.md +++ /dev/null @@ -1,26 +0,0 @@ - - -# create - -``` -Creates containers for a service. - -Usage: create [options] [SERVICE...] - -Options: - --force-recreate Recreate containers even if their configuration and - image haven't changed. Incompatible with --no-recreate. - --no-recreate If containers already exist, don't recreate them. - Incompatible with --force-recreate. - --no-build Don't build an image, even if it's missing. - --build Build images before creating containers. -``` diff --git a/docs/reference/down.md b/docs/reference/down.md deleted file mode 100644 index ffe88b4e..00000000 --- a/docs/reference/down.md +++ /dev/null @@ -1,38 +0,0 @@ - - -# down - -``` -Usage: down [options] - -Options: - --rmi type Remove images. Type must be one of: - 'all': Remove all images used by any service. - 'local': Remove only images that don't have a custom tag - set by the `image` field. - -v, --volumes Remove named volumes declared in the `volumes` section - of the Compose file and anonymous volumes - attached to containers. - --remove-orphans Remove containers for services not defined in the - Compose file -``` - -Stops containers and removes containers, networks, volumes, and images -created by `up`. - -By default, the only things removed are: - -- Containers for services defined in the Compose file -- Networks defined in the `networks` section of the Compose file -- The default network, if one is used - -Networks and volumes defined as `external` are never removed. diff --git a/docs/reference/envvars.md b/docs/reference/envvars.md deleted file mode 100644 index 22516deb..00000000 --- a/docs/reference/envvars.md +++ /dev/null @@ -1,92 +0,0 @@ - - - -# CLI Environment Variables - -Several environment variables are available for you to configure the Docker Compose command-line behaviour. - -Variables starting with `DOCKER_` are the same as those used to configure the -Docker command-line client. If you're using `docker-machine`, then the `eval "$(docker-machine env my-docker-vm)"` command should set them to their correct values. (In this example, `my-docker-vm` is the name of a machine you created.) - -> Note: Some of these variables can also be provided using an -> [environment file](../env-file.md) - -## COMPOSE\_PROJECT\_NAME - -Sets the project name. This value is prepended along with the service name to the container container on start up. For example, if you project name is `myapp` and it includes two services `db` and `web` then compose starts containers named `myapp_db_1` and `myapp_web_1` respectively. - -Setting this is optional. If you do not set this, the `COMPOSE_PROJECT_NAME` -defaults to the `basename` of the project directory. See also the `-p` -[command-line option](overview.md). - -## COMPOSE\_FILE - -Specify the path to a Compose file. If not provided, Compose looks for a file named -`docker-compose.yml` in the current directory and then each parent directory in -succession until a file by that name is found. - -This variable supports multiple compose files separate by a path separator (on -Linux and OSX the path separator is `:`, on Windows it is `;`). For example: -`COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml` - -See also the `-f` [command-line option](overview.md). - -## COMPOSE\_API\_VERSION - -The Docker API only supports requests from clients which report a specific -version. If you receive a `client and server don't have same version error` using -`docker-compose`, you can workaround this error by setting this environment -variable. Set the version value to match the server version. - -Setting this variable is intended as a workaround for situations where you need -to run temporarily with a mismatch between the client and server version. For -example, if you can upgrade the client but need to wait to upgrade the server. - -Running with this variable set and a known mismatch does prevent some Docker -features from working properly. The exact features that fail would depend on the -Docker client and server versions. For this reason, running with this variable -set is only intended as a workaround and it is not officially supported. - -If you run into problems running with this set, resolve the mismatch through -upgrade and remove this setting to see if your problems resolve before notifying -support. - -## DOCKER\_HOST - -Sets the URL of the `docker` daemon. As with the Docker client, defaults to `unix:///var/run/docker.sock`. - -## DOCKER\_TLS\_VERIFY - -When set to anything other than an empty string, enables TLS communication with -the `docker` daemon. - -## DOCKER\_CERT\_PATH - -Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. - -## COMPOSE\_HTTP\_TIMEOUT - -Configures the time (in seconds) a request to the Docker daemon is allowed to hang before Compose considers -it failed. Defaults to 60 seconds. - -## COMPOSE\_TLS\_VERSION - -Configure which TLS version is used for TLS communication with the `docker` -daemon. Defaults to `TLSv1`. -Supported values are: `TLSv1`, `TLSv1_1`, `TLSv1_2`. - -## Related Information - -- [User guide](../index.md) -- [Installing Compose](../install.md) -- [Compose file reference](../compose-file.md) -- [Environment file](../env-file.md) diff --git a/docs/reference/events.md b/docs/reference/events.md deleted file mode 100644 index 827258f2..00000000 --- a/docs/reference/events.md +++ /dev/null @@ -1,34 +0,0 @@ - - -# events - -``` -Usage: events [options] [SERVICE...] - -Options: - --json Output events as a stream of json objects -``` - -Stream container events for every container in the project. - -With the `--json` flag, a json object will be printed one per line with the -format: - -``` -{ - "service": "web", - "event": "create", - "container": "213cf75fc39a", - "image": "alpine:edge", - "time": "2015-11-20T18:01:03.615550", -} -``` diff --git a/docs/reference/exec.md b/docs/reference/exec.md deleted file mode 100644 index 6c0eeb04..00000000 --- a/docs/reference/exec.md +++ /dev/null @@ -1,29 +0,0 @@ - - -# exec - -``` -Usage: exec [options] SERVICE COMMAND [ARGS...] - -Options: --d Detached mode: Run command in the background. ---privileged Give extended privileges to the process. ---user USER Run the command as this user. --T Disable pseudo-tty allocation. By default `docker-compose exec` - allocates a TTY. ---index=index index of the container if there are multiple - instances of a service [default: 1] -``` - -This is equivalent of `docker exec`. With this subcommand you can run arbitrary -commands in your services. Commands are by default allocating a TTY, so you can -do e.g. `docker-compose exec web sh` to get an interactive prompt. diff --git a/docs/reference/help.md b/docs/reference/help.md deleted file mode 100644 index 613708ed..00000000 --- a/docs/reference/help.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# help - -``` -Usage: help COMMAND -``` - -Displays help and usage instructions for a command. diff --git a/docs/reference/index.md b/docs/reference/index.md deleted file mode 100644 index 2ac3676a..00000000 --- a/docs/reference/index.md +++ /dev/null @@ -1,42 +0,0 @@ - - -## Compose command-line reference - -The following pages describe the usage information for the [docker-compose](overview.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. - -* [docker-compose](overview.md) -* [build](build.md) -* [config](config.md) -* [create](create.md) -* [down](down.md) -* [events](events.md) -* [help](help.md) -* [kill](kill.md) -* [logs](logs.md) -* [pause](pause.md) -* [port](port.md) -* [ps](ps.md) -* [pull](pull.md) -* [restart](restart.md) -* [rm](rm.md) -* [run](run.md) -* [scale](scale.md) -* [start](start.md) -* [stop](stop.md) -* [unpause](unpause.md) -* [up](up.md) - -## Where to go next - -* [CLI environment variables](envvars.md) -* [docker-compose Command](overview.md) diff --git a/docs/reference/kill.md b/docs/reference/kill.md deleted file mode 100644 index dc4bf23a..00000000 --- a/docs/reference/kill.md +++ /dev/null @@ -1,24 +0,0 @@ - - -# kill - -``` -Usage: kill [options] [SERVICE...] - -Options: --s SIGNAL SIGNAL to send to the container. Default signal is SIGKILL. -``` - -Forces running containers to stop by sending a `SIGKILL` signal. Optionally the -signal can be passed, for example: - - $ docker-compose kill -s SIGINT diff --git a/docs/reference/logs.md b/docs/reference/logs.md deleted file mode 100644 index 745d24f7..00000000 --- a/docs/reference/logs.md +++ /dev/null @@ -1,25 +0,0 @@ - - -# logs - -``` -Usage: logs [options] [SERVICE...] - -Options: ---no-color Produce monochrome output. --f, --follow Follow log output --t, --timestamps Show timestamps ---tail Number of lines to show from the end of the logs - for each container. -``` - -Displays log output from services. diff --git a/docs/reference/overview.md b/docs/reference/overview.md deleted file mode 100644 index d59fa565..00000000 --- a/docs/reference/overview.md +++ /dev/null @@ -1,127 +0,0 @@ - - - -# Overview of docker-compose CLI - -This page provides the usage information for the `docker-compose` Command. -You can also see this information by running `docker-compose --help` from the -command line. - -``` -Define and run multi-container applications with Docker. - -Usage: - docker-compose [-f=...] [options] [COMMAND] [ARGS...] - docker-compose -h|--help - -Options: - -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) - -p, --project-name NAME Specify an alternate project name (default: directory name) - --verbose Show more output - -v, --version Print version and exit - -H, --host HOST Daemon socket to connect to - - --tls Use TLS; implied by --tlsverify - --tlscacert CA_PATH Trust certs signed only by this CA - --tlscert CLIENT_CERT_PATH Path to TLS certificate file - --tlskey TLS_KEY_PATH Path to TLS key file - --tlsverify Use TLS and verify the remote - --skip-hostname-check 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) - -Commands: - build Build or rebuild services - config Validate and view the compose file - create Create services - down Stop and remove containers, networks, images, and volumes - events Receive real time events from containers - help Get help on a command - kill Kill containers - logs View output from containers - pause Pause services - port Print the public port for a port binding - ps List containers - pull Pulls service images - restart Restart services - rm Remove stopped containers - run Run a one-off command - scale Set number of containers for a service - start Start services - stop Stop services - unpause Unpause services - up Create and start containers - version Show the Docker-Compose version information - -``` - -The Docker Compose binary. You use this command to build and manage multiple -services in Docker containers. - -Use the `-f` flag to specify the location of a Compose configuration file. You -can supply multiple `-f` configuration files. When you supply multiple files, -Compose combines them into a single configuration. Compose builds the -configuration in the order you supply the files. Subsequent files override and -add to their successors. - -For example, consider this command line: - -``` -$ docker-compose -f docker-compose.yml -f docker-compose.admin.yml run backup_db` -``` - -The `docker-compose.yml` file might specify a `webapp` service. - -``` -webapp: - image: examples/web - ports: - - "8000:8000" - volumes: - - "/data" -``` - -If the `docker-compose.admin.yml` also specifies this same service, any matching -fields will override the previous file. New values, add to the `webapp` service -configuration. - -``` -webapp: - build: . - environment: - - DEBUG=1 -``` - -Use a `-f` with `-` (dash) as the filename to read the configuration from -stdin. When stdin is used all paths in the configuration are -relative to the current working directory. - -The `-f` flag is optional. If you don't provide this flag on the command line, -Compose traverses the working directory and its parent directories looking for a -`docker-compose.yml` and a `docker-compose.override.yml` file. You must -supply at least the `docker-compose.yml` file. If both files are present on the -same directory level, Compose combines the two files into a single configuration. -The configuration in the `docker-compose.override.yml` file is applied over and -in addition to the values in the `docker-compose.yml` file. - -See also the `COMPOSE_FILE` [environment variable](envvars.md#compose-file). - -Each configuration has a project name. If you supply a `-p` flag, you can -specify a project name. If you don't specify the flag, Compose uses the current -directory name. See also the `COMPOSE_PROJECT_NAME` [environment variable]( -envvars.md#compose-project-name) - - -## Where to go next - -* [CLI environment variables](envvars.md) diff --git a/docs/reference/pause.md b/docs/reference/pause.md deleted file mode 100644 index a0ffab03..00000000 --- a/docs/reference/pause.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# pause - -``` -Usage: pause [SERVICE...] -``` - -Pauses running containers of a service. They can be unpaused with `docker-compose unpause`. diff --git a/docs/reference/port.md b/docs/reference/port.md deleted file mode 100644 index c946a97d..00000000 --- a/docs/reference/port.md +++ /dev/null @@ -1,23 +0,0 @@ - - -# port - -``` -Usage: port [options] SERVICE PRIVATE_PORT - -Options: ---protocol=proto tcp or udp [default: tcp] ---index=index index of the container if there are multiple - instances of a service [default: 1] -``` - -Prints the public port for a port binding. diff --git a/docs/reference/ps.md b/docs/reference/ps.md deleted file mode 100644 index 546d68e7..00000000 --- a/docs/reference/ps.md +++ /dev/null @@ -1,21 +0,0 @@ - - -# ps - -``` -Usage: ps [options] [SERVICE...] - -Options: --q Only display IDs -``` - -Lists containers. diff --git a/docs/reference/pull.md b/docs/reference/pull.md deleted file mode 100644 index 5ec184b7..00000000 --- a/docs/reference/pull.md +++ /dev/null @@ -1,21 +0,0 @@ - - -# pull - -``` -Usage: pull [options] [SERVICE...] - -Options: ---ignore-pull-failures Pull what it can and ignores images with pull failures. -``` - -Pulls service images. diff --git a/docs/reference/push.md b/docs/reference/push.md deleted file mode 100644 index bdc3112e..00000000 --- a/docs/reference/push.md +++ /dev/null @@ -1,21 +0,0 @@ - - -# push - -``` -Usage: push [options] [SERVICE...] - -Options: - --ignore-push-failures Push what it can and ignores images with push failures. -``` - -Pushes images for services. diff --git a/docs/reference/restart.md b/docs/reference/restart.md deleted file mode 100644 index bbd4a68b..00000000 --- a/docs/reference/restart.md +++ /dev/null @@ -1,21 +0,0 @@ - - -# restart - -``` -Usage: restart [options] [SERVICE...] - -Options: --t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) -``` - -Restarts services. diff --git a/docs/reference/rm.md b/docs/reference/rm.md deleted file mode 100644 index 6351e6cf..00000000 --- a/docs/reference/rm.md +++ /dev/null @@ -1,28 +0,0 @@ - - -# rm - -``` -Usage: rm [options] [SERVICE...] - -Options: - -f, --force Don't ask to confirm removal - -v Remove any anonymous volumes attached to containers - -a, --all Deprecated - no effect. -``` - -Removes stopped service containers. - -By default, anonymous volumes attached to containers will not be removed. You -can override this with `-v`. To list all volumes, use `docker volume ls`. - -Any data which is not in a volume will be lost. diff --git a/docs/reference/run.md b/docs/reference/run.md deleted file mode 100644 index 86354424..00000000 --- a/docs/reference/run.md +++ /dev/null @@ -1,56 +0,0 @@ - - -# run - -``` -Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] - -Options: --d Detached mode: Run container in the background, print - new container name. ---name NAME Assign a name to the container ---entrypoint CMD Override the entrypoint of the image. --e KEY=VAL Set an environment variable (can be used multiple times) --u, --user="" Run as specified username or uid ---no-deps Don't start linked services. ---rm Remove container after run. Ignored in detached mode. --p, --publish=[] Publish a container's port(s) to the host ---service-ports Run command with the service's ports enabled and mapped to the host. --T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. --w, --workdir="" Working directory inside the container -``` - -Runs a one-time command against a service. For example, the following command starts the `web` service and runs `bash` as its command. - - $ docker-compose run web bash - -Commands you use with `run` start in new containers with the same configuration as defined by the service' configuration. This means the container has the same volumes, links, as defined in the configuration file. There two differences though. - -First, the command passed by `run` overrides the command defined in the service configuration. For example, if the `web` service configuration is started with `bash`, then `docker-compose run web python app.py` overrides it with `python app.py`. - -The second difference is the `docker-compose run` command does not create any of the ports specified in the service configuration. This prevents the port collisions with already open ports. If you *do want* the service's ports created and mapped to the host, specify the `--service-ports` flag: - - $ docker-compose run --service-ports web python manage.py shell - -Alternatively manual port mapping can be specified. Same as when running Docker's `run` command - using `--publish` or `-p` options: - - $ docker-compose run --publish 8080:80 -p 2022:22 -p 127.0.0.1:2021:21 web python manage.py shell - -If you start a service configured with links, the `run` command first checks to see if the linked service is running and starts the service if it is stopped. Once all the linked services are running, the `run` executes the command you passed it. So, for example, you could run: - - $ docker-compose run db psql -h db -U docker - -This would open up an interactive PostgreSQL shell for the linked `db` container. - -If you do not want the `run` command to start linked containers, specify the `--no-deps` flag: - - $ docker-compose run --no-deps web python manage.py shell diff --git a/docs/reference/scale.md b/docs/reference/scale.md deleted file mode 100644 index 75140ee9..00000000 --- a/docs/reference/scale.md +++ /dev/null @@ -1,21 +0,0 @@ - - -# scale - -``` -Usage: scale [SERVICE=NUM...] -``` - -Sets the number of containers to run for a service. - -Numbers are specified as arguments in the form `service=num`. For example: - - $ docker-compose scale web=2 worker=3 diff --git a/docs/reference/start.md b/docs/reference/start.md deleted file mode 100644 index f0bdd5a9..00000000 --- a/docs/reference/start.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# start - -``` -Usage: start [SERVICE...] -``` - -Starts existing containers for a service. diff --git a/docs/reference/stop.md b/docs/reference/stop.md deleted file mode 100644 index ec7e6688..00000000 --- a/docs/reference/stop.md +++ /dev/null @@ -1,22 +0,0 @@ - - -# stop - -``` -Usage: stop [options] [SERVICE...] - -Options: --t, --timeout TIMEOUT Specify a shutdown timeout in seconds (default: 10). -``` - -Stops running containers without removing them. They can be started again with -`docker-compose start`. diff --git a/docs/reference/unpause.md b/docs/reference/unpause.md deleted file mode 100644 index 846b229e..00000000 --- a/docs/reference/unpause.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# unpause - -``` -Usage: unpause [SERVICE...] -``` - -Unpauses paused containers of a service. diff --git a/docs/reference/up.md b/docs/reference/up.md deleted file mode 100644 index 3951f879..00000000 --- a/docs/reference/up.md +++ /dev/null @@ -1,55 +0,0 @@ - - -# up - -``` -Usage: up [options] [SERVICE...] - -Options: - -d Detached mode: Run containers in the background, - print new container names. - Incompatible with --abort-on-container-exit. - --no-color Produce monochrome output. - --no-deps Don't start linked services. - --force-recreate Recreate containers even if their configuration - and image haven't changed. - Incompatible with --no-recreate. - --no-recreate If containers already exist, don't recreate them. - Incompatible with --force-recreate. - --no-build Don't build an image, even if it's missing. - --build Build images before starting containers. - --abort-on-container-exit Stops all containers if any container was stopped. - Incompatible with -d. - -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown - when attached or when containers are already - running. (default: 10) - --remove-orphans Remove containers for services not defined in - the Compose file - -``` - -Builds, (re)creates, starts, and attaches to containers for a service. - -Unless they are already running, this command also starts any linked services. - -The `docker-compose up` command aggregates the output of each container. When -the command exits, all containers are stopped. Running `docker-compose up -d` -starts the containers in the background and leaves them running. - -If there are existing containers for a service, and the service's configuration -or image was changed after the container's creation, `docker-compose up` picks -up the changes by stopping and recreating the containers (preserving mounted -volumes). To prevent Compose from picking up changes, use the `--no-recreate` -flag. - -If you want to force Compose to stop and recreate all containers, use the -`--force-recreate` flag. diff --git a/docs/startup-order.md b/docs/startup-order.md deleted file mode 100644 index c67e1829..00000000 --- a/docs/startup-order.md +++ /dev/null @@ -1,88 +0,0 @@ - - -# Controlling startup order in Compose - -You can control the order of service startup with the -[depends_on](compose-file.md#depends-on) option. Compose always starts -containers in dependency order, where dependencies are determined by -`depends_on`, `links`, `volumes_from` and `network_mode: "service:..."`. - -However, Compose will not wait until a container is "ready" (whatever that means -for your particular application) - only until it's running. There's a good -reason for this. - -The problem of waiting for a database (for example) to be ready is really just -a subset of a much larger problem of distributed systems. In production, your -database could become unavailable or move hosts at any time. Your application -needs to be resilient to these types of failures. - -To handle this, your application should attempt to re-establish a connection to -the database after a failure. If the application retries the connection, -it should eventually be able to connect to the database. - -The best solution is to perform this check in your application code, both at -startup and whenever a connection is lost for any reason. However, if you don't -need this level of resilience, you can work around the problem with a wrapper -script: - -- Use a tool such as [wait-for-it](https://github.com/vishnubob/wait-for-it) - or [dockerize](https://github.com/jwilder/dockerize). These are small - wrapper scripts which you can include in your application's image and will - poll a given host and port until it's accepting TCP connections. - - Supposing your application's image has a `CMD` set in its Dockerfile, you - can wrap it by setting the entrypoint in `docker-compose.yml`: - - version: "2" - services: - web: - build: . - ports: - - "80:8000" - depends_on: - - "db" - entrypoint: ./wait-for-it.sh db:5432 - db: - image: postgres - -- Write your own wrapper script to perform a more application-specific health - check. For example, you might want to wait until Postgres is definitely - ready to accept commands: - - #!/bin/bash - - set -e - - host="$1" - shift - cmd="$@" - - until psql -h "$host" -U "postgres" -c '\l'; do - >&2 echo "Postgres is unavailable - sleeping" - sleep 1 - done - - >&2 echo "Postgres is up - executing command" - exec $cmd - - You can use this as a wrapper script as in the previous example, by setting - `entrypoint: ./wait-for-postgres.sh db`. - - -## Compose documentation - -- [Installing Compose](install.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) diff --git a/docs/swarm.md b/docs/swarm.md deleted file mode 100644 index f956f8c2..00000000 --- a/docs/swarm.md +++ /dev/null @@ -1,185 +0,0 @@ - - - -# Using Compose with Swarm - -> **Note:** “Swarm” here refers to [Docker Swarm](/swarm/overview.md), a product separate from Docker Engine. It does _not_ refer to [swarm mode](/engine/swarm), which is a built-in feature of Docker Engine introduced in version 1.12. -> -> Integration between Compose and swarm mode is at the experimental stage. See [Docker Stacks and Bundles](bundles.md) for details. - -Docker Compose and [Docker Swarm](/swarm/overview.md) aim to have full integration, meaning -you can point a Compose app at a Swarm cluster and have it all just work as if -you were using a single Docker host. - -The actual extent of integration depends on which version of the [Compose file -format](compose-file.md#versioning) you are using: - -1. If you're using version 1 along with `links`, your app will work, but Swarm - will schedule all containers on one host, because links between containers - do not work across hosts with the old networking system. - -2. If you're using version 2, your app should work with no changes: - - - subject to the [limitations](#limitations) described below, - - - as long as the Swarm cluster is configured to use the [overlay driver](https://docs.docker.com/engine/userguide/networking/dockernetworks/#an-overlay-network), - or a custom driver which supports multi-host networking. - -Read [Get started with multi-host networking](https://docs.docker.com/engine/userguide/networking/get-started-overlay/) to see how to -set up a Swarm cluster with [Docker Machine](/machine/overview.md) and the overlay driver. Once you've got it running, deploying your app to it should be as simple as: - - $ eval "$(docker-machine env --swarm )" - $ docker-compose up - - -## Limitations - -### Building images - -Swarm can build an image from a Dockerfile just like a single-host Docker -instance can, but the resulting image will only live on a single node and won't -be distributed to other nodes. - -If you want to use Compose to scale the service in question to multiple nodes, -you'll have to build it yourself, push it to a registry (e.g. the Docker Hub) -and reference it from `docker-compose.yml`: - - $ docker build -t myusername/web . - $ docker push myusername/web - - $ cat docker-compose.yml - web: - image: myusername/web - - $ docker-compose up -d - $ docker-compose scale web=3 - -### Multiple dependencies - -If a service has multiple dependencies of the type which force co-scheduling -(see [Automatic scheduling](#automatic-scheduling) below), it's possible that -Swarm will schedule the dependencies on different nodes, making the dependent -service impossible to schedule. For example, here `foo` needs to be co-scheduled -with `bar` and `baz`: - - version: "2" - services: - foo: - image: foo - volumes_from: ["bar"] - network_mode: "service:baz" - bar: - image: bar - baz: - image: baz - -The problem is that Swarm might first schedule `bar` and `baz` on different -nodes (since they're not dependent on one another), making it impossible to -pick an appropriate node for `foo`. - -To work around this, use [manual scheduling](#manual-scheduling) to ensure that -all three services end up on the same node: - - version: "2" - services: - foo: - image: foo - volumes_from: ["bar"] - network_mode: "service:baz" - environment: - - "constraint:node==node-1" - bar: - image: bar - environment: - - "constraint:node==node-1" - baz: - image: baz - environment: - - "constraint:node==node-1" - -### Host ports and recreating containers - -If a service maps a port from the host, e.g. `80:8000`, then you may get an -error like this when running `docker-compose up` on it after the first time: - - docker: Error response from daemon: unable to find a node that satisfies - container==6ab2dfe36615ae786ef3fc35d641a260e3ea9663d6e69c5b70ce0ca6cb373c02. - -The usual cause of this error is that the container has a volume (defined either -in its image or in the Compose file) without an explicit mapping, and so in -order to preserve its data, Compose has directed Swarm to schedule the new -container on the same node as the old container. This results in a port clash. - -There are two viable workarounds for this problem: - -- Specify a named volume, and use a volume driver which is capable of mounting - the volume into the container regardless of what node it's scheduled on. - - Compose does not give Swarm any specific scheduling instructions if a - service uses only named volumes. - - version: "2" - - services: - web: - build: . - ports: - - "80:8000" - volumes: - - web-logs:/var/log/web - - volumes: - web-logs: - driver: custom-volume-driver - -- Remove the old container before creating the new one. You will lose any data - in the volume. - - $ docker-compose stop web - $ docker-compose rm -f web - $ docker-compose up web - - -## Scheduling containers - -### Automatic scheduling - -Some configuration options will result in containers being automatically -scheduled on the same Swarm node to ensure that they work correctly. These are: - -- `network_mode: "service:..."` and `network_mode: "container:..."` (and - `net: "container:..."` in the version 1 file format). - -- `volumes_from` - -- `links` - -### Manual scheduling - -Swarm offers a rich set of scheduling and affinity hints, enabling you to -control where containers are located. They are specified via container -environment variables, so you can use Compose's `environment` option to set -them. - - # Schedule containers on a specific node - environment: - - "constraint:node==node-1" - - # Schedule containers on a node that has the 'storage' label set to 'ssd' - environment: - - "constraint:storage==ssd" - - # Schedule containers where the 'redis' image is already pulled - environment: - - "affinity:image==redis" - -For the full set of available filters and expressions, see the [Swarm -documentation](/swarm/scheduler/filter.md). diff --git a/docs/wordpress.md b/docs/wordpress.md deleted file mode 100644 index b39a8bbb..00000000 --- a/docs/wordpress.md +++ /dev/null @@ -1,112 +0,0 @@ - - - -# Quickstart: Docker Compose and WordPress - -You can use Docker Compose to easily run WordPress in an isolated environment built -with Docker containers. This quick-start guide demonstrates how to use Compose to set up and run WordPress. Before starting, you'll need to have -[Compose installed](install.md). - -### Define the project - -1. Create an empty project directory. - - You can name the directory something easy for you to remember. This directory is the context for your application image. The directory should only contain resources to build that image. - - This project directory will contain a `docker-compose.yaml` file which will be complete in itself for a good starter wordpress project. - -2. Change directories into your project directory. - - For example, if you named your directory `my_wordpress`: - - $ cd my-wordpress/ - -3. Create a `docker-compose.yml` file that will start your `Wordpress` blog and a separate `MySQL` instance with a volume mount for data persistence: - - version: '2' - services: - db: - image: mysql:5.7 - volumes: - - "./.data/db:/var/lib/mysql" - restart: always - environment: - MYSQL_ROOT_PASSWORD: wordpress - MYSQL_DATABASE: wordpress - MYSQL_USER: wordpress - MYSQL_PASSWORD: wordpress - - wordpress: - depends_on: - - db - image: wordpress:latest - links: - - db - ports: - - "8000:80" - restart: always - environment: - WORDPRESS_DB_HOST: db:3306 - WORDPRESS_DB_PASSWORD: wordpress - - **NOTE**: The folder `./.data/db` will be automatically created in the project directory - alongside the `docker-compose.yml` which will persist any updates made by wordpress to the - database. - -### Build the project - -Now, run `docker-compose up -d` from your project directory. - -This pulls the needed images, and starts the wordpress and database containers, as shown in the example below. - - $ docker-compose up -d - Creating network "my_wordpress_default" with the default driver - Pulling db (mysql:5.7)... - 5.7: Pulling from library/mysql - efd26ecc9548: Pull complete - a3ed95caeb02: Pull complete - ... - Digest: sha256:34a0aca88e85f2efa5edff1cea77cf5d3147ad93545dbec99cfe705b03c520de - Status: Downloaded newer image for mysql:5.7 - Pulling wordpress (wordpress:latest)... - latest: Pulling from library/wordpress - efd26ecc9548: Already exists - a3ed95caeb02: Pull complete - 589a9d9a7c64: Pull complete - ... - Digest: sha256:ed28506ae44d5def89075fd5c01456610cd6c64006addfe5210b8c675881aff6 - Status: Downloaded newer image for wordpress:latest - Creating my_wordpress_db_1 - Creating my_wordpress_wordpress_1 - -### Bring up WordPress in a web browser - -If you're using [Docker Machine](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` gives you the machine address and you can open `http://MACHINE_VM_IP:8000` in a browser. - -At this point, WordPress should be running on port `8000` of your Docker Host, and you can complete the "famous five-minute installation" as a WordPress administrator. - -**NOTE**: The Wordpress site will not be immediately available on port `8000` because the containers are still being initialized and may take a couple of minutes before the first load. - -![Choose language for WordPress install](images/wordpress-lang.png) - -![WordPress Welcome](images/wordpress-welcome.png) - - -## More Compose documentation - -- [User guide](index.md) -- [Installing Compose](install.md) -- [Getting Started](gettingstarted.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Command line reference](./reference/index.md) -- [Compose file reference](compose-file.md) From a8ff4285d1bdcafb0a4c1bd176c052a9898ec1e8 Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Wed, 5 Oct 2016 16:19:09 -0700 Subject: [PATCH 472/580] updated README per vnext branch plan Signed-off-by: Victoria Bialas --- docs/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/README.md b/docs/README.md index 03d2e3a7..50c91d20 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,5 +6,11 @@ The documentation for Compose has been merged into The docs for Compose are now here: https://github.com/docker/docker.github.io/tree/master/compose +Please submit pull requests for unpublished features on the `vnext-compose` branch (https://github.com/docker/docker.github.io/tree/vnext-compose). + +If you submit a PR to this codebase that has a docs impact, create a second docs PR on `docker.github.io`. Use the docs PR template provided (coming soon - watch this space). + +PRs for typos, additional information, etc. for already-published features should be labeled as `okay-to-publish` (we are still settling on a naming convention, will provide a label soon). You can submit these PRs either to `vnext-compose` or directly to `master` on `docker.github.io` + As always, the docs remain open-source and we appreciate your feedback and pull requests! From b50b14f937455889fa0d62c451941d58b3cec124 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Sep 2016 13:13:04 -0700 Subject: [PATCH 473/580] Fix openssl dependency in OSX binary build Signed-off-by: Joffrey F --- script/setup/osx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 39941de2..e6ab62a8 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -10,12 +10,12 @@ openssl_version() { python -c "import ssl; print ssl.OPENSSL_VERSION" } -desired_python_version="2.7.9" -desired_python_brew_version="2.7.9" -python_formula="https://raw.githubusercontent.com/Homebrew/homebrew/1681e193e4d91c9620c4901efd4458d9b6fcda8e/Library/Formula/python.rb" +desired_python_version="2.7.12" +desired_python_brew_version="2.7.12" +python_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/737a2e34a89b213c1f0a2a24fc1a3c06635eed04/Formula/python.rb" -desired_openssl_version="1.0.2h" -desired_openssl_brew_version="1.0.2h" +desired_openssl_version="1.0.2j" +desired_openssl_brew_version="1.0.2j" openssl_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/30d3766453347f6e22b3ed6c74bb926d6def2eb5/Formula/openssl.rb" PATH="/usr/local/bin:$PATH" From 2bce81508effe82c0bca3603769bdd2cc1b8d00f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 6 Oct 2016 17:04:19 -0400 Subject: [PATCH 474/580] Support non-alphanumeric default values. Signed-off-by: Daniel Nephin --- compose/config/interpolation.py | 2 +- tests/unit/config/interpolation_test.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index cb841437..1b270b9e 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -72,7 +72,7 @@ def recursive_interpolate(obj, interpolator): class TemplateWithDefaults(Template): - idpattern = r'[_a-z][_a-z0-9]*(?::?-[_a-z0-9]+)?' + idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]+)?' # Modified from python2.7/string.py def substitute(self, mapping): diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 22444495..fd40153d 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -113,6 +113,7 @@ def test_interpolate_with_value(defaults_interpolator): def test_interpolate_missing_with_default(defaults_interpolator): assert defaults_interpolator("ok ${missing:-def}") == "ok def" assert defaults_interpolator("ok ${missing-def}") == "ok def" + assert defaults_interpolator("ok ${BAR:-/non:-alphanumeric}") == "ok /non:-alphanumeric" def test_interpolate_with_empty_and_default_value(defaults_interpolator): From 17c5b45641a4fd7a210d1b73a96d6e6357a8cb12 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 6 Oct 2016 15:39:47 -0700 Subject: [PATCH 475/580] Handle formatter case where logrecord message is binary containing unicode characters. Signed-off-by: Joffrey F --- compose/cli/formatter.py | 5 ++++- tests/unit/cli/formatter_test.py | 28 +++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index d0ed0f87..5f580645 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import logging import os +import six import texttable from compose.cli import colors @@ -44,5 +45,7 @@ class ConsoleWarningFormatter(logging.Formatter): return '' def format(self, record): + if isinstance(record.msg, six.binary_type): + record.msg = record.msg.decode('utf-8') message = super(ConsoleWarningFormatter, self).format(record) - return self.get_level_message(record) + message + return '{0}{1}'.format(self.get_level_message(record), message) diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py index 1c3b6a68..4aa025e6 100644 --- a/tests/unit/cli/formatter_test.py +++ b/tests/unit/cli/formatter_test.py @@ -11,8 +11,8 @@ from tests import unittest MESSAGE = 'this is the message' -def makeLogRecord(level): - return logging.LogRecord('name', level, 'pathame', 0, MESSAGE, (), None) +def make_log_record(level, message=None): + return logging.LogRecord('name', level, 'pathame', 0, message or MESSAGE, (), None) class ConsoleWarningFormatterTestCase(unittest.TestCase): @@ -21,15 +21,33 @@ class ConsoleWarningFormatterTestCase(unittest.TestCase): self.formatter = ConsoleWarningFormatter() def test_format_warn(self): - output = self.formatter.format(makeLogRecord(logging.WARN)) + output = self.formatter.format(make_log_record(logging.WARN)) expected = colors.yellow('WARNING') + ': ' assert output == expected + MESSAGE def test_format_error(self): - output = self.formatter.format(makeLogRecord(logging.ERROR)) + output = self.formatter.format(make_log_record(logging.ERROR)) expected = colors.red('ERROR') + ': ' assert output == expected + MESSAGE def test_format_info(self): - output = self.formatter.format(makeLogRecord(logging.INFO)) + output = self.formatter.format(make_log_record(logging.INFO)) assert output == MESSAGE + + def test_format_unicode_info(self): + message = b'\xec\xa0\x95\xec\x88\x98\xec\xa0\x95' + output = self.formatter.format(make_log_record(logging.INFO, message)) + print(output) + assert output == message.decode('utf-8') + + def test_format_unicode_warn(self): + message = b'\xec\xa0\x95\xec\x88\x98\xec\xa0\x95' + output = self.formatter.format(make_log_record(logging.WARN, message)) + expected = colors.yellow('WARNING') + ': ' + assert output == '{0}{1}'.format(expected, message.decode('utf-8')) + + def test_format_unicode_error(self): + message = b'\xec\xa0\x95\xec\x88\x98\xec\xa0\x95' + output = self.formatter.format(make_log_record(logging.ERROR, message)) + expected = colors.red('ERROR') + ': ' + assert output == '{0}{1}'.format(expected, message.decode('utf-8')) From e5ded6ff9b56835d9b40b3b6768658f02f347b07 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Sep 2016 12:51:01 -0700 Subject: [PATCH 476/580] Add support for enable_ipv6 in network definition. Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 3 +- compose/network.py | 5 ++- tests/integration/project_test.py | 45 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index de4ddf25..617f8ebe 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -246,7 +246,8 @@ "name": {"type": "string"} }, "additionalProperties": false - } + }, + "enable_ipv6": {"type": "boolean"} }, "additionalProperties": false }, diff --git a/compose/network.py b/compose/network.py index 8962a892..c3af9aa1 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None, internal=False): + ipam=None, external_name=None, internal=False, enable_ipv6=False): self.client = client self.project = project self.name = name @@ -24,6 +24,7 @@ class Network(object): self.ipam = create_ipam_config_from_dict(ipam) self.external_name = external_name self.internal = internal + self.enable_ipv6 = enable_ipv6 def ensure(self): if self.external_name: @@ -70,6 +71,7 @@ class Network(object): options=self.driver_opts, ipam=self.ipam, internal=self.internal, + enable_ipv6=self.enable_ipv6 ) def remove(self): @@ -118,6 +120,7 @@ def build_networks(name, config_data, client): ipam=data.get('ipam'), external_name=data.get('external_name'), internal=data.get('internal'), + enable_ipv6=data.get('enable_ipv6'), ) for network_name, data in network_config.items() } diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4427fe6b..94eac530 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -721,6 +721,51 @@ class ProjectTest(DockerClientTestCase): assert IPAMConfig.get('IPv4Address') == '172.16.100.100' assert IPAMConfig.get('IPv6Address') == 'fe80::1001:102' + @v2_1_only() + def test_up_with_enable_ipv6(self): + self.require_api_version('1.23') + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top', + 'networks': { + 'static_test': { + 'ipv6_address': 'fe80::1001:102' + } + }, + }], + volumes={}, + networks={ + 'static_test': { + 'driver': 'bridge', + 'enable_ipv6': True, + 'ipam': { + 'driver': 'default', + 'config': [ + {"subnet": "fe80::/64", + "gateway": "fe80::1001:1"} + ] + } + } + } + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + project.up(detached=True) + network = self.client.networks(names=['static_test'])[0] + service_container = project.get_service('web').containers()[0] + + assert network['EnableIPv6'] is True + ipam_config = (service_container.inspect().get('NetworkSettings', {}). + get('Networks', {}).get('composetest_static_test', {}). + get('IPAMConfig', {})) + assert ipam_config.get('IPv6Address') == 'fe80::1001:102' + @v2_only() def test_up_with_network_static_addresses_missing_subnet(self): config_data = config.Config( From 0603b445e28502fc27850ce16dbdb158b3089f7e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 4 Oct 2016 17:35:20 -0700 Subject: [PATCH 477/580] Properly merge logging dictionaries in overriding configs Signed-off-by: Joffrey F --- compose/config/config.py | 12 +++ tests/unit/config/config_test.py | 158 ++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index aea1e094..8582d83c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -760,6 +760,8 @@ def merge_service_dicts(base, override, version): for field in ['dns', 'dns_search', 'env_file', 'tmpfs']: md.merge_field(field, merge_list_or_string) + md.merge_field('logging', merge_logging) + for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -789,6 +791,16 @@ def merge_build(output, base, override): return dict(md) +def merge_logging(base, override): + md = MergeDict(base, override) + md.merge_scalar('driver') + if md.get('driver') == base.get('driver') or base.get('driver') is None: + md.merge_mapping('options', lambda m: m or {}) + else: + md['options'] = override.get('options') + return dict(md) + + def legacy_v1_merge_image_or_build(output, base, override): output.pop('image', None) output.pop('build', None) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 88b990e5..d9269ab4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1330,7 +1330,7 @@ class ConfigTest(unittest.TestCase): 'image': 'alpine', 'group_add': ["docker", 777] } - } + } })) assert actual.services == [ @@ -1429,6 +1429,162 @@ class ConfigTest(unittest.TestCase): 'command': 'true', } + def test_merge_logging_v2(self): + base = { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000', + 'timeout': '23' + } + } + } + override = { + 'logging': { + 'options': { + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000', + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + def test_merge_logging_v2_override_driver(self): + base = { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000', + 'timeout': '23' + } + } + } + override = { + 'logging': { + 'driver': 'syslog', + 'options': { + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'syslog', + 'options': { + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + def test_merge_logging_v2_no_base_driver(self): + base = { + 'image': 'alpine:edge', + 'logging': { + 'options': { + 'frequency': '2000', + 'timeout': '23' + } + } + } + override = { + 'logging': { + 'driver': 'json-file', + 'options': { + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000', + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + def test_merge_logging_v2_no_drivers(self): + base = { + 'image': 'alpine:edge', + 'logging': { + 'options': { + 'frequency': '2000', + 'timeout': '23' + } + } + } + override = { + 'logging': { + 'options': { + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'options': { + 'frequency': '2000', + 'timeout': '360', + 'pretty-print': 'on' + } + } + } + + def test_merge_logging_v2_no_override_options(self): + base = { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000', + 'timeout': '23' + } + } + } + override = { + 'logging': { + 'driver': 'syslog' + } + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'syslog', + 'options': None + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From bdcce13f4a50126ba42b76f70d1526dbe0ce3068 Mon Sep 17 00:00:00 2001 From: dbdd Date: Tue, 27 Sep 2016 11:02:56 +0800 Subject: [PATCH 478/580] add support for creating volume and network with label definition Signed-off-by: dbdd --- compose/config/config.py | 3 + compose/config/config_schema_v2.1.json | 4 +- compose/network.py | 5 +- compose/volume.py | 8 ++- tests/acceptance/cli_test.py | 41 ++++++++++++ tests/fixtures/networks/network-label.yml | 13 ++++ tests/fixtures/volumes/volume-label.yml | 13 ++++ tests/integration/project_test.py | 76 +++++++++++++++++++++++ tests/unit/config/config_test.py | 53 ++++++++++++++++ 9 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/networks/network-label.yml create mode 100644 tests/fixtures/volumes/volume-label.yml diff --git a/compose/config/config.py b/compose/config/config.py index 4d32b50c..b3e01778 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -361,6 +361,9 @@ def load_mapping(config_files, get_func, entity_type): config['driver_opts'] ) + if 'labels' in config: + config['labels'] = parse_labels(config['labels']) + return mapping diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index de4ddf25..980e4ba1 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -246,7 +246,8 @@ "name": {"type": "string"} }, "additionalProperties": false - } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, @@ -268,6 +269,7 @@ "name": {"type": "string"} } }, + "labels": {"$ref": "#/definitions/list_or_dict"}, "additionalProperties": false }, "additionalProperties": false diff --git a/compose/network.py b/compose/network.py index 8962a892..796836fe 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None, internal=False): + ipam=None, external_name=None, internal=False, labels=None): self.client = client self.project = project self.name = name @@ -24,6 +24,7 @@ class Network(object): self.ipam = create_ipam_config_from_dict(ipam) self.external_name = external_name self.internal = internal + self.labels = labels def ensure(self): if self.external_name: @@ -70,6 +71,7 @@ class Network(object): options=self.driver_opts, ipam=self.ipam, internal=self.internal, + labels=self.labels, ) def remove(self): @@ -118,6 +120,7 @@ def build_networks(name, config_data, client): ipam=data.get('ipam'), external_name=data.get('external_name'), internal=data.get('internal'), + labels=data.get('labels'), ) for network_name, data in network_config.items() } diff --git a/compose/volume.py b/compose/volume.py index f440ba40..1fd1d51c 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -12,17 +12,18 @@ log = logging.getLogger(__name__) class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - external_name=None): + external_name=None, labels=None): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts self.external_name = external_name + self.labels = labels def create(self): return self.client.create_volume( - self.full_name, self.driver, self.driver_opts + self.full_name, self.driver, self.driver_opts, labels=self.labels ) def remove(self): @@ -68,7 +69,8 @@ class ProjectVolumes(object): name=vol_name, driver=data.get('driver'), driver_opts=data.get('driver_opts'), - external_name=data.get('external_name') + external_name=data.get('external_name'), + labels=data.get('labels') ) for vol_name, data in config_volumes.items() } diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2247ffff..54737c7a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -22,6 +22,7 @@ from compose.project import OneOffFilter from tests.integration.testcases import DockerClientTestCase from tests.integration.testcases import get_links from tests.integration.testcases import pull_busybox +from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only @@ -767,6 +768,46 @@ class CLITestCase(DockerClientTestCase): container = self.project.containers()[0] assert list(container.get('NetworkSettings.Networks')) == [network_name] + @v2_1_only() + def test_up_with_network_labels(self): + filename = 'network-label.yml' + + self.base_dir = 'tests/fixtures/networks' + self._project = get_project(self.base_dir, [filename]) + + self.dispatch(['-f', filename, 'up', '-d'], returncode=0) + + network_with_label = '{}_network_with_label'.format(self.project.name) + + networks = [ + n for n in self.client.networks() + if n['Name'].startswith('{}_'.format(self.project.name)) + ] + + assert [n['Name'] for n in networks] == [network_with_label] + + assert networks[0]['Labels'] == {'label_key': 'label_val'} + + @v2_1_only() + def test_up_with_volume_labels(self): + filename = 'volume-label.yml' + + self.base_dir = 'tests/fixtures/volumes' + self._project = get_project(self.base_dir, [filename]) + + self.dispatch(['-f', filename, 'up', '-d'], returncode=0) + + volume_with_label = '{}_volume_with_label'.format(self.project.name) + + volumes = [ + v for v in self.client.volumes().get('Volumes', []) + if v['Name'].startswith('{}_'.format(self.project.name)) + ] + + assert [v['Name'] for v in volumes] == [volume_with_label] + + assert volumes[0]['Labels'] == {'label_key': 'label_val'} + @v2_only() def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' diff --git a/tests/fixtures/networks/network-label.yml b/tests/fixtures/networks/network-label.yml new file mode 100644 index 00000000..fdb24f65 --- /dev/null +++ b/tests/fixtures/networks/network-label.yml @@ -0,0 +1,13 @@ +version: "2.1" + +services: + web: + image: busybox + command: top + networks: + - network_with_label + +networks: + network_with_label: + labels: + - "label_key=label_val" diff --git a/tests/fixtures/volumes/volume-label.yml b/tests/fixtures/volumes/volume-label.yml new file mode 100644 index 00000000..a5f33a5a --- /dev/null +++ b/tests/fixtures/volumes/volume-label.yml @@ -0,0 +1,13 @@ +version: "2.1" + +services: + web: + image: busybox + command: top + volumes: + - volume_with_label:/data + +volumes: + volume_with_label: + labels: + - "label_key=label_val" diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4427fe6b..149facfe 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -821,6 +821,42 @@ class ProjectTest(DockerClientTestCase): assert network['Internal'] is True + @v2_1_only() + def test_project_up_with_network_label(self): + self.require_api_version('1.23') + + network_name = 'network_with_label' + + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': {network_name: None} + }], + volumes={}, + networks={ + network_name: {'labels': {'label_key': 'label_val'}} + } + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + + project.up() + + networks = [ + n for n in self.client.networks() + if n['Name'].startswith('composetest_') + ] + + assert [n['Name'] for n in networks] == ['composetest_{}'.format(network_name)] + + assert networks[0]['Labels'] == {'label_key': 'label_val'} + @v2_only() def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) @@ -847,6 +883,46 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') + @v2_1_only() + def test_project_up_with_volume_labels(self): + self.require_api_version('1.23') + + volume_name = 'volume_with_label' + + config_data = config.Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'volumes': [VolumeSpec.parse('{}:/data'.format(volume_name))] + }], + volumes={ + volume_name: { + 'labels': { + 'label_key': 'label_val' + } + } + }, + networks={}, + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + + project.up() + + volumes = [ + v for v in self.client.volumes().get('Volumes', []) + if v['Name'].startswith('composetest_') + ] + + assert [v['Name'] for v in volumes] == ['composetest_{}'.format(volume_name)] + + assert volumes[0]['Labels'] == {'label_key': 'label_val'} + @v2_only() def test_project_up_logging_with_multiple_files(self): base_file = config.ConfigFile( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 88b990e5..7a8832d2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -376,6 +376,59 @@ class ConfigTest(unittest.TestCase): } } + def test_load_config_volume_and_network_labels(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.1', + 'services': { + 'web': { + 'image': 'example/web', + }, + }, + 'networks': { + 'with_label': { + 'labels': { + 'label_key': 'label_val' + } + } + }, + 'volumes': { + 'with_label': { + 'labels': { + 'label_key': 'label_val' + } + } + } + } + ) + + details = config.ConfigDetails('.', [base_file]) + network_dict = config.load(details).networks + volume_dict = config.load(details).volumes + + self.assertEqual( + network_dict, + { + 'with_label': { + 'labels': { + 'label_key': 'label_val' + } + } + } + ) + + self.assertEqual( + volume_dict, + { + 'with_label': { + 'labels': { + 'label_key': 'label_val' + } + } + } + ) + def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: From 22d91f60bea78cbe232a1ac86ba747930ecf7d1a Mon Sep 17 00:00:00 2001 From: realityone Date: Fri, 14 Oct 2016 18:20:55 +0800 Subject: [PATCH 479/580] fix serialize restart spec with null string Signed-off-by: realityone --- compose/config/types.py | 2 ++ tests/acceptance/cli_test.py | 4 ++++ tests/fixtures/restart/docker-compose.yml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/compose/config/types.py b/compose/config/types.py index 9664b580..c450a0f9 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -93,6 +93,8 @@ def parse_restart_spec(restart_config): def serialize_restart_spec(restart_spec): + if not restart_spec: + return '' parts = [restart_spec['Name']] if restart_spec['MaximumRetryCount']: parts.append(six.text_type(restart_spec['MaximumRetryCount'])) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 0c7c17bd..e2c02798 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -236,6 +236,10 @@ class CLITestCase(DockerClientTestCase): 'image': 'busybox', 'restart': 'on-failure:5', }, + 'restart-null': { + 'image': 'busybox', + 'restart': '' + }, }, 'networks': {}, 'volumes': {}, diff --git a/tests/fixtures/restart/docker-compose.yml b/tests/fixtures/restart/docker-compose.yml index 2d10aa39..ecfdfbf5 100644 --- a/tests/fixtures/restart/docker-compose.yml +++ b/tests/fixtures/restart/docker-compose.yml @@ -12,3 +12,6 @@ services: on-failure-5: image: busybox restart: "on-failure:5" + restart-null: + image: busybox + restart: "" From 8b383ad79571fd15e47c88dff71e2a4836bd3ffd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 14 Oct 2016 17:27:57 +0100 Subject: [PATCH 480/580] Refactor "docker not found" message generation, add Windows message Signed-off-by: Aanand Prasad --- compose/cli/errors.py | 38 ++++++++++++++++++-------------------- compose/cli/utils.py | 5 +++++ 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index f9a20b9e..4fdec08a 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -17,6 +17,7 @@ from .utils import call_silently from .utils import is_docker_for_mac_installed from .utils import is_mac from .utils import is_ubuntu +from .utils import is_windows log = logging.getLogger(__name__) @@ -90,11 +91,7 @@ def exit_with_error(msg): def get_conn_error_message(url): if call_silently(['which', 'docker']) != 0: - if is_mac(): - return docker_not_found_mac - if is_ubuntu(): - return docker_not_found_ubuntu - return docker_not_found_generic + return docker_not_found_msg("Couldn't connect to Docker daemon.") if is_docker_for_mac_installed(): return conn_error_docker_for_mac if call_silently(['which', 'docker-machine']) == 0: @@ -102,25 +99,26 @@ def get_conn_error_message(url): return conn_error_generic.format(url=url) -docker_not_found_mac = """ - Couldn't connect to Docker daemon. You might need to install Docker: - - https://docs.docker.com/engine/installation/mac/ -""" +def docker_not_found_msg(problem): + return "{} You might need to install Docker:\n\n{}".format( + problem, docker_install_url()) -docker_not_found_ubuntu = """ - Couldn't connect to Docker daemon. You might need to install Docker: - - https://docs.docker.com/engine/installation/ubuntulinux/ -""" +def docker_install_url(): + if is_mac(): + return docker_install_url_mac + elif is_ubuntu(): + return docker_install_url_ubuntu + elif is_windows(): + return docker_install_url_windows + else: + return docker_install_url_generic -docker_not_found_generic = """ - Couldn't connect to Docker daemon. You might need to install Docker: - - https://docs.docker.com/engine/installation/ -""" +docker_install_url_mac = "https://docs.docker.com/engine/installation/mac/" +docker_install_url_ubuntu = "https://docs.docker.com/engine/installation/ubuntulinux/" +docker_install_url_windows = "https://docs.docker.com/engine/installation/windows/" +docker_install_url_generic = "https://docs.docker.com/engine/installation/" conn_error_docker_machine = """ diff --git a/compose/cli/utils.py b/compose/cli/utils.py index e10a3674..580bd1b0 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -11,6 +11,7 @@ import sys import docker import compose +from ..const import IS_WINDOWS_PLATFORM # WindowsError is not defined on non-win32 platforms. Avoid runtime errors by # defining it as OSError (its parent class) if missing. @@ -73,6 +74,10 @@ def is_ubuntu(): return platform.system() == 'Linux' and platform.linux_distribution()[0] == 'Ubuntu' +def is_windows(): + return IS_WINDOWS_PLATFORM + + def get_version_info(scope): versioninfo = 'docker-compose version {}, build {}'.format( compose.__version__, From 8314a48a2e96a0e34e913fb6b3a2973f1bceec5a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 26 Sep 2016 13:18:16 +0100 Subject: [PATCH 481/580] Attach interactively on Windows by shelling out Signed-off-by: Aanand Prasad --- compose/cli/main.py | 60 ++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 438753a2..cbbb1325 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -7,6 +7,7 @@ import functools import json import logging import re +import subprocess import sys from inspect import getdoc from operator import attrgetter @@ -406,11 +407,6 @@ class TopLevelCommand(object): service = self.project.get_service(options['SERVICE']) detach = options['-d'] - if IS_WINDOWS_PLATFORM and not detach: - raise UserError( - "Interactive mode is not yet supported on Windows.\n" - "Please pass the -d flag when using `docker-compose exec`." - ) try: container = service.get_container(number=index) except ValueError as e: @@ -418,6 +414,28 @@ class TopLevelCommand(object): command = [options['COMMAND']] + options['ARGS'] tty = not options["-T"] + if IS_WINDOWS_PLATFORM and not detach: + args = ["docker", "exec"] + + if options["-d"]: + args += ["--detach"] + else: + args += ["--interactive"] + + if not options["-T"]: + args += ["--tty"] + + if options["--privileged"]: + args += ["--privileged"] + + if options["--user"]: + args += ["--user", options["--user"]] + + args += [container.id] + args += command + + sys.exit(subprocess.call(args)) + create_exec_options = { "privileged": options["--privileged"], "user": options["--user"], @@ -675,12 +693,6 @@ class TopLevelCommand(object): service = self.project.get_service(options['SERVICE']) detach = options['-d'] - if IS_WINDOWS_PLATFORM and not detach: - raise UserError( - "Interactive mode is not yet supported on Windows.\n" - "Please pass the -d flag when using `docker-compose run`." - ) - if options['--publish'] and options['--service-ports']: raise UserError( 'Service port mapping and manual port mapping ' @@ -969,17 +981,21 @@ def run_one_off_container(container_options, project, service, options): signals.set_signal_handler_to_shutdown() try: try: - operation = RunOperation( - project.client, - container.id, - interactive=not options['-T'], - logs=False, - ) - pty = PseudoTerminal(project.client, operation) - sockets = pty.sockets() - service.start_container(container) - pty.start(sockets) - exit_code = container.wait() + if IS_WINDOWS_PLATFORM: + args = ["docker", "start", "--attach", "--interactive", container.id] + exit_code = subprocess.call(args) + else: + operation = RunOperation( + project.client, + container.id, + interactive=not options['-T'], + logs=False, + ) + pty = PseudoTerminal(project.client, operation) + sockets = pty.sockets() + service.start_container(container) + pty.start(sockets) + exit_code = container.wait() except signals.ShutdownException: project.client.stop(container.id) exit_code = 1 From 925915eb2535a069d3eefe4c14d35e5964182ff9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 14 Oct 2016 17:30:10 +0100 Subject: [PATCH 482/580] Show clear error when docker binary can't be found Signed-off-by: Aanand Prasad --- compose/cli/main.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index cbbb1325..58b95c2f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -6,6 +6,7 @@ import contextlib import functools import json import logging +import pipes import re import subprocess import sys @@ -415,7 +416,7 @@ class TopLevelCommand(object): tty = not options["-T"] if IS_WINDOWS_PLATFORM and not detach: - args = ["docker", "exec"] + args = ["exec"] if options["-d"]: args += ["--detach"] @@ -434,7 +435,7 @@ class TopLevelCommand(object): args += [container.id] args += command - sys.exit(subprocess.call(args)) + sys.exit(call_docker(args)) create_exec_options = { "privileged": options["--privileged"], @@ -982,8 +983,7 @@ def run_one_off_container(container_options, project, service, options): try: try: if IS_WINDOWS_PLATFORM: - args = ["docker", "start", "--attach", "--interactive", container.id] - exit_code = subprocess.call(args) + exit_code = call_docker(["start", "--attach", "--interactive", container.id]) else: operation = RunOperation( project.client, @@ -1060,3 +1060,15 @@ def exit_if(condition, message, exit_code): if condition: log.error(message) raise SystemExit(exit_code) + + +def call_docker(args): + try: + executable_path = subprocess.check_output(["which", "docker"]).strip() + except subprocess.CalledProcessError: + raise UserError(errors.docker_not_found_msg("Couldn't find `docker` binary.")) + + args = [executable_path] + args + log.debug(" ".join(map(pipes.quote, args))) + + return subprocess.call(args) From 882084932dd86ecf5e695c8d48f3621b134006d2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 11 Oct 2016 17:56:02 -0700 Subject: [PATCH 483/580] Upgrade docker-py to latest version Adjust required requests version Signed-off-by: Joffrey F --- requirements.txt | 4 ++-- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7acdd130..474efbdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.10.3 +docker-py==1.10.4 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' @@ -9,7 +9,7 @@ functools32==3.2.3.post2; python_version < '3.2' ipaddress==1.0.16 jsonschema==2.5.1 pypiwin32==219; sys_platform == 'win32' -requests==2.7.0 +requests==2.11.1 six==1.10.0 texttable==0.8.4 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index 80258fbd..442bc80c 100644 --- a/setup.py +++ b/setup.py @@ -31,10 +31,10 @@ install_requires = [ 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, < 2.8', + 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.10.3, < 2.0', + 'docker-py >= 1.10.4, < 2.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From efb09af271a1522914431818409b1c16c4bd24a9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 10 Oct 2016 17:44:49 -0700 Subject: [PATCH 484/580] Do not normalize volume paths on Windows by default Add environment variable to enable normalization if needed. Do not normalize internal paths Signed-off-by: Joffrey F --- compose/config/config.py | 5 ++- compose/config/types.py | 27 +++++++++++---- tests/unit/config/types_test.py | 58 +++++++++++++++++++++++++-------- tests/unit/service_test.py | 16 ++++----- 4 files changed, 77 insertions(+), 29 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 870bbad9..437ed389 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -651,7 +651,10 @@ def finalize_service(service_config, service_names, version, environment): if 'volumes' in service_dict: service_dict['volumes'] = [ - VolumeSpec.parse(v) for v in service_dict['volumes']] + VolumeSpec.parse( + v, environment.get('COMPOSE_CONVERT_WINDOWS_PATHS') + ) for v in service_dict['volumes'] + ] if 'net' in service_dict: network_mode = service_dict.pop('net') diff --git a/compose/config/types.py b/compose/config/types.py index c450a0f9..4c106747 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -5,6 +5,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import os +import re from collections import namedtuple import six @@ -14,6 +15,8 @@ from compose.config.errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM from compose.utils import splitdrive +win32_root_path_pattern = re.compile(r'^[A-Za-z]\:\\.*') + class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')): @@ -154,7 +157,7 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): return cls(external, internal, mode) @classmethod - def _parse_win32(cls, volume_config): + def _parse_win32(cls, volume_config, normalize): # relative paths in windows expand to include the drive, eg C:\ # so we join the first 2 parts back together to count as one mode = 'rw' @@ -168,13 +171,13 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): parts = separate_next_section(volume_config) if len(parts) == 1: - internal = normalize_path_for_engine(os.path.normpath(parts[0])) + internal = parts[0] external = None else: external = parts[0] parts = separate_next_section(parts[1]) - external = normalize_path_for_engine(os.path.normpath(external)) - internal = normalize_path_for_engine(os.path.normpath(parts[0])) + external = os.path.normpath(external) + internal = parts[0] if len(parts) > 1: if ':' in parts[1]: raise ConfigurationError( @@ -183,15 +186,18 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): ) mode = parts[1] + if normalize: + external = normalize_path_for_engine(external) if external else None + return cls(external, internal, mode) @classmethod - def parse(cls, volume_config): + def parse(cls, volume_config, normalize=False): """Parse a volume_config path and split it into external:internal[:mode] parts to be returned as a valid VolumeSpec. """ if IS_WINDOWS_PLATFORM: - return cls._parse_win32(volume_config) + return cls._parse_win32(volume_config, normalize) else: return cls._parse_unix(volume_config) @@ -201,7 +207,14 @@ class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): @property def is_named_volume(self): - return self.external and not self.external.startswith(('.', '/', '~')) + res = self.external and not self.external.startswith(('.', '/', '~')) + if not IS_WINDOWS_PLATFORM: + return res + + return ( + res and not self.external.startswith('\\') and + not win32_root_path_pattern.match(self.external) + ) class ServiceLink(namedtuple('_ServiceLink', 'target alias')): diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 8dfa65d5..11427352 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -63,35 +63,67 @@ class TestVolumeSpec(object): VolumeSpec.parse('one:two:three:four') assert 'has incorrect format' in exc.exconly() - def test_parse_volume_windows_absolute_path(self): - windows_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" - assert VolumeSpec._parse_win32(windows_path) == ( + def test_parse_volume_windows_absolute_path_normalized(self): + windows_path = "c:\\Users\\me\\Documents\\shiny\\config:/opt/shiny/config:ro" + assert VolumeSpec._parse_win32(windows_path, True) == ( "/c/Users/me/Documents/shiny/config", "/opt/shiny/config", "ro" ) - def test_parse_volume_windows_internal_path(self): + def test_parse_volume_windows_absolute_path_native(self): + windows_path = "c:\\Users\\me\\Documents\\shiny\\config:/opt/shiny/config:ro" + assert VolumeSpec._parse_win32(windows_path, False) == ( + "c:\\Users\\me\\Documents\\shiny\\config", + "/opt/shiny/config", + "ro" + ) + + def test_parse_volume_windows_internal_path_normalized(self): windows_path = 'C:\\Users\\reimu\\scarlet:C:\\scarlet\\app:ro' - assert VolumeSpec._parse_win32(windows_path) == ( + assert VolumeSpec._parse_win32(windows_path, True) == ( '/c/Users/reimu/scarlet', - '/c/scarlet/app', + 'C:\\scarlet\\app', 'ro' ) - def test_parse_volume_windows_just_drives(self): + def test_parse_volume_windows_internal_path_native(self): + windows_path = 'C:\\Users\\reimu\\scarlet:C:\\scarlet\\app:ro' + assert VolumeSpec._parse_win32(windows_path, False) == ( + 'C:\\Users\\reimu\\scarlet', + 'C:\\scarlet\\app', + 'ro' + ) + + def test_parse_volume_windows_just_drives_normalized(self): windows_path = 'E:\\:C:\\:ro' - assert VolumeSpec._parse_win32(windows_path) == ( + assert VolumeSpec._parse_win32(windows_path, True) == ( '/e/', - '/c/', + 'C:\\', 'ro' ) - def test_parse_volume_windows_mixed_notations(self): - windows_path = '/c/Foo:C:\\bar' - assert VolumeSpec._parse_win32(windows_path) == ( + def test_parse_volume_windows_just_drives_native(self): + windows_path = 'E:\\:C:\\:ro' + assert VolumeSpec._parse_win32(windows_path, False) == ( + 'E:\\', + 'C:\\', + 'ro' + ) + + def test_parse_volume_windows_mixed_notations_normalized(self): + windows_path = 'C:\\Foo:/root/foo' + assert VolumeSpec._parse_win32(windows_path, True) == ( '/c/Foo', - '/c/bar', + '/root/foo', + 'rw' + ) + + def test_parse_volume_windows_mixed_notations_native(self): + windows_path = 'C:\\Foo:/root/foo' + assert VolumeSpec._parse_win32(windows_path, False) == ( + 'C:\\Foo', + '/root/foo', 'rw' ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a259c476..1d5aa10f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -786,7 +786,7 @@ class ServiceVolumesTest(unittest.TestCase): self.mock_client = mock.create_autospec(docker.Client) def test_build_volume_binding(self): - binding = build_volume_binding(VolumeSpec.parse('/outside:/inside')) + binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True)) assert binding == ('/inside', '/outside:/inside:rw') def test_get_container_data_volumes(self): @@ -845,10 +845,10 @@ class ServiceVolumesTest(unittest.TestCase): def test_merge_volume_bindings(self): options = [ - VolumeSpec.parse('/host/volume:/host/volume:ro'), - VolumeSpec.parse('/host/rw/volume:/host/rw/volume'), - VolumeSpec.parse('/new/volume'), - VolumeSpec.parse('/existing/volume'), + VolumeSpec.parse('/host/volume:/host/volume:ro', True), + VolumeSpec.parse('/host/rw/volume:/host/rw/volume', True), + VolumeSpec.parse('/new/volume', True), + VolumeSpec.parse('/existing/volume', True), ] self.mock_client.inspect_image.return_value = { @@ -882,8 +882,8 @@ class ServiceVolumesTest(unittest.TestCase): 'web', image='busybox', volumes=[ - VolumeSpec.parse('/host/path:/data1'), - VolumeSpec.parse('/host/path:/data2'), + VolumeSpec.parse('/host/path:/data1', True), + VolumeSpec.parse('/host/path:/data2', True), ], client=self.mock_client, ) @@ -1007,7 +1007,7 @@ class ServiceVolumesTest(unittest.TestCase): 'web', client=self.mock_client, image='busybox', - volumes=[VolumeSpec.parse(volume)], + volumes=[VolumeSpec.parse(volume, True)], ).create_container() assert self.mock_client.create_container.call_count == 1 From cd94c37f5d3f1c901002d44b9a17b29f68147e24 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 18 Oct 2016 14:09:31 -0700 Subject: [PATCH 485/580] Fix merge error (missing Network.labels attribute) Signed-off-by: Joffrey F --- compose/network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/network.py b/compose/network.py index 00d68aa4..e581a4fe 100644 --- a/compose/network.py +++ b/compose/network.py @@ -26,6 +26,7 @@ class Network(object): self.external_name = external_name self.internal = internal self.enable_ipv6 = enable_ipv6 + self.labels = labels def ensure(self): if self.external_name: From f039c8b43cae5fab4b8a22004ae6ab56d4d698b8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 20 Oct 2016 17:39:55 -0700 Subject: [PATCH 486/580] Update release process document to account for recent changes. Signed-off-by: Joffrey F --- project/RELEASE-PROCESS.md | 62 ++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 930af15a..c1834f2f 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -20,18 +20,30 @@ release. As part of this script you'll be asked to: -1. Update the version in `docs/install.md` and `compose/__init__.py`. +1. Update the version in `compose/__init__.py` and `script/run/run.sh`. - If the next release will be an RC, append `rcN`, e.g. `1.4.0rc1`. + If the next release will be an RC, append `-rcN`, e.g. `1.4.0-rc1`. 2. Write release notes in `CHANGES.md`. - Almost every feature enhancement should be mentioned, with the most visible/exciting ones first. Use descriptive sentences and give context where appropriate. + Almost every feature enhancement should be mentioned, with the most + visible/exciting ones first. Use descriptive sentences and give context + where appropriate. - Bug fixes are worth mentioning if it's likely that they've affected lots of people, or if they were regressions in the previous version. + Bug fixes are worth mentioning if it's likely that they've affected lots + of people, or if they were regressions in the previous version. Improvements to the code are not worth mentioning. +3. Create a new repository on [bintray](https://bintray.com/docker-compose). + The name has to match the name of the branch (e.g. `bump-1.9.0`) and the + type should be "Generic". Other fields can be left blank. + +4. Check that the `vnext-compose` branch on + [the docs repo](https://github.com/docker/docker.github.io/) has + documentation for all the new additions in the upcoming release, and create + a PR there for what needs to be amended. + ## When a PR is merged into master that we want in the release @@ -55,8 +67,8 @@ Check out the bump branch and run the `build-binaries` script When prompted build the non-linux binaries and test them. -1. Download the osx binary from Bintray. Make sure that the latest build has - finished, otherwise you'll be downloading an old binary. +1. Download the osx binary from Bintray. Make sure that the latest Travis + build has finished, otherwise you'll be downloading an old binary. https://dl.bintray.com/docker-compose/$BRANCH_NAME/ @@ -67,22 +79,24 @@ When prompted build the non-linux binaries and test them. 3. Draft a release from the tag on GitHub (the script will open the window for you) - In the "Tag version" dropdown, select the tag you just pushed. + The tag will only be present on Github when you run the `push-release` + script in step 7, but you can pre-fill it at that point. -4. Paste in installation instructions and release notes. Here's an example - change the Compose version and Docker version as appropriate: +4. Paste in installation instructions and release notes. Here's an example - + change the Compose version and Docker version as appropriate: - Firstly, note that Compose 1.5.0 requires Docker 1.8.0 or later. + If you're a Mac or Windows user, the best way to install Compose and keep it up-to-date is **[Docker for Mac and Windows](https://www.docker.com/products/docker)**. - Secondly, if you're a Mac user, the **[Docker Toolbox](https://www.docker.com/toolbox)** will install Compose 1.5.0 for you, alongside the latest versions of the Docker Engine, Machine and Kitematic. + Note that Compose 1.9.0 requires Docker Engine 1.10.0 or later for version 2 of the Compose File format, and Docker Engine 1.9.1 or later for version 1. Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. - Otherwise, you can use the usual commands to install/upgrade. Either download the binary: + Alternatively, you can use the usual commands to install or upgrade Compose: - curl -L https://github.com/docker/compose/releases/download/1.5.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose + ``` + curl -L https://github.com/docker/compose/releases/download/1.9.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + ``` - Or install the PyPi package: - - pip install -U docker-compose==1.5.0 + See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. Here's what's new: @@ -99,6 +113,8 @@ When prompted build the non-linux binaries and test them. ./script/release/push-release +8. Merge the bump PR. + 8. Publish the release on GitHub. 9. Check that all the binaries download (following the install instructions) and run. @@ -107,19 +123,7 @@ When prompted build the non-linux binaries and test them. ## If it’s a stable release (not an RC) -1. Merge the bump PR. - -2. Make sure `origin/release` is updated locally: - - git fetch origin - -3. Update the `docs` branch on the upstream repo: - - git push git@github.com:docker/compose.git origin/release:docs - -4. Let the docs team know that it’s been updated so they can publish it. - -5. Close the release’s milestone. +1. Close the release’s milestone. ## If it’s a minor release (1.x.0), rather than a patch release (1.x.y) From 3d8dc6f47a1484b0b6f8e3bd5cbf6115524d3892 Mon Sep 17 00:00:00 2001 From: Michal Zdrojewski Date: Mon, 24 Oct 2016 15:05:01 +0100 Subject: [PATCH 487/580] fix(docs): updated documentation links Signed-off-by: Michal Zdrojewski --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93550f5a..5cf69b05 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a Compose file to configure your application's services. Then, using a single command, you create and start all the services from your configuration. To learn more about all the features of Compose -see [the list of features](https://github.com/docker/compose/blob/release/docs/overview.md#features). +see [the list of features](https://github.com/docker/docker.github.io/blob/master/compose/overview.md#features). Compose is great for development, testing, and staging environments, as well as CI workflows. You can learn more about each case in -[Common Use Cases](https://github.com/docker/compose/blob/release/docs/overview.md#common-use-cases). +[Common Use Cases](https://github.com/docker/docker.github.io/blob/master/compose/overview.md#common-use-cases). Using Compose is basically a three-step process. @@ -35,7 +35,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](https://github.com/docker/compose/blob/release/docs/compose-file.md) +[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file.md) Compose has commands for managing the whole lifecycle of your application: From 2c24bc3a083e138898cfe619a9cbf414f6df12d8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 10:55:04 -0700 Subject: [PATCH 488/580] Add missing config schema to docker-compose.spec Signed-off-by: Joffrey F --- docker-compose.spec | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.spec b/docker-compose.spec index 3a165dd6..e57d6b7a 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -27,6 +27,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v2.0.json', 'DATA' ), + ( + 'compose/config/config_schema_v2.1.json', + 'compose/config/config_schema_v2.1.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From ea68be3441ef2c0c100b8bac9499fa4180106eb5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 11:36:44 -0700 Subject: [PATCH 489/580] Do not print Swarm mode warning when connecting to a UCP server Signed-off-by: Joffrey F --- compose/project.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/project.py b/compose/project.py index f85e285f..60647fe9 100644 --- a/compose/project.py +++ b/compose/project.py @@ -538,6 +538,10 @@ def get_volumes_from(project, service_dict): def warn_for_swarm_mode(client): info = client.info() if info.get('Swarm', {}).get('LocalNodeState') == 'active': + if info.get('ServerVersion', '').startswith('ucp'): + # UCP does multi-node scheduling with traditional Compose files. + return + log.warn( "The Docker Engine you're using is running in swarm mode.\n\n" "Compose does not use swarm mode to deploy services to multiple nodes in a swarm. " From 43e29b41c04dfa00fee294f57ccd2e5d1a80f99a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 11:51:45 -0700 Subject: [PATCH 490/580] Fix schema divergence - add missing fields to compose 2.1 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index f30b9054..3561a8cf 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -140,6 +140,7 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, + "mem_swappiness": {"type": "integer"}, "network_mode": {"type": "string"}, "networks": { @@ -168,6 +169,14 @@ } ] }, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, "pid": {"type": ["string", "null"]}, "ports": { @@ -248,6 +257,7 @@ }, "additionalProperties": false }, + "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"} }, From d2fb146913a1204b805ceb05e3d2b1b1a1006675 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 12:12:19 -0700 Subject: [PATCH 491/580] Replace "which" calls with the portable find_executable function Signed-off-by: Joffrey F --- compose/cli/errors.py | 6 +++--- compose/cli/main.py | 6 +++--- tests/unit/cli/errors_test.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 4fdec08a..5b977095 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import contextlib import logging import socket +from distutils.spawn import find_executable from textwrap import dedent from docker.errors import APIError @@ -13,7 +14,6 @@ from requests.exceptions import SSLError from requests.packages.urllib3.exceptions import ReadTimeoutError from ..const import API_VERSION_TO_ENGINE_VERSION -from .utils import call_silently from .utils import is_docker_for_mac_installed from .utils import is_mac from .utils import is_ubuntu @@ -90,11 +90,11 @@ def exit_with_error(msg): def get_conn_error_message(url): - if call_silently(['which', 'docker']) != 0: + if find_executable('docker') is None: return docker_not_found_msg("Couldn't connect to Docker daemon.") if is_docker_for_mac_installed(): return conn_error_docker_for_mac - if call_silently(['which', 'docker-machine']) == 0: + if find_executable('docker-machine') is not None: return conn_error_docker_machine return conn_error_generic.format(url=url) diff --git a/compose/cli/main.py b/compose/cli/main.py index 58b95c2f..08e58e37 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,6 +10,7 @@ import pipes import re import subprocess import sys +from distutils.spawn import find_executable from inspect import getdoc from operator import attrgetter @@ -1063,9 +1064,8 @@ def exit_if(condition, message, exit_code): def call_docker(args): - try: - executable_path = subprocess.check_output(["which", "docker"]).strip() - except subprocess.CalledProcessError: + executable_path = find_executable('docker') + if not executable_path: raise UserError(errors.docker_not_found_msg("Couldn't find `docker` binary.")) args = [executable_path] + args diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 1d454a08..a7b57562 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -16,9 +16,9 @@ def mock_logging(): yield mock_log -def patch_call_silently(side_effect): +def patch_find_executable(side_effect): return mock.patch( - 'compose.cli.errors.call_silently', + 'compose.cli.errors.find_executable', autospec=True, side_effect=side_effect) @@ -27,7 +27,7 @@ class TestHandleConnectionErrors(object): def test_generic_connection_error(self, mock_logging): with pytest.raises(errors.ConnectionError): - with patch_call_silently([0, 1]): + with patch_find_executable(['/bin/docker', None]): with handle_connection_errors(mock.Mock()): raise ConnectionError() From 60d005b055119ce976265eaf34e4daa6483ead58 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 13:58:45 -0700 Subject: [PATCH 492/580] Improve robustness of a couple integration tests with occasional failures Signed-off-by: Joffrey F --- tests/integration/project_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index e3c34af9..3afefb5a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -826,9 +826,9 @@ class ProjectTest(DockerClientTestCase): name='composetest', config_data=config_data ) - project.up() + project.up(detached=True) - service_container = project.get_service('web').containers()[0] + service_container = project.get_service('web').containers(stopped=True)[0] ipam_config = service_container.inspect().get( 'NetworkSettings', {} ).get( @@ -857,8 +857,8 @@ class ProjectTest(DockerClientTestCase): name='composetest', config_data=config_data ) - project.up() - service_container = project.get_service('web').containers()[0] + project.up(detached=True) + service_container = project.get_service('web').containers(stopped=True)[0] assert service_container.inspect()['HostConfig']['Isolation'] == 'default' @v2_1_only() From 99343fd76cc632d30ff2132ea715728317e10250 Mon Sep 17 00:00:00 2001 From: Albin Kerouanton Date: Tue, 25 Oct 2016 11:06:39 +0200 Subject: [PATCH 493/580] Fix path of the parent dir of COMPOSE_FILE Signed-off-by: Albin Kerouanton --- script/run/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/run/run.sh b/script/run/run.sh index 6205747a..5872b081 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -35,7 +35,7 @@ if [ "$(pwd)" != '/' ]; then VOLUMES="-v $(pwd):$(pwd)" fi if [ -n "$COMPOSE_FILE" ]; then - compose_dir=$(dirname $COMPOSE_FILE) + compose_dir=$(realpath $(dirname $COMPOSE_FILE)) fi # TODO: also check --file argument if [ -n "$compose_dir" ]; then From 4871523d5e113e1f16a4ce50533d9b6f0bb80237 Mon Sep 17 00:00:00 2001 From: Mike Dougherty Date: Fri, 22 Apr 2016 12:56:07 -0700 Subject: [PATCH 494/580] Update Jenkinsfile to perform existing jenkins tasks Signed-off-by: Mike Dougherty --- Jenkinsfile | 84 +++++++++++++++++++++++++++++++++++++++++++++---- script/test/all | 3 +- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index fa29520b..5de9a3fb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,8 +1,80 @@ -// Only run on Linux atm -wrappedNode(label: 'docker') { - deleteDir() - stage "checkout" - checkout scm +#!groovy - documentationChecker("docs") +def image + +def checkDocs = { -> + wrappedNode(label: 'linux') { + deleteDir(); checkout(scm) + documentationChecker("docs") + } } + +def buildImage = { -> + wrappedNode(label: "linux && !zfs") { + stage("build image") { + deleteDir(); checkout(scm) + def imageName = "dockerbuildbot/compose:${gitCommit()}" + image = docker.image(imageName) + try { + image.pull() + } catch (Exception exc) { + image = docker.build(imageName, ".") + image.push() + } + } + } +} + +def runTests = { Map settings -> + def dockerVersions = settings.get("dockerVersions", null) + def pythonVersions = settings.get("pythonVersions", null) + + if (!pythonVersions) { + throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py34')`") + } + if (!dockerVersions) { + throw new Exception("Need Docker versions to test. e.g.: `runTests(dockerVersions: 'all')`") + } + + { -> + wrappedNode(label: "linux && !zfs") { + stage("test python=${pythonVersions} / docker=${dockerVersions}") { + deleteDir(); checkout(scm) + def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() + echo "Using local system's storage driver: ${storageDriver}" + sh """docker run \\ + -t \\ + --rm \\ + --privileged \\ + --volume="\$(pwd)/.git:/code/.git" \\ + --volume="/var/run/docker.sock:/var/run/docker.sock" \\ + -e "TAG=${image.id}" \\ + -e "STORAGE_DRIVER=${storageDriver}" \\ + -e "DOCKER_VERSIONS=${dockerVersions}" \\ + -e "BUILD_NUMBER=\$BUILD_TAG" \\ + -e "PY_TEST_VERSIONS=${pythonVersions}" \\ + --entrypoint="script/ci" \\ + ${image.id} \\ + --verbose + """ + } + } + } +} + +def buildAndTest = { -> + buildImage() + // TODO: break this out into meaningful "DOCKER_VERSIONS" values instead of all + parallel( + failFast: true, + all_py27: runTests(pythonVersions: "py27", dockerVersions: "all"), + all_py34: runTests(pythonVersions: "py34", dockerVersions: "all"), + ) +} + + +parallel( + failFast: false, + docs: checkDocs, + test: buildAndTest +) diff --git a/script/test/all b/script/test/all index 08bf1618..7151a75e 100755 --- a/script/test/all +++ b/script/test/all @@ -24,6 +24,7 @@ fi BUILD_NUMBER=${BUILD_NUMBER-$USER} +PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py34} for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" @@ -58,6 +59,6 @@ for version in $DOCKER_VERSIONS; do --env="DOCKER_VERSION=$version" \ --entrypoint="tox" \ "$TAG" \ - -e py27,py34 -- "$@" + -e "$PY_TEST_VERSIONS" -- "$@" done From 046144e8f40de571b0d9c0029329e20712ca4736 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 27 Oct 2016 12:13:32 -0700 Subject: [PATCH 495/580] Bump docker-py version to include latest patch Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 474efbdf..e72a88e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.10.4 +docker-py==1.10.5 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' diff --git a/setup.py b/setup.py index 442bc80c..19ff5c6a 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.10.4, < 2.0', + 'docker-py >= 1.10.5, < 2.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From ba43d08fbdc1e7ec25978cc9068c3a9a7be91aab Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Nov 2016 11:09:29 -0700 Subject: [PATCH 496/580] Add whitelisted driver option added by the overlay driver to avoid breakage Signed-off-by: Joffrey F --- compose/network.py | 21 +++++++++++++++++ tests/unit/network_test.py | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/unit/network_test.py diff --git a/compose/network.py b/compose/network.py index e581a4fe..8e38401c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -12,6 +12,10 @@ from .config import ConfigurationError log = logging.getLogger(__name__) +OPTS_EXCEPTIONS = [ + 'com.docker.network.driver.overlay.vxlanid_list', +] + class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, @@ -113,6 +117,23 @@ def create_ipam_config_from_dict(ipam_dict): ) +def check_remote_network_config(remote, local): + if local.driver and remote['Driver'] != local.driver: + raise ConfigurationError( + 'Network "{}" needs to be recreated - driver has changed' + .format(local.full_name) + ) + local_opts = local.driver_opts or {} + for k in set.union(set(remote['Options'].keys()), set(local_opts.keys())): + if k in OPTS_EXCEPTIONS: + continue + if remote['Options'].get(k) != local_opts.get(k): + raise ConfigurationError( + 'Network "{}" needs to be recreated - options have changed' + .format(local.full_name) + ) + + def build_networks(name, config_data, client): network_config = config_data.networks or {} networks = { diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py new file mode 100644 index 00000000..4720b053 --- /dev/null +++ b/tests/unit/network_test.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import pytest + +from .. import unittest +from compose.config import ConfigurationError +from compose.network import check_remote_network_config +from compose.network import Network + + +class NetworkTest(unittest.TestCase): + def test_check_remote_network_config_success(self): + options = {'com.docker.network.driver.foo': 'bar'} + net = Network( + None, 'compose_test', 'net1', 'bridge', + options + ) + check_remote_network_config( + {'Driver': 'bridge', 'Options': options}, net + ) + + def test_check_remote_network_config_whitelist(self): + options = {'com.docker.network.driver.foo': 'bar'} + remote_options = { + 'com.docker.network.driver.overlay.vxlanid_list': '257', + 'com.docker.network.driver.foo': 'bar' + } + net = Network( + None, 'compose_test', 'net1', 'overlay', + options + ) + check_remote_network_config( + {'Driver': 'overlay', 'Options': remote_options}, net + ) + + def test_check_remote_network_config_driver_mismatch(self): + net = Network(None, 'compose_test', 'net1', 'overlay') + with pytest.raises(ConfigurationError): + check_remote_network_config({'Driver': 'bridge', 'Options': {}}, net) + + def test_check_remote_network_config_options_mismatch(self): + net = Network(None, 'compose_test', 'net1', 'overlay') + with pytest.raises(ConfigurationError): + check_remote_network_config({'Driver': 'overlay', 'Options': { + 'com.docker.network.driver.foo': 'baz' + }}, net) From 7a430dbe96ad01831e21eab7eaebf26fdaa9249c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Nov 2016 16:43:29 -0700 Subject: [PATCH 497/580] Updated docker-py dependency to latest version Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e72a88e7..933146c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.10.5 +docker-py==1.10.6 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' diff --git a/setup.py b/setup.py index 19ff5c6a..672ea80e 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.10.5, < 2.0', + 'docker-py >= 1.10.6, < 2.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From da1508051dcc7f106f891078b688bc31db7e43c5 Mon Sep 17 00:00:00 2001 From: Mike Dougherty Date: Thu, 3 Nov 2016 13:59:56 -0700 Subject: [PATCH 498/580] Remove docs checker from Jenkinsfile and use cleanWorkspace option on wrappedNode Signed-off-by: Mike Dougherty --- Jenkinsfile | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 5de9a3fb..e2f86daa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,17 +2,10 @@ def image -def checkDocs = { -> - wrappedNode(label: 'linux') { - deleteDir(); checkout(scm) - documentationChecker("docs") - } -} - def buildImage = { -> - wrappedNode(label: "linux && !zfs") { + wrappedNode(label: "linux && !zfs", cleanWorkspace: true) { stage("build image") { - deleteDir(); checkout(scm) + checkout(scm) def imageName = "dockerbuildbot/compose:${gitCommit()}" image = docker.image(imageName) try { @@ -37,9 +30,9 @@ def runTests = { Map settings -> } { -> - wrappedNode(label: "linux && !zfs") { + wrappedNode(label: "linux && !zfs", cleanWorkspace: true) { stage("test python=${pythonVersions} / docker=${dockerVersions}") { - deleteDir(); checkout(scm) + checkout(scm) def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() echo "Using local system's storage driver: ${storageDriver}" sh """docker run \\ @@ -62,19 +55,10 @@ def runTests = { Map settings -> } } -def buildAndTest = { -> - buildImage() - // TODO: break this out into meaningful "DOCKER_VERSIONS" values instead of all - parallel( - failFast: true, - all_py27: runTests(pythonVersions: "py27", dockerVersions: "all"), - all_py34: runTests(pythonVersions: "py34", dockerVersions: "all"), - ) -} - - +buildImage() +// TODO: break this out into meaningful "DOCKER_VERSIONS" values instead of all parallel( - failFast: false, - docs: checkDocs, - test: buildAndTest + failFast: true, + all_py27: runTests(pythonVersions: "py27", dockerVersions: "all"), + all_py34: runTests(pythonVersions: "py34", dockerVersions: "all"), ) From 10417eebd70f028f57e56ef9a04d8ed51abdad99 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 3 Nov 2016 16:28:44 -0700 Subject: [PATCH 499/580] Fix logging dict merging Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 437ed389..9d23b34d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -771,7 +771,7 @@ def merge_service_dicts(base, override, version): for field in ['dns', 'dns_search', 'env_file', 'tmpfs']: md.merge_field(field, merge_list_or_string) - md.merge_field('logging', merge_logging) + md.merge_field('logging', merge_logging, default={}) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d205e282..66ae0147 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1657,6 +1657,51 @@ class ConfigTest(unittest.TestCase): } } + def test_merge_logging_v2_no_base(self): + base = { + 'image': 'alpine:edge' + } + override = { + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000' + } + } + } + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'json-file', + 'options': { + 'frequency': '2000' + } + } + } + + def test_merge_logging_v2_no_override(self): + base = { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'syslog', + 'options': { + 'frequency': '2000' + } + } + } + override = {} + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'alpine:edge', + 'logging': { + 'driver': 'syslog', + 'options': { + 'frequency': '2000' + } + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 4aa7d15d9771fadd22e8c1ff952526fdd6a67d77 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 4 Nov 2016 10:51:14 -0700 Subject: [PATCH 500/580] Call check_remote_network_config from Network.ensure Signed-off-by: Joffrey F --- compose/network.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/compose/network.py b/compose/network.py index 8e38401c..06935a46 100644 --- a/compose/network.py +++ b/compose/network.py @@ -53,14 +53,7 @@ class Network(object): try: data = self.inspect() - if self.driver and data['Driver'] != self.driver: - raise ConfigurationError( - 'Network "{}" needs to be recreated - driver has changed' - .format(self.full_name)) - if data['Options'] != (self.driver_opts or {}): - raise ConfigurationError( - 'Network "{}" needs to be recreated - options have changed' - .format(self.full_name)) + check_remote_network_config(data, self) except NotFound: driver_name = 'the default driver' if self.driver: From ba249e51796e6b35ed13e5f03716a9520a2dacfc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 9 Nov 2016 15:10:02 +0000 Subject: [PATCH 501/580] Test that values in 'environment' override env files Signed-off-by: Aanand Prasad --- tests/unit/config/config_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 66ae0147..51c5e226 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2493,6 +2493,15 @@ class EnvTest(unittest.TestCase): {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, ) + def test_environment_overrides_env_file(self): + self.assertEqual( + resolve_environment({ + 'environment': {'FOO': 'baz'}, + 'env_file': ['tests/fixtures/env/one.env'], + }), + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz'}, + ) + def test_resolve_environment_with_multiple_env_files(self): service_dict = { 'env_file': [ From 91620ae97bbffd247e2f78132255c0fe97910c4f Mon Sep 17 00:00:00 2001 From: Jari Takkala Date: Fri, 29 Jul 2016 06:46:42 -0400 Subject: [PATCH 502/580] Add sysctl option support when creating service Closes #3765 Signed-off-by: Jari Takkala --- compose/config/config.py | 16 +++++++++++----- compose/config/config_schema_v2.1.json | 1 + compose/service.py | 2 ++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9d23b34d..57039e69 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -83,6 +83,7 @@ DOCKER_CONFIG_KEYS = [ 'shm_size', 'stdin_open', 'stop_signal', + 'sysctls', 'tty', 'user', 'volume_driver', @@ -629,6 +630,9 @@ def process_service(service_config): if 'extra_hosts' in service_dict: service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts']) + if 'sysctls' in service_dict: + service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) + for field in ['dns', 'dns_search', 'tmpfs']: if field in service_dict: service_dict[field] = to_list(service_dict[field]) @@ -757,6 +761,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('labels', parse_labels) md.merge_mapping('ulimits', parse_ulimits) md.merge_mapping('networks', parse_networks) + md.merge_mapping('sysctls', parse_sysctls) md.merge_sequence('links', ServiceLink.parse) for field in ['volumes', 'devices']: @@ -831,11 +836,11 @@ def merge_environment(base, override): return env -def split_label(label): - if '=' in label: - return label.split('=', 1) +def split_kv(kvpair): + if '=' in kvpair: + return kvpair.split('=', 1) else: - return label, '' + return kvpair, '' def parse_dict_or_list(split_func, type_name, arguments): @@ -856,8 +861,9 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_build_arguments = functools.partial(parse_dict_or_list, split_env, 'build arguments') parse_environment = functools.partial(parse_dict_or_list, split_env, 'environment') -parse_labels = functools.partial(parse_dict_or_list, split_label, 'labels') +parse_labels = functools.partial(parse_dict_or_list, split_kv, 'labels') parse_networks = functools.partial(parse_dict_or_list, lambda k: (k, None), 'networks') +parse_sysctls = functools.partial(parse_dict_or_list, split_kv, 'sysctls') def parse_ulimits(ulimits): diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 3561a8cf..fc95f2cb 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -193,6 +193,7 @@ "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, diff --git a/compose/service.py b/compose/service.py index 760d29a7..c917aac4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -62,6 +62,7 @@ DOCKER_START_KEYS = [ 'restart', 'security_opt', 'shm_size', + 'sysctls', 'volumes_from', ] @@ -707,6 +708,7 @@ class Service(object): cgroup_parent=options.get('cgroup_parent'), cpu_quota=options.get('cpu_quota'), shm_size=options.get('shm_size'), + sysctls=options.get('sysctls'), tmpfs=options.get('tmpfs'), oom_score_adj=options.get('oom_score_adj'), mem_swappiness=options.get('mem_swappiness'), From 0291d9ade5574ff31aecc1a9f263f1ec21a901cf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 14 Nov 2016 17:23:25 -0800 Subject: [PATCH 503/580] Limit testing pool to Ubuntu hosts to avoid errors with dind not starting properly. Signed-off-by: Joffrey F --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index e2f86daa..51136b1f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,7 @@ def image def buildImage = { -> - wrappedNode(label: "linux && !zfs", cleanWorkspace: true) { + wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { stage("build image") { checkout(scm) def imageName = "dockerbuildbot/compose:${gitCommit()}" @@ -30,7 +30,7 @@ def runTests = { Map settings -> } { -> - wrappedNode(label: "linux && !zfs", cleanWorkspace: true) { + wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { stage("test python=${pythonVersions} / docker=${dockerVersions}") { checkout(scm) def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() From efb4ed1b9e130ca5ac54f6e0fb23ce68c3689c1f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 14 Nov 2016 18:03:26 -0800 Subject: [PATCH 504/580] Handle new pull failures behavior in Engine 1.13 Signed-off-by: Joffrey F --- compose/service.py | 6 +++--- tests/acceptance/cli_test.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index 760d29a7..ad426706 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ from operator import attrgetter import enum import six from docker.errors import APIError +from docker.errors import NotFound from docker.utils import LogConfig from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port @@ -829,12 +830,11 @@ class Service(object): repo, tag, separator = parse_repository_tag(self.options['image']) tag = tag or 'latest' log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) - output = self.client.pull(repo, tag=tag, stream=True) - try: + output = self.client.pull(repo, tag=tag, stream=True) return progress_stream.get_digest_from_pull( stream_output(output, sys.stdout)) - except StreamOutputError as e: + except (StreamOutputError, NotFound) as e: if not ignore_pull_failures: raise else: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a7cd78f1..f153bd95 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -330,12 +330,13 @@ 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'] + ) assert 'Pulling simple (busybox:latest)...' in result.stderr assert 'Pulling another (nonexisting-image:latest)...' in result.stderr - assert 'Error: image library/nonexisting-image' in result.stderr - assert 'not found' in result.stderr + assert ('repository nonexisting-image not found' in result.stderr or + 'image library/nonexisting-image:latest not found' in result.stderr) def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' From 7f60ff5ae6b320dc0b38f8f2bcc850eca95f49ae Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Nov 2016 15:38:09 -0800 Subject: [PATCH 505/580] Avoid breaking when remote driver options are null. Signed-off-by: Joffrey F --- compose/network.py | 7 ++++--- tests/unit/network_test.py | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/compose/network.py b/compose/network.py index 06935a46..3b57cb94 100644 --- a/compose/network.py +++ b/compose/network.py @@ -111,16 +111,17 @@ def create_ipam_config_from_dict(ipam_dict): def check_remote_network_config(remote, local): - if local.driver and remote['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) ) local_opts = local.driver_opts or {} - for k in set.union(set(remote['Options'].keys()), set(local_opts.keys())): + remote_opts = remote.get('Options') or {} + for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if k in OPTS_EXCEPTIONS: continue - if remote['Options'].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) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index 4720b053..12d06f41 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -37,7 +37,9 @@ class NetworkTest(unittest.TestCase): def test_check_remote_network_config_driver_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') with pytest.raises(ConfigurationError): - check_remote_network_config({'Driver': 'bridge', 'Options': {}}, net) + check_remote_network_config( + {'Driver': 'bridge', 'Options': {}}, net + ) def test_check_remote_network_config_options_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') @@ -45,3 +47,9 @@ class NetworkTest(unittest.TestCase): check_remote_network_config({'Driver': 'overlay', 'Options': { 'com.docker.network.driver.foo': 'baz' }}, net) + + def test_check_remote_network_config_null_remote(self): + net = Network(None, 'compose_test', 'net1', 'overlay') + check_remote_network_config( + {'Driver': 'overlay', 'Options': None}, net + ) From d717c88b6e81f5eb0769bc0670a6b78de842b2ce Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 9 Nov 2016 17:16:36 +0000 Subject: [PATCH 506/580] Support version 3.0 of the Compose file format Signed-off-by: Aanand Prasad --- compose/config/config.py | 13 +- compose/config/config_schema_v3.0.json | 378 ++++++++++++++++++++++ compose/config/errors.py | 4 +- compose/config/serialize.py | 3 +- compose/const.py | 3 + tests/acceptance/cli_test.py | 55 ++++ tests/fixtures/v3-full/docker-compose.yml | 37 +++ tests/unit/config/config_test.py | 6 + 8 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 compose/config/config_schema_v3.0.json create mode 100644 tests/fixtures/v3-full/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 9d23b34d..fb77436d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,7 @@ from cached_property import cached_property 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 ..utils import build_string_dict from ..utils import splitdrive from .environment import env_vars_from_file @@ -175,7 +176,10 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): if version == '2': version = V2_0 - if version not in (V2_0, V2_1): + if version == '3': + version = V3_0 + + if version not in (V2_0, V2_1, V3_0): raise ConfigurationError( 'Version in "{}" is unsupported. {}' .format(self.filename, VERSION_EXPLANATION)) @@ -433,7 +437,7 @@ def process_config_file(config_file, environment, service_name=None): 'service', environment) - if config_file.version in (V2_0, V2_1): + if config_file.version in (V2_0, V2_1, V3_0): processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( @@ -446,9 +450,10 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - - if config_file.version == V1: + elif config_file.version == V1: processed_config = services + else: + raise Exception("Unsupported version: {}".format(repr(config_file.version))) config_file = config_file._replace(config=processed_config) validate_against_config_schema(config_file) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json new file mode 100644 index 00000000..9ac31b1f --- /dev/null +++ b/compose/config/config_schema_v3.0.json @@ -0,0 +1,378 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.0.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 + } + }, + + "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"} + }, + "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": { + "type": ["string", "number"], + "format": "ports" + }, + "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"]}, + "stdin_open": {"type": "boolean"}, + "stop_signal": {"type": "string"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "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"}, + "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": ["object", "null"], + "properties": { + "interval": {"type":"string"}, + "timeout": {"type":"string"}, + "retries": {"type": "number"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + } + }, + "additionalProperties": false + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "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"} + }, + "additionaProperties": 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 + }, + "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"} + } + } + }, + "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"] + } + } + } + } + } +} diff --git a/compose/config/errors.py b/compose/config/errors.py index d14cbbdd..16ed01b8 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals VERSION_EXPLANATION = ( - 'You might be seeing this error because you\'re using the wrong Compose ' - 'file version. Either specify a version of "2" (or "2.0") and place your ' + '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 ' 'service definitions under the `services` key, or omit the `version` key ' 'and place your service definitions at the root of the file to use ' 'version 1.\nFor more on the Compose file format versions, see ' diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 95b1387f..768f3d47 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -6,7 +6,6 @@ import yaml from compose.config import types from compose.config.config import V1 -from compose.config.config import V2_0 from compose.config.config import V2_1 @@ -34,7 +33,7 @@ def denormalize_config(config): del net_conf['external_name'] version = config.version - if version not in (V2_0, V2_1): + if version == V1: version = V2_1 return { diff --git a/compose/const.py b/compose/const.py index e7b1ae97..3da39855 100644 --- a/compose/const.py +++ b/compose/const.py @@ -17,15 +17,18 @@ LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' COMPOSEFILE_V2_1 = '2.1' +COMPOSEFILE_V3_0 = '3.0' API_VERSIONS = { COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_1: '1.24', + COMPOSEFILE_V3_0: '1.24', } API_VERSION_TO_ENGINE_VERSION = { API_VERSIONS[COMPOSEFILE_V1]: '1.9.0', API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', + API_VERSIONS[COMPOSEFILE_V3_0]: '1.12.0', } diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f153bd95..e9a41691 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -285,6 +285,61 @@ class CLITestCase(DockerClientTestCase): 'volumes': {}, } + def test_config_v3(self): + self.base_dir = 'tests/fixtures/v3-full' + result = self.dispatch(['config']) + + assert yaml.load(result.stdout) == { + 'version': '3.0', + 'networks': {}, + 'volumes': {}, + 'services': { + 'web': { + 'image': 'busybox', + 'deploy': { + 'mode': 'replicated', + 'replicas': 6, + 'labels': ['FOO=BAR'], + 'update_config': { + 'parallelism': 3, + 'delay': '10s', + 'failure_action': 'continue', + 'monitor': '60s', + 'max_failure_ratio': 0.3, + }, + 'resources': { + 'limits': { + 'cpus': '0.001', + 'memory': '50M', + }, + 'reservations': { + 'cpus': '0.0001', + 'memory': '20M', + }, + }, + 'restart_policy': { + 'condition': 'on_failure', + 'delay': '5s', + 'max_attempts': 3, + 'window': '120s', + }, + 'placement': { + 'constraints': ['node=foo'], + }, + }, + + 'healthcheck': { + 'command': 'cat /etc/passwd', + 'interval': '10s', + 'timeout': '1s', + 'retries': 5, + }, + + 'stop_grace_period': '20s', + }, + }, + } + def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml new file mode 100644 index 00000000..1187dd7b --- /dev/null +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3" +services: + web: + image: busybox + + deploy: + mode: replicated + replicas: 6 + labels: [FOO=BAR] + update_config: + parallelism: 3 + delay: 10s + failure_action: continue + monitor: 60s + max_failure_ratio: 0.3 + resources: + limits: + cpus: '0.001' + memory: 50M + reservations: + cpus: '0.0001' + memory: 20M + restart_policy: + condition: on_failure + delay: 5s + max_attempts: 3 + window: 120s + placement: + constraints: [node=foo] + + healthcheck: + command: cat /etc/passwd + interval: 10s + timeout: 1s + retries: 5 + + stop_grace_period: 20s diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 51c5e226..114145e1 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -18,6 +18,7 @@ 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.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION @@ -156,9 +157,14 @@ class ConfigTest(unittest.TestCase): for version in ['2', '2.0']: cfg = config.load(build_config_details({'version': version})) assert cfg.version == V2_0 + cfg = config.load(build_config_details({'version': '2.1'})) assert cfg.version == V2_1 + for version in ['3', '3.0']: + cfg = config.load(build_config_details({'version': version})) + assert cfg.version == V3_0 + def test_v1_file_version(self): cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) assert cfg.version == V1 From f75ef6862fe20f378c9702cda652deb34a49f946 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 10 Nov 2016 17:30:46 +0000 Subject: [PATCH 507/580] Warn if any services use 'deploy' Signed-off-by: Aanand Prasad --- compose/config/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index fb77436d..940b9eb0 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -330,6 +330,14 @@ def load(config_details): for service_dict in service_dicts: match_named_volumes(service_dict, volumes) + services_using_deploy = [s for s in service_dicts if s.get('deploy')] + if services_using_deploy: + log.warn( + "Some services ({}) use the 'deploy' key, which will be ignored. " + "Compose does not support deploy configuration - use the experimental " + "`docker deploy` command to deploy to a swarm." + .format(", ".join(sorted(s['name'] for s in services_using_deploy)))) + return Config(main_file.version, service_dicts, volumes, networks) From 6cac48c0564f7f6b4e4d91055f50ec8c86505dd9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 10 Nov 2016 17:38:19 -0500 Subject: [PATCH 508/580] Add a vendored and modified pytimeparse Signed-off-by: Daniel Nephin --- compose/timeparse.py | 96 ++++++++++++++++++++++++++++++++++++ tests/unit/timeparse_test.py | 52 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 compose/timeparse.py create mode 100644 tests/unit/timeparse_test.py diff --git a/compose/timeparse.py b/compose/timeparse.py new file mode 100644 index 00000000..16ef8a6d --- /dev/null +++ b/compose/timeparse.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +''' +timeparse.py +(c) Will Roberts 1 February, 2014 + +This is a vendored and modified copy of: +github.com/wroberts/pytimeparse @ cc0550d + +It has been modified to mimic the behaviour of +https://golang.org/pkg/time/#ParseDuration +''' +# MIT LICENSE +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from __future__ import absolute_import +from __future__ import unicode_literals + +import re + +HOURS = r'(?P[\d.]+)h' +MINS = r'(?P[\d.]+)m' +SECS = r'(?P[\d.]+)s' +MILLI = r'(?P[\d.]+)ms' +MICRO = r'(?P[\d.]+)(?:us|µs)' +NANO = r'(?P[\d.]+)ns' + + +def opt(x): + return r'(?:{x})?'.format(x=x) + + +TIMEFORMAT = r'{HOURS}{MINS}{SECS}{MILLI}{MICRO}{NANO}'.format( + HOURS=opt(HOURS), + MINS=opt(MINS), + SECS=opt(SECS), + MILLI=opt(MILLI), + MICRO=opt(MICRO), + NANO=opt(NANO), +) + +MULTIPLIERS = dict([ + ('hours', 60 * 60), + ('mins', 60), + ('secs', 1), + ('milli', 1.0 / 1000), + ('micro', 1.0 / 1000.0 / 1000), + ('nano', 1.0 / 1000.0 / 1000.0 / 1000.0), +]) + + +def timeparse(sval): + """Parse a time expression, returning it as a number of seconds. If + possible, the return value will be an `int`; if this is not + possible, the return will be a `float`. Returns `None` if a time + expression cannot be parsed from the given string. + + Arguments: + - `sval`: the string value to parse + + >>> timeparse('1m24s') + 84 + >>> timeparse('1.2 minutes') + 72 + >>> timeparse('1.2 seconds') + 1.2 + """ + match = re.match(r'\s*' + TIMEFORMAT + r'\s*$', sval, re.I) + if not match or not match.group(0).strip(): + return + + mdict = match.groupdict() + return sum( + MULTIPLIERS[k] * cast(v) for (k, v) in mdict.items() if v is not None) + + +def cast(value): + return int(value, 10) if value.isdigit() else float(value) diff --git a/tests/unit/timeparse_test.py b/tests/unit/timeparse_test.py new file mode 100644 index 00000000..e9fe6c24 --- /dev/null +++ b/tests/unit/timeparse_test.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from compose import timeparse + + +def test_milli(): + assert timeparse.timeparse('5ms') == 0.005 + + +def test_milli_float(): + assert timeparse.timeparse('50.5ms') == 0.0505 + + +def test_second_milli(): + assert timeparse.timeparse('200s5ms') == 200.005 + + +def test_second_milli_micro(): + assert timeparse.timeparse('200s5ms10us') == 200.00501 + + +def test_second(): + assert timeparse.timeparse('200s') == 200 + + +def test_second_as_float(): + assert timeparse.timeparse('20.5s') == 20.5 + + +def test_minute(): + assert timeparse.timeparse('32m') == 1920 + + +def test_hour_minute(): + assert timeparse.timeparse('2h32m') == 9120 + + +def test_minute_as_float(): + assert timeparse.timeparse('1.5m') == 90 + + +def test_hour_minute_second(): + assert timeparse.timeparse('5h34m56s') == 20096 + + +def test_invalid_with_space(): + assert timeparse.timeparse('5h 34m 56s') is None + + +def test_invalid_with_comma(): + assert timeparse.timeparse('5h,34m,56s') is None From 079c95c3401adb0f837e6b4e54132cdd41eada68 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 10 Nov 2016 17:47:05 -0500 Subject: [PATCH 509/580] Use stop grace period for container stop. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 14 +++++++++----- compose/parallel.py | 4 ---- compose/project.py | 20 ++++++++++++++++---- compose/service.py | 23 ++++++++++++++++------- tests/unit/timeparse_test.py | 4 ++++ 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 08e58e37..cf53f6aa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,7 +24,6 @@ from ..config import ConfigurationError from ..config import parse_environment from ..config.environment import Environment from ..config.serialize import serialize_config -from ..const import DEFAULT_TIMEOUT from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError from ..progress_stream import StreamOutputError @@ -726,7 +725,7 @@ class TopLevelCommand(object): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ - timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + timeout = timeout_from_opts(options) for s in options['SERVICE=NUM']: if '=' not in s: @@ -760,7 +759,7 @@ class TopLevelCommand(object): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ - timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + timeout = timeout_from_opts(options) self.project.stop(service_names=options['SERVICE'], timeout=timeout) def restart(self, options): @@ -773,7 +772,7 @@ class TopLevelCommand(object): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ - timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + timeout = timeout_from_opts(options) containers = self.project.restart(service_names=options['SERVICE'], timeout=timeout) exit_if(not containers, 'No containers to restart', 1) @@ -831,7 +830,7 @@ class TopLevelCommand(object): start_deps = not options['--no-deps'] cascade_stop = options['--abort-on-container-exit'] service_names = options['SERVICE'] - timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] detached = options.get('-d') @@ -896,6 +895,11 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed +def timeout_from_opts(options): + timeout = options.get('--timeout') + return None if timeout is None else int(timeout) + + def image_type_from_opt(flag, value): if not value: return ImageType.none diff --git a/compose/parallel.py b/compose/parallel.py index 7ac66b37..26718872 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -248,7 +248,3 @@ def parallel_unpause(containers, options): def parallel_kill(containers, options): parallel_operation(containers, 'kill', options, 'Killing') - - -def parallel_restart(containers, options): - parallel_operation(containers, 'restart', options, 'Restarting') diff --git a/compose/project.py b/compose/project.py index 60647fe9..eef2f3b8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -14,7 +14,6 @@ from .config import ConfigurationError from .config.config import V1 from .config.sort_services import get_container_name_from_network_mode from .config.sort_services import get_service_name_from_network_mode -from .const import DEFAULT_TIMEOUT from .const import IMAGE_EVENTS from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT @@ -250,7 +249,7 @@ class Project(object): parallel.parallel_execute( containers, - operator.methodcaller('stop', **options), + self.build_container_operation_with_timeout_func('stop', options), operator.attrgetter('name'), 'Stopping', get_deps) @@ -291,7 +290,12 @@ class Project(object): def restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) - parallel.parallel_restart(containers, options) + + parallel.parallel_execute( + containers, + self.build_container_operation_with_timeout_func('restart', options), + operator.attrgetter('name'), + 'Restarting') return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): @@ -365,7 +369,7 @@ class Project(object): start_deps=True, strategy=ConvergenceStrategy.changed, do_build=BuildAction.none, - timeout=DEFAULT_TIMEOUT, + timeout=None, detached=False, remove_orphans=False): @@ -506,6 +510,14 @@ class Project(object): dep_services.append(service) return acc + dep_services + def build_container_operation_with_timeout_func(self, operation, options): + def container_operation_with_timeout(container): + if options.get('timeout') is None: + service = self.get_service(container.service) + options['timeout'] = service.stop_timeout(None) + return getattr(container, operation)(**options) + return container_operation_with_timeout + def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) diff --git a/compose/service.py b/compose/service.py index ad426706..39737694 100644 --- a/compose/service.py +++ b/compose/service.py @@ -17,6 +17,7 @@ from docker.utils.ports import split_port from . import __version__ from . import progress_stream +from . import timeparse from .config import DOCKER_CONFIG_KEYS from .config import merge_environment from .config.types import VolumeSpec @@ -169,7 +170,7 @@ class Service(object): self.start_container_if_stopped(c, **options) return containers - def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): + def scale(self, desired_num, timeout=None): """ Adjusts the number of containers to the specified number and ensures they are running. @@ -196,7 +197,7 @@ class Service(object): return container def stop_and_remove(container): - container.stop(timeout=timeout) + container.stop(timeout=self.stop_timeout(timeout)) container.remove() running_containers = self.containers(stopped=False) @@ -374,7 +375,7 @@ class Service(object): def execute_convergence_plan(self, plan, - timeout=DEFAULT_TIMEOUT, + timeout=None, detached=False, start=True): (action, containers) = plan @@ -421,7 +422,7 @@ class Service(object): def recreate_container( self, container, - timeout=DEFAULT_TIMEOUT, + timeout=None, attach_logs=False, start_new_container=True): """Recreate a container. @@ -432,7 +433,7 @@ class Service(object): """ log.info("Recreating %s" % container.name) - container.stop(timeout=timeout) + container.stop(timeout=self.stop_timeout(timeout)) container.rename_to_tmp_name() new_container = self.create_container( previous_container=container, @@ -446,6 +447,14 @@ class Service(object): container.remove() return new_container + def stop_timeout(self, timeout): + if timeout is not None: + return timeout + timeout = timeparse.timeparse(self.options.get('stop_grace_period') or '') + if timeout is not None: + return timeout + return DEFAULT_TIMEOUT + def start_container_if_stopped(self, container, attach_logs=False, quiet=False): if not container.is_running: if not quiet: @@ -483,10 +492,10 @@ class Service(object): link_local_ips=netdefs.get('link_local_ips', None), ) - def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): + def remove_duplicate_containers(self, timeout=None): for c in self.duplicate_containers(): log.info('Removing %s' % c.name) - c.stop(timeout=timeout) + c.stop(timeout=self.stop_timeout(timeout)) c.remove() def duplicate_containers(self): diff --git a/tests/unit/timeparse_test.py b/tests/unit/timeparse_test.py index e9fe6c24..9915932c 100644 --- a/tests/unit/timeparse_test.py +++ b/tests/unit/timeparse_test.py @@ -50,3 +50,7 @@ def test_invalid_with_space(): def test_invalid_with_comma(): assert timeparse.timeparse('5h,34m,56s') is None + + +def test_invalid_with_empty_string(): + assert timeparse.timeparse('') is None From b93211881b913091501a76d062f033754a653740 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 16 Nov 2016 13:35:29 -0800 Subject: [PATCH 510/580] Changelog update Signed-off-by: Joffrey F --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec7d5b5..17487890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,106 @@ Change log ========== +1.9.0 (2016-11-16) +----------------- + +**Breaking changes** + +- When using Compose with Docker Toolbox/Machine on Windows, volume paths are + no longer converted from `C:\Users` to `/c/Users`-style by default. To + re-enable this conversion so that your volumes keep working, set the + environment variable `COMPOSE_CONVERT_WINDOWS_PATHS=1`. Users of + Docker for Windows are not affected and do not need to set the variable. + +New Features + +- Interactive mode for `docker-compose run` and `docker-compose exec` is + now supported on Windows platforms. Please note that the `docker` binary + is required to be present on the system for this feature to work. + +- Introduced version 2.1 of the `docker-compose.yml` specification. This + version requires to be used with Docker Engine 1.12 or above. + - Added support for setting volume labels and network labels in + `docker-compose.yml`. + - Added support for the `isolation` parameter in service definitions. + - Added support for link-local IPs in the service networks definitions. + - Added support for shell-style inline defaults in variable interpolation. + The supported forms are `${FOO-default}` (fall back if FOO is unset) and + `${FOO:-default}` (fall back if FOO is unset or empty). + +- Added support for the `group_add` and `oom_score_adj` parameters in + service definitions. + +- Added support for the `internal` and `enable_ipv6` parameters in network + definitions. + +- Compose now defaults to using the `npipe` protocol on Windows. + +- Overriding a `logging` configuration will now properly merge the `options` + mappings if the `driver` values do not conflict. + +Bug Fixes + +- Fixed several bugs related to `npipe` protocol support on Windows. + +- Fixed an issue with Windows paths being incorrectly converted when + using Docker on Windows Server. + +- Fixed a bug where an empty `restart` value would sometimes result in an + exception being raised. + +- Fixed an issue where service logs containing unicode characters would + sometimes cause an error to occur. + +- Fixed a bug where unicode values in environment variables would sometimes + raise a unicode exception when retrieved. + +- Fixed an issue where Compose would incorrectly detect a configuration + mismatch for overlay networks. + + +1.8.1 (2016-09-22) +----------------- + +Bug Fixes + +- Fixed a bug where users using a credentials store were not able + to access their private images. + +- Fixed a bug where users using identity tokens to authenticate + were not able to access their private images. + +- Fixed a bug where an `HttpHeaders` entry in the docker configuration + file would cause Compose to crash when trying to build an image. + +- Fixed a few bugs related to the handling of Windows paths in volume + binding declarations. + +- Fixed a bug where Compose would sometimes crash while trying to + read a streaming response from the engine. + +- Fixed an issue where Compose would crash when encountering an API error + while streaming container logs. + +- Fixed an issue where Compose would erroneously try to output logs from + drivers not handled by the Engine's API. + +- Fixed a bug where options from the `docker-machine config` command would + not be properly interpreted by Compose. + +- Fixed a bug where the connection to the Docker Engine would + sometimes fail when running a large number of services simultaneously. + +- Fixed an issue where Compose would sometimes print a misleading + suggestion message when running the `bundle` command. + +- Fixed a bug where connection errors would not be handled properly by + Compose during the project initialization phase. + +- Fixed a bug where a misleading error would appear when encountering + a connection timeout. + + 1.8.0 (2016-06-14) ----------------- From 716a6baa59c62266af0bb7628ec88586f470140c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 14 Nov 2016 18:31:38 +0000 Subject: [PATCH 511/580] Implement 'healthcheck' option Signed-off-by: Aanand Prasad --- compose/config/config.py | 29 +++++++++++ compose/config/config_schema_v3.0.json | 5 +- compose/service.py | 4 +- compose/utils.py | 16 ++++++ requirements.txt | 2 +- tests/acceptance/cli_test.py | 50 +++++++++++++++++-- tests/fixtures/healthcheck/docker-compose.yml | 24 +++++++++ tests/fixtures/v3-full/docker-compose.yml | 2 +- tests/unit/config/config_test.py | 49 ++++++++++++++++++ 9 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/healthcheck/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 940b9eb0..e5a37e84 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -17,6 +17,7 @@ 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 ..utils import build_string_dict +from ..utils import parse_nanoseconds_int from ..utils import splitdrive from .environment import env_vars_from_file from .environment import Environment @@ -65,6 +66,7 @@ DOCKER_CONFIG_KEYS = [ 'extra_hosts', 'group_add', 'hostname', + 'healthcheck', 'image', 'ipc', 'labels', @@ -642,6 +644,10 @@ def process_service(service_config): if 'extra_hosts' in service_dict: service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts']) + if 'healthcheck' in service_dict: + service_dict['healthcheck'] = process_healthcheck( + service_dict['healthcheck'], service_config.name) + for field in ['dns', 'dns_search', 'tmpfs']: if field in service_dict: service_dict[field] = to_list(service_dict[field]) @@ -649,6 +655,29 @@ def process_service(service_config): return service_dict +def process_healthcheck(raw, service_name): + hc = {} + + if raw.get('disable'): + if len(raw) > 1: + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + '"disable: true" cannot be combined with other options' + .format(service_name)) + hc['test'] = ['NONE'] + elif 'test' in raw: + hc['test'] = raw['test'] + + if 'interval' in raw: + hc['interval'] = parse_nanoseconds_int(raw['interval']) + if 'timeout' in raw: + hc['timeout'] = parse_nanoseconds_int(raw['timeout']) + if 'retries' in raw: + hc['retries'] = raw['retries'] + + return hc + + def finalize_service(service_config, service_names, version, environment): service_dict = dict(service_config.config) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 9ac31b1f..4edd8dd4 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -205,12 +205,13 @@ "interval": {"type":"string"}, "timeout": {"type":"string"}, "retries": {"type": "number"}, - "command": { + "test": { "oneOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}} ] - } + }, + "disable": {"type": "boolean"} }, "additionalProperties": false }, diff --git a/compose/service.py b/compose/service.py index 39737694..cf52d489 100644 --- a/compose/service.py +++ b/compose/service.py @@ -17,7 +17,6 @@ from docker.utils.ports import split_port from . import __version__ from . import progress_stream -from . import timeparse from .config import DOCKER_CONFIG_KEYS from .config import merge_environment from .config.types import VolumeSpec @@ -35,6 +34,7 @@ from .parallel import parallel_start from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash +from .utils import parse_seconds_float log = logging.getLogger(__name__) @@ -450,7 +450,7 @@ class Service(object): def stop_timeout(self, timeout): if timeout is not None: return timeout - timeout = timeparse.timeparse(self.options.get('stop_grace_period') or '') + timeout = parse_seconds_float(self.options.get('stop_grace_period')) if timeout is not None: return timeout return DEFAULT_TIMEOUT diff --git a/compose/utils.py b/compose/utils.py index 8f05e308..b8bdf732 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -11,6 +11,7 @@ import ntpath import six from .errors import StreamParseError +from .timeparse import timeparse json_decoder = json.JSONDecoder() @@ -107,6 +108,21 @@ def microseconds_from_time_nano(time_nano): return int(time_nano % 1000000000 / 1000) +def nanoseconds_from_time_seconds(time_seconds): + return time_seconds * 1000000000 + + +def parse_seconds_float(value): + return timeparse(value or '') + + +def parse_nanoseconds_int(value): + parsed = timeparse(value or '') + if parsed is None: + return None + return int(parsed * 1000000000) + + def build_string_dict(source_dict): return dict((k, str(v if v is not None else '')) for k, v in source_dict.items()) diff --git a/requirements.txt b/requirements.txt index 933146c7..63469799 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 -docker-py==1.10.6 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' +git+https://github.com/docker/docker-py.git@2ff7371ae7703033f981e1b137a3be0caf7a4f9c#egg=docker-py ipaddress==1.0.16 jsonschema==2.5.1 pypiwin32==219; sys_platform == 'win32' diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e9a41691..97518f4f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -21,6 +21,7 @@ from .. import mock from compose.cli.command import get_project from compose.container import Container from compose.project import OneOffFilter +from compose.utils import nanoseconds_from_time_seconds from tests.integration.testcases import DockerClientTestCase from tests.integration.testcases import get_links from tests.integration.testcases import pull_busybox @@ -329,9 +330,9 @@ class CLITestCase(DockerClientTestCase): }, 'healthcheck': { - 'command': 'cat /etc/passwd', - 'interval': '10s', - 'timeout': '1s', + 'test': 'cat /etc/passwd', + 'interval': 10000000000, + 'timeout': 1000000000, 'retries': 5, }, @@ -925,6 +926,49 @@ class CLITestCase(DockerClientTestCase): assert foo_container.get('HostConfig.NetworkMode') == \ 'container:{}'.format(bar_container.id) + def test_up_with_healthcheck(self): + def wait_on_health_status(container, status): + def condition(): + container.inspect() + return container.get('State.Health.Status') == status + + return wait_on_condition(condition, delay=0.5) + + self.base_dir = 'tests/fixtures/healthcheck' + self.dispatch(['up', '-d'], None) + + passes = self.project.get_service('passes') + passes_container = passes.containers()[0] + + assert passes_container.get('Config.Healthcheck') == { + "Test": ["CMD-SHELL", "/bin/true"], + "Interval": nanoseconds_from_time_seconds(1), + "Timeout": nanoseconds_from_time_seconds(30*60), + "Retries": 1, + } + + wait_on_health_status(passes_container, 'healthy') + + fails = self.project.get_service('fails') + fails_container = fails.containers()[0] + + assert fails_container.get('Config.Healthcheck') == { + "Test": ["CMD", "/bin/false"], + "Interval": nanoseconds_from_time_seconds(2.5), + "Retries": 2, + } + + wait_on_health_status(fails_container, 'unhealthy') + + disabled = self.project.get_service('disabled') + disabled_container = disabled.containers()[0] + + assert disabled_container.get('Config.Healthcheck') == { + "Test": ["NONE"], + } + + assert 'Health' not in disabled_container.get('State') + def test_up_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', '--no-deps', 'web'], None) diff --git a/tests/fixtures/healthcheck/docker-compose.yml b/tests/fixtures/healthcheck/docker-compose.yml new file mode 100644 index 00000000..2c45b8d8 --- /dev/null +++ b/tests/fixtures/healthcheck/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3" +services: + passes: + image: busybox + command: top + healthcheck: + test: "/bin/true" + interval: 1s + timeout: 30m + retries: 1 + + fails: + image: busybox + command: top + healthcheck: + test: ["CMD", "/bin/false"] + interval: 2.5s + retries: 2 + + disabled: + image: busybox + command: top + healthcheck: + disable: true diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index 1187dd7b..b4d1b642 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -29,7 +29,7 @@ services: constraints: [node=foo] healthcheck: - command: cat /etc/passwd + test: cat /etc/passwd interval: 10s timeout: 1s retries: 5 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 114145e1..f7df3aee 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -24,6 +24,7 @@ from compose.config.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM +from compose.utils import nanoseconds_from_time_seconds from tests import mock from tests import unittest @@ -3171,6 +3172,54 @@ class BuildPathTest(unittest.TestCase): assert 'build path' in exc.exconly() +class HealthcheckTest(unittest.TestCase): + def test_healthcheck(self): + service_dict = make_service_dict( + 'test', + {'healthcheck': { + 'test': ['CMD', 'true'], + 'interval': '1s', + 'timeout': '1m', + 'retries': 3, + }}, + '.', + ) + + assert service_dict['healthcheck'] == { + 'test': ['CMD', 'true'], + 'interval': nanoseconds_from_time_seconds(1), + 'timeout': nanoseconds_from_time_seconds(60), + 'retries': 3, + } + + def test_disable(self): + service_dict = make_service_dict( + 'test', + {'healthcheck': { + 'disable': True, + }}, + '.', + ) + + assert service_dict['healthcheck'] == { + 'test': ['NONE'], + } + + def test_disable_with_other_config_is_invalid(self): + with pytest.raises(ConfigurationError) as excinfo: + make_service_dict( + 'invalid-healthcheck', + {'healthcheck': { + 'disable': True, + 'interval': '1s', + }}, + '.', + ) + + assert 'invalid-healthcheck' in excinfo.exconly() + assert 'disable' in excinfo.exconly() + + class GetDefaultConfigFilesTestCase(unittest.TestCase): files = [ From c26a2afaf36443fbc4ea813e34027bdb05ded0e1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 21 Nov 2016 16:34:49 -0500 Subject: [PATCH 512/580] Update messages about docker stack deploy. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 ++-- compose/project.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 940b9eb0..5215b361 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -334,8 +334,8 @@ def load(config_details): if services_using_deploy: log.warn( "Some services ({}) use the 'deploy' key, which will be ignored. " - "Compose does not support deploy configuration - use the experimental " - "`docker deploy` command to deploy to a swarm." + "Compose does not support deploy configuration - use " + "`docker stack deploy` to deploy to a swarm." .format(", ".join(sorted(s['name'] for s in services_using_deploy)))) return Config(main_file.version, service_dicts, volumes, networks) diff --git a/compose/project.py b/compose/project.py index eef2f3b8..0178bbab 100644 --- a/compose/project.py +++ b/compose/project.py @@ -559,9 +559,7 @@ def warn_for_swarm_mode(client): "Compose does not use swarm mode to deploy services to multiple nodes in a swarm. " "All containers will be scheduled on the current node.\n\n" "To deploy your application across the swarm, " - "use the bundle feature of the Docker experimental build.\n\n" - "More info:\n" - "https://docs.docker.com/compose/bundles\n" + "use `docker stack deploy`.\n" ) From 024b5dd6da4c96822ea84e31cedcb88a8949a245 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 22 Nov 2016 11:14:56 +0000 Subject: [PATCH 513/580] case PyPI correctly Signed-off-by: Thomas Grainger --- CHANGELOG.md | 2 +- script/release/push-release | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17487890..9780df98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -814,7 +814,7 @@ Fig has been renamed to Docker Compose, or just Compose for short. This has seve - The command you type is now `docker-compose`, not `fig`. - You should rename your fig.yml to docker-compose.yml. -- If you’re installing via PyPi, the package is now `docker-compose`, so install it with `pip install docker-compose`. +- If you’re installing via PyPI, the package is now `docker-compose`, so install it with `pip install docker-compose`. Besides that, there’s a lot of new stuff in this release: diff --git a/script/release/push-release b/script/release/push-release index 33d0d777..d5ae3de9 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -54,7 +54,7 @@ git push $GITHUB_REPO $VERSION echo "Uploading the docker image" docker push docker/compose:$VERSION -echo "Uploading sdist to pypi" +echo "Uploading sdist to PyPI" pandoc -f markdown -t rst README.md -o README.rst sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst ./script/build/write-git-sha From dc9184a90fd22b13265162c4bc8e04c9867a768d Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Fri, 25 Nov 2016 10:12:22 +0000 Subject: [PATCH 514/580] progress_stream: Avoid undefined ANSI escape codes The ANSI escape codes \e[0A (cursor up 0 lines) and \e[0B (cursor down 0 lines) are not well defined and are treated differently by different terminals. In particular xterm treats 0 as a missing parameter and therefore defaults to 1, whereas rxvt-unicode treats these escapes as a request to move 0 lines. However the use of these codes is unnecessary and were really just hiding the fact that we were not correctly computing diff when adding a new line. Having added the new line to the ids map and output the corresponding \n the correct diff would be 1 and not 0 (which xterm interprets as 1) as currently. Rather than changing the hardcoded 0 to a 1 pull the diff calculation out and always do it since it produces the correct answer in both cases. This fixes similar corruption when compose is pulling an image to that seen with `docker pull` and rxvt-unicode (and likely other terminals in that family) seen in docker/docker#28111. This is the same as the fix made to Docker's pkg/jsonmessage in https://github.com/docker/docker/pull/28238 (and I have shamelessly ripped off most of this commit message from there). Signed-off-by: Ian Campbell --- compose/progress_stream.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index a0f5601f..5314f89f 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -32,12 +32,11 @@ def stream_output(output, stream): if not image_id: continue - if image_id in lines: - diff = len(lines) - lines[image_id] - else: + if image_id not in lines: lines[image_id] = len(lines) stream.write("\n") - diff = 0 + + diff = len(lines) - lines[image_id] # move cursor up `diff` rows stream.write("%c[%dA" % (27, diff)) From 6aacf5142750654a9b1f165223478fab3a424eeb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Dec 2016 16:10:15 -0800 Subject: [PATCH 515/580] Win32 interactive run - Connect container to networks before starting Signed-off-by: Joffrey F --- compose/cli/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index cf53f6aa..c25ccbfa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -988,6 +988,7 @@ def run_one_off_container(container_options, project, service, options): try: try: if IS_WINDOWS_PLATFORM: + service.connect_container_to_networks(container) exit_code = call_docker(["start", "--attach", "--interactive", container.id]) else: operation = RunOperation( From 62f8b1402e1bbe726cf83ea204077754385fe53a Mon Sep 17 00:00:00 2001 From: Yong Wen Chua Date: Mon, 5 Dec 2016 14:25:56 +0800 Subject: [PATCH 516/580] Implement `userns_mode` HostConfig for services Fixes #3349 This allows the key `userns_mode` to be used in service definitions. Since `userns_mode` requires API version > 1.23, this is only available in 2.1 and 3.0 versions of compose file Signed-off-by: Yong Wen Chua --- compose/config/config.py | 1 + compose/config/config_schema_v2.1.json | 1 + compose/config/config_schema_v3.0.json | 1 + compose/service.py | 4 +++- tests/integration/service_test.py | 13 +++++++++++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5215b361..b83b12bf 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -86,6 +86,7 @@ DOCKER_CONFIG_KEYS = [ 'stop_signal', 'tty', 'user', + 'userns_mode', 'volume_driver', 'volumes', 'volumes_from', diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 3561a8cf..b5d614d6 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -217,6 +217,7 @@ } }, "user": {"type": "string"}, + "userns_mode": {"type": "string"}, "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "volume_driver": {"type": "string"}, "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 9ac31b1f..297c60be 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -192,6 +192,7 @@ } }, "user": {"type": "string"}, + "userns_mode": {"type": "string"}, "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "working_dir": {"type": "string"} }, diff --git a/compose/service.py b/compose/service.py index 39737694..6a62067b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -64,6 +64,7 @@ DOCKER_START_KEYS = [ 'restart', 'security_opt', 'shm_size', + 'userns_mode', 'volumes_from', ] @@ -720,7 +721,8 @@ class Service(object): tmpfs=options.get('tmpfs'), oom_score_adj=options.get('oom_score_adj'), mem_swappiness=options.get('mem_swappiness'), - group_add=options.get('group_add') + group_add=options.get('group_add'), + userns_mode=options.get('userns_mode') ) # TODO: Add as an argument to create_host_config once it's supported diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a5ca81ee..09758eee 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -30,6 +30,7 @@ from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode from compose.service import Service +from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only @@ -842,6 +843,18 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.PidMode'), 'host') + @v2_1_only() + def test_userns_mode_none_defined(self): + service = self.create_service('web', userns_mode=None) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.UsernsMode'), '') + + @v2_1_only() + def test_userns_mode_host(self): + service = self.create_service('web', userns_mode='host') + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.UsernsMode'), 'host') + def test_dns_no_value(self): service = self.create_service('web') container = create_and_start_container(service) From 4d0575355c1a0201ed7fc11063a4efbca6971a96 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Dec 2016 17:14:05 -0800 Subject: [PATCH 517/580] Bump master version to 1.10.0dev Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/__init__.py b/compose/__init__.py index 6e610652..6f05b282 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.9.0dev' +__version__ = '1.10.0dev' From e04a12b5ca9fb30d152ab6a83ae8c0ec15ac85b0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 6 Dec 2016 17:01:35 -0500 Subject: [PATCH 518/580] Increase minimum version for v3. Signed-off-by: Daniel Nephin --- compose/const.py | 4 ++-- tests/acceptance/cli_test.py | 2 ++ tests/integration/testcases.py | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/compose/const.py b/compose/const.py index 3da39855..ca8d7fe5 100644 --- a/compose/const.py +++ b/compose/const.py @@ -23,12 +23,12 @@ API_VERSIONS = { COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_1: '1.24', - COMPOSEFILE_V3_0: '1.24', + COMPOSEFILE_V3_0: '1.25', } API_VERSION_TO_ENGINE_VERSION = { API_VERSIONS[COMPOSEFILE_V1]: '1.9.0', API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', - API_VERSIONS[COMPOSEFILE_V3_0]: '1.12.0', + API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', } diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e9a41691..0d5d5058 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -26,6 +26,7 @@ from tests.integration.testcases import get_links from tests.integration.testcases import pull_busybox from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only +from tests.integration.testcases import v3_only ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -285,6 +286,7 @@ class CLITestCase(DockerClientTestCase): 'volumes': {}, } + @v3_only() def test_config_v3(self): self.base_dir = 'tests/fixtures/v3-full' result = self.dispatch(['config']) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index c7743fb8..f6bc402b 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -45,11 +45,11 @@ def engine_max_version(): return V2_1 -def v2_only(): +def build_version_required_decorator(ignored_versions): def decorator(f): @functools.wraps(f) def wrapper(self, *args, **kwargs): - if engine_max_version() == V1: + if engine_max_version() in ignored_versions: skip("Engine version is too low") return return f(self, *args, **kwargs) @@ -58,17 +58,16 @@ def v2_only(): return decorator -def v2_1_only(): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - if engine_max_version() in (V1, V2_0): - skip('Engine version is too low') - return - return f(self, *args, **kwargs) - return wrapper +def v2_only(): + return build_version_required_decorator((V1,)) - return decorator + +def v2_1_only(): + return build_version_required_decorator((V1, V2_0)) + + +def v3_only(): + return build_version_required_decorator((V1, V2_0, V2_1)) class DockerClientTestCase(unittest.TestCase): From 04e5925a230d07a6ffd855b4c3812f5a6b54a523 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Dec 2016 17:42:07 -0800 Subject: [PATCH 519/580] Use docker SDK 2.0 Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 4 ++-- compose/network.py | 8 ++++---- compose/service.py | 2 +- requirements.txt | 2 +- setup.py | 2 +- tests/unit/bundle_test.py | 2 +- tests/unit/cli_test.py | 4 ++-- tests/unit/container_test.py | 2 +- tests/unit/project_test.py | 2 +- tests/unit/service_test.py | 10 +++++----- tests/unit/volume_test.py | 2 +- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index b196d303..018d2451 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import logging -from docker import Client +from docker import APIClient from docker.errors import TLSParameterError from docker.tls import TLSConfig from docker.utils import kwargs_from_env @@ -71,4 +71,4 @@ def docker_client(environment, version=None, tls_config=None, host=None, kwargs['user_agent'] = generate_user_agent() - return Client(**kwargs) + return APIClient(**kwargs) diff --git a/compose/network.py b/compose/network.py index 3b57cb94..8a29b73e 100644 --- a/compose/network.py +++ b/compose/network.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals import logging from docker.errors import NotFound -from docker.utils import create_ipam_config -from docker.utils import create_ipam_pool +from docker.types import IPAMConfig +from docker.types import IPAMPool from .config import ConfigurationError @@ -96,10 +96,10 @@ def create_ipam_config_from_dict(ipam_dict): if not ipam_dict: return None - return create_ipam_config( + return IPAMConfig( driver=ipam_dict.get('driver'), pool_configs=[ - create_ipam_pool( + IPAMPool( subnet=config.get('subnet'), iprange=config.get('ip_range'), gateway=config.get('gateway'), diff --git a/compose/service.py b/compose/service.py index cf52d489..c32788fe 100644 --- a/compose/service.py +++ b/compose/service.py @@ -11,7 +11,7 @@ import enum import six from docker.errors import APIError from docker.errors import NotFound -from docker.utils import LogConfig +from docker.types import LogConfig from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port diff --git a/requirements.txt b/requirements.txt index 63469799..8f6fe169 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 +docker==2.0.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -git+https://github.com/docker/docker-py.git@2ff7371ae7703033f981e1b137a3be0caf7a4f9c#egg=docker-py ipaddress==1.0.16 jsonschema==2.5.1 pypiwin32==219; sys_platform == 'win32' diff --git a/setup.py b/setup.py index 672ea80e..9dba2167 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ install_requires = [ 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.10.6, < 2.0', + 'docker >= 2.0.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 223b3b07..a279cab0 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -15,7 +15,7 @@ from compose.config.config import Config def mock_service(): return mock.create_autospec( service.Service, - client=mock.create_autospec(docker.Client), + client=mock.create_autospec(docker.APIClient), options={}) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 2c90b29b..f9b60bff 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -97,7 +97,7 @@ class CLITestCase(unittest.TestCase): @mock.patch('compose.cli.main.RunOperation', autospec=True) @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): - mock_client = mock.create_autospec(docker.Client) + mock_client = mock.create_autospec(docker.APIClient) project = Project.from_config( name='composetest', client=mock_client, @@ -128,7 +128,7 @@ class CLITestCase(unittest.TestCase): assert call_kwargs['logs'] is False def test_run_service_with_restart_always(self): - mock_client = mock.create_autospec(docker.Client) + mock_client = mock.create_autospec(docker.APIClient) project = Project.from_config( name='composetest', diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 62e3aa2c..04f43016 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -98,7 +98,7 @@ class ContainerTest(unittest.TestCase): self.assertEqual(container.name_without_project, "custom_name_of_container") def test_inspect_if_not_inspected(self): - mock_client = mock.create_autospec(docker.Client) + mock_client = mock.create_autospec(docker.APIClient) container = Container(mock_client, dict(Id="the_id")) container.inspect_if_not_inspected() diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 9569adc9..9a12438f 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -19,7 +19,7 @@ from compose.service import Service class ProjectTest(unittest.TestCase): def setUp(self): - self.mock_client = mock.create_autospec(docker.Client) + self.mock_client = mock.create_autospec(docker.APIClient) def test_from_config(self): config = Config( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 1d5aa10f..2d5b1761 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -34,7 +34,7 @@ from compose.service import warn_on_masked_volume class ServiceTest(unittest.TestCase): def setUp(self): - self.mock_client = mock.create_autospec(docker.Client) + self.mock_client = mock.create_autospec(docker.APIClient) def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') @@ -666,7 +666,7 @@ class ServiceTest(unittest.TestCase): class TestServiceNetwork(object): def test_connect_container_to_networks_short_aliase_exists(self): - mock_client = mock.create_autospec(docker.Client) + mock_client = mock.create_autospec(docker.APIClient) service = Service( 'db', mock_client, @@ -751,7 +751,7 @@ class NetTestCase(unittest.TestCase): def test_network_mode_service(self): container_id = 'bbbb' service_name = 'web' - mock_client = mock.create_autospec(docker.Client) + mock_client = mock.create_autospec(docker.APIClient) mock_client.containers.return_value = [ {'Id': container_id, 'Name': container_id, 'Image': 'abcd'}, ] @@ -765,7 +765,7 @@ class NetTestCase(unittest.TestCase): def test_network_mode_service_no_containers(self): service_name = 'web' - mock_client = mock.create_autospec(docker.Client) + mock_client = mock.create_autospec(docker.APIClient) mock_client.containers.return_value = [] service = Service(name=service_name, client=mock_client) @@ -783,7 +783,7 @@ def build_mount(destination, source, mode='rw'): class ServiceVolumesTest(unittest.TestCase): def setUp(self): - self.mock_client = mock.create_autospec(docker.Client) + self.mock_client = mock.create_autospec(docker.APIClient) def test_build_volume_binding(self): binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True)) diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py index d7ad0792..24829192 100644 --- a/tests/unit/volume_test.py +++ b/tests/unit/volume_test.py @@ -10,7 +10,7 @@ from tests import mock @pytest.fixture def mock_client(): - return mock.create_autospec(docker.Client) + return mock.create_autospec(docker.APIClient) class TestVolume(object): From fb165d9c1505e2505f637cb2cede77e4d0731884 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 16 Dec 2016 12:21:59 -0800 Subject: [PATCH 520/580] Add v3_only marker to healthcheck test Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 856b8f93..2d0ce715 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -316,8 +316,8 @@ class CLITestCase(DockerClientTestCase): 'memory': '50M', }, 'reservations': { - 'cpus': '0.0001', - 'memory': '20M', + 'cpus': '0.0001', + 'memory': '20M', }, }, 'restart_policy': { @@ -928,6 +928,7 @@ class CLITestCase(DockerClientTestCase): assert foo_container.get('HostConfig.NetworkMode') == \ 'container:{}'.format(bar_container.id) + @v3_only() def test_up_with_healthcheck(self): def wait_on_health_status(container, status): def condition(): @@ -945,7 +946,7 @@ class CLITestCase(DockerClientTestCase): assert passes_container.get('Config.Healthcheck') == { "Test": ["CMD-SHELL", "/bin/true"], "Interval": nanoseconds_from_time_seconds(1), - "Timeout": nanoseconds_from_time_seconds(30*60), + "Timeout": nanoseconds_from_time_seconds(30 * 60), "Retries": 1, } From e736151ee497a65df4e7e271ebd059731e250760 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Dec 2016 16:11:21 -0800 Subject: [PATCH 521/580] Make created networks attachable for file format >=2.1 Signed-off-by: Joffrey F --- compose/network.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/network.py b/compose/network.py index 8a29b73e..eb76e292 100644 --- a/compose/network.py +++ b/compose/network.py @@ -6,6 +6,7 @@ import logging from docker.errors import NotFound from docker.types import IPAMConfig from docker.types import IPAMPool +from docker.utils import version_gte from .config import ConfigurationError @@ -72,6 +73,7 @@ class Network(object): internal=self.internal, enable_ipv6=self.enable_ipv6, labels=self.labels, + attachable=version_gte(self.client._version, '1.24') or None ) def remove(self): From eb6441c8e337a1e797adee26b77c0c03465375a2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Dec 2016 20:35:09 -0800 Subject: [PATCH 522/580] Add sysctls option to 3.0 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v3.0.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 8b8ceac0..1f93347f 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -167,6 +167,7 @@ "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, "stdin_open": {"type": "boolean"}, "stop_signal": {"type": "string"}, "stop_grace_period": {"type": "string", "format": "duration"}, From 346802715dd3869c2a33c5d361ec8a56fa3352c5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 16:27:47 -0700 Subject: [PATCH 523/580] Use colorama to enable colored output on Windows Signed-off-by: Joffrey F --- compose/cli/colors.py | 4 ++++ requirements.txt | 1 + setup.py | 1 + 3 files changed, 6 insertions(+) diff --git a/compose/cli/colors.py b/compose/cli/colors.py index 3c18886f..6677a376 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -1,5 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals + +import colorama + NAMES = [ 'grey', 'red', @@ -30,6 +33,7 @@ def make_color_fn(code): return lambda s: ansi_color(code, s) +colorama.init() for (name, code) in get_pairs(): globals()[name] = make_color_fn(code) diff --git a/requirements.txt b/requirements.txt index 8f6fe169..bae5d9ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 +colorama==0.3.7 docker==2.0.0 dockerpty==0.4.1 docopt==0.6.1 diff --git a/setup.py b/setup.py index 9dba2167..8b4cf709 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ def find_version(*file_paths): install_requires = [ 'cached-property >= 1.2.0, < 2', + 'colorama >= 0.3.7, < 0.4', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', 'requests >= 2.6.1, != 2.11.0, < 2.12', From ba47fb99ba7670fc8435af0a958a3ed01188711a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Dec 2016 18:21:10 -0800 Subject: [PATCH 524/580] Add default labels to networks and volumes created by Compose Signed-off-by: Joffrey F --- compose/const.py | 2 ++ compose/network.py | 18 ++++++++++++++++-- compose/volume.py | 16 +++++++++++++++- tests/acceptance/cli_test.py | 8 ++++---- tests/integration/network_test.py | 17 +++++++++++++++++ tests/integration/project_test.py | 7 ++++--- tests/integration/volume_test.py | 10 ++++++++++ 7 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 tests/integration/network_test.py diff --git a/compose/const.py b/compose/const.py index ca8d7fe5..1b1be5c7 100644 --- a/compose/const.py +++ b/compose/const.py @@ -11,7 +11,9 @@ LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' LABEL_PROJECT = 'com.docker.compose.project' LABEL_SERVICE = 'com.docker.compose.service' +LABEL_NETWORK = 'com.docker.compose.network' LABEL_VERSION = 'com.docker.compose.version' +LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' COMPOSEFILE_V1 = '1' diff --git a/compose/network.py b/compose/network.py index eb76e292..d98f68d2 100644 --- a/compose/network.py +++ b/compose/network.py @@ -7,8 +7,11 @@ from docker.errors import NotFound from docker.types import IPAMConfig from docker.types import IPAMPool from docker.utils import version_gte +from docker.utils import version_lt from .config import ConfigurationError +from .const import LABEL_NETWORK +from .const import LABEL_PROJECT log = logging.getLogger(__name__) @@ -72,8 +75,8 @@ class Network(object): ipam=self.ipam, internal=self.internal, enable_ipv6=self.enable_ipv6, - labels=self.labels, - attachable=version_gte(self.client._version, '1.24') or None + labels=self._labels, + attachable=version_gte(self.client._version, '1.24') or None, ) def remove(self): @@ -93,6 +96,17 @@ class Network(object): return self.external_name return '{0}_{1}'.format(self.project, self.name) + @property + def _labels(self): + if version_lt(self.client._version, '1.23'): + return None + labels = self.labels.copy() if self.labels else {} + labels.update({ + LABEL_PROJECT: self.project, + LABEL_NETWORK: self.name, + }) + return labels + def create_ipam_config_from_dict(ipam_dict): if not ipam_dict: diff --git a/compose/volume.py b/compose/volume.py index 1fd1d51c..ab6a88fa 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -4,8 +4,11 @@ from __future__ import unicode_literals import logging from docker.errors import NotFound +from docker.utils import version_lt from .config import ConfigurationError +from .const import LABEL_PROJECT +from .const import LABEL_VOLUME log = logging.getLogger(__name__) @@ -23,7 +26,7 @@ class Volume(object): def create(self): return self.client.create_volume( - self.full_name, self.driver, self.driver_opts, labels=self.labels + self.full_name, self.driver, self.driver_opts, labels=self._labels ) def remove(self): @@ -53,6 +56,17 @@ class Volume(object): return self.external_name return '{0}_{1}'.format(self.project, self.name) + @property + def _labels(self): + if version_lt(self.client._version, '1.23'): + return None + labels = self.labels.copy() if self.labels else {} + labels.update({ + LABEL_PROJECT: self.project, + LABEL_VOLUME: self.name, + }) + return labels + class ProjectVolumes(object): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2d0ce715..b9766226 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -850,8 +850,8 @@ class CLITestCase(DockerClientTestCase): ] assert [n['Name'] for n in networks] == [network_with_label] - - assert networks[0]['Labels'] == {'label_key': 'label_val'} + assert 'label_key' in networks[0]['Labels'] + assert networks[0]['Labels']['label_key'] == 'label_val' @v2_1_only() def test_up_with_volume_labels(self): @@ -870,8 +870,8 @@ class CLITestCase(DockerClientTestCase): ] assert [v['Name'] for v in volumes] == [volume_with_label] - - assert volumes[0]['Labels'] == {'label_key': 'label_val'} + assert 'label_key' in volumes[0]['Labels'] + assert volumes[0]['Labels']['label_key'] == 'label_val' @v2_only() def test_up_no_services(self): diff --git a/tests/integration/network_test.py b/tests/integration/network_test.py new file mode 100644 index 00000000..2ff610fb --- /dev/null +++ b/tests/integration/network_test.py @@ -0,0 +1,17 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from .testcases import DockerClientTestCase +from compose.const import LABEL_NETWORK +from compose.const import LABEL_PROJECT +from compose.network import Network + + +class NetworkTest(DockerClientTestCase): + def test_network_default_labels(self): + net = Network(self.client, 'composetest', 'foonet') + net.ensure() + net_data = net.inspect() + labels = net_data['Labels'] + assert labels[LABEL_NETWORK] == net.name + assert labels[LABEL_PROJECT] == net.project diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 3afefb5a..de073239 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -942,8 +942,8 @@ class ProjectTest(DockerClientTestCase): ] assert [n['Name'] for n in networks] == ['composetest_{}'.format(network_name)] - - assert networks[0]['Labels'] == {'label_key': 'label_val'} + assert 'label_key' in networks[0]['Labels'] + assert networks[0]['Labels']['label_key'] == 'label_val' @v2_only() def test_project_up_volumes(self): @@ -1009,7 +1009,8 @@ class ProjectTest(DockerClientTestCase): assert [v['Name'] for v in volumes] == ['composetest_{}'.format(volume_name)] - assert volumes[0]['Labels'] == {'label_key': 'label_val'} + assert 'label_key' in volumes[0]['Labels'] + assert volumes[0]['Labels']['label_key'] == 'label_val' @v2_only() def test_project_up_logging_with_multiple_files(self): diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index a75250ac..add16962 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals from docker.errors import DockerException from .testcases import DockerClientTestCase +from compose.const import LABEL_PROJECT +from compose.const import LABEL_VOLUME from compose.volume import Volume @@ -94,3 +96,11 @@ class VolumeTest(DockerClientTestCase): assert vol.exists() is False vol.create() assert vol.exists() is True + + def test_volume_default_labels(self): + vol = self.create_volume('volume01') + vol.create() + vol_data = vol.inspect() + labels = vol_data['Labels'] + assert labels[LABEL_VOLUME] == vol.name + assert labels[LABEL_PROJECT] == vol.project From a74b2f2f70a82bc56fb463d46480ea1baad9d4ab Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Dec 2016 15:16:53 -0500 Subject: [PATCH 525/580] Fix schema typo. Signed-off-by: Daniel Nephin --- compose/config/config_schema_v3.0.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 1f93347f..6212058c 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -270,7 +270,7 @@ "cpus": {"type": "string"}, "memory": {"type": "string"} }, - "additionaProperties": false + "additionalProperties": false }, "network": { From c73fc26824f2bffa791472a27039da0d2dd1ccbe Mon Sep 17 00:00:00 2001 From: Jun Guo Date: Wed, 4 Jan 2017 15:31:12 +0800 Subject: [PATCH 526/580] Fix 404 issue, change APIError to more accureate ImageNotFound Signed-off-by: Jun Guo --- compose/service.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index e3862b6e..ee8c88a9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ from operator import attrgetter import enum import six from docker.errors import APIError +from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig from docker.utils.ports import build_port_bindings @@ -318,11 +319,8 @@ class Service(object): def image(self): try: return self.client.inspect_image(self.image_name) - except APIError as e: - if e.response.status_code == 404 and e.explanation and 'No such image' in str(e.explanation): - raise NoSuchImageError("Image '{}' not found".format(self.image_name)) - else: - raise + except ImageNotFound: + raise NoSuchImageError("Image '{}' not found".format(self.image_name)) @property def image_name(self): From 2648af6807f83f0dd85b236e89e4bc3ee5db15fc Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 22 Nov 2016 11:09:56 +0000 Subject: [PATCH 527/580] enable universal wheels Signed-off-by: Thomas Grainger --- Dockerfile.run | 2 +- script/build/image | 4 ++-- script/release/push-release | 6 +++--- setup.cfg | 2 ++ setup.py | 23 ++++++++++++++++++++++- 5 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 setup.cfg diff --git a/Dockerfile.run b/Dockerfile.run index 4e76d64f..c6852af1 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -7,7 +7,7 @@ RUN apk -U add \ COPY requirements.txt /code/requirements.txt RUN pip install -r /code/requirements.txt -ADD dist/docker-compose-release.tar.gz /code/docker-compose +ADD dist/docker-compose-release-py2.py3-none-any.whl /code/docker-compose RUN pip install --no-deps /code/docker-compose/docker-compose-* ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/script/build/image b/script/build/image index bdd98f03..28aa2047 100755 --- a/script/build/image +++ b/script/build/image @@ -11,6 +11,6 @@ TAG=$1 VERSION="$(python setup.py --version)" ./script/build/write-git-sha -python setup.py sdist -cp dist/docker-compose-$VERSION.tar.gz dist/docker-compose-release.tar.gz +python setup.py sdist bdist_wheel +cp dist/docker-compose-$VERSION-py2.py3-none-any.whl dist/docker-compose-release-py2.py3-none-any.whl docker build -t docker/compose:$TAG -f Dockerfile.run . diff --git a/script/release/push-release b/script/release/push-release index d5ae3de9..d1a9e3f6 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -54,13 +54,13 @@ git push $GITHUB_REPO $VERSION echo "Uploading the docker image" docker push docker/compose:$VERSION -echo "Uploading sdist to PyPI" +echo "Uploading package to PyPI" pandoc -f markdown -t rst README.md -o README.rst sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst ./script/build/write-git-sha -python setup.py sdist +python setup.py sdist bdist_wheel if [ "$(command -v twine 2> /dev/null)" ]; then - twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz + twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz ./dist/docker-compose-${VERSION/-/}-py2.py3-none-any.whl else python setup.py upload fi diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..3c6e79cf --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py index 8b4cf709..00ca9f4c 100644 --- a/setup.py +++ b/setup.py @@ -4,10 +4,12 @@ from __future__ import absolute_import from __future__ import unicode_literals import codecs +import logging import os import re import sys +import pkg_resources from setuptools import find_packages from setuptools import setup @@ -49,7 +51,25 @@ tests_require = [ if sys.version_info[:2] < (3, 4): tests_require.append('mock >= 1.0.1') - install_requires.append('enum34 >= 1.0.4, < 2') + +extras_require = { + ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'] +} + + +try: + if 'bdist_wheel' not in sys.argv: + for key, value in extras_require.items(): + if key.startswith(':') and pkg_resources.evaluate_marker(key[1:]): + install_requires.extend(value) +except Exception: + logging.getLogger(__name__).exception( + 'Something went wrong calculating platform specific dependencies, so ' + "you're getting them all!" + ) + for key, value in extras_require.items(): + if key.startswith(':'): + install_requires.extend(value) setup( @@ -63,6 +83,7 @@ setup( include_package_data=True, test_suite='nose.collector', install_requires=install_requires, + extras_require=extras_require, tests_require=tests_require, entry_points=""" [console_scripts] From 0edfe08bf017841a1a2624b98e4a05bb11fc6d4f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 13 Dec 2016 16:54:41 -0800 Subject: [PATCH 528/580] Add healthchecks to 2.1 schema. Update depends_on Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 42 +++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 3057f2de..5ab9f71f 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -77,7 +77,28 @@ "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, "cpuset": {"type": "string"}, - "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "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"] + } + } + } + ] + }, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, "dns_search": {"$ref": "#/definitions/string_or_list"}, @@ -120,6 +141,7 @@ "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"}, @@ -231,6 +253,24 @@ "additionalProperties": false }, + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disabled": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "network": { "id": "#/definitions/network", "type": "object", From f6edd610f36ec9f6ffdd16df970f3d6cefb1169d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 14 Dec 2016 15:39:42 -0800 Subject: [PATCH 529/580] Add 3.0 schema to docker-compose.spec Signed-off-by: Joffrey F --- docker-compose.spec | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.spec b/docker-compose.spec index e57d6b7a..ec5a2039 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -32,6 +32,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v2.1.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.0.json', + 'compose/config/config_schema_v3.0.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From 04394b1d0a82f4507edc8863c4ab9cf64944f6d9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Dec 2016 20:20:03 -0800 Subject: [PATCH 530/580] Expand depends_on to allow different conditions (service_start, service_healthy) Rework "up" and "start" to wait on conditional state of dependent services Add integration tests Signed-off-by: Joffrey F --- compose/config/config.py | 12 +++- compose/config/validation.py | 8 ++- compose/errors.py | 21 ++++++ compose/parallel.py | 7 +- compose/project.py | 12 +++- compose/service.py | 59 ++++++++++++++-- tests/acceptance/cli_test.py | 2 +- tests/integration/project_test.py | 114 ++++++++++++++++++++++++++++++ tests/unit/config/config_test.py | 9 ++- tests/unit/parallel_test.py | 2 +- 10 files changed, 228 insertions(+), 18 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index dccd11e0..73a34017 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -649,6 +649,8 @@ def process_service(service_config): if 'sysctls' in service_dict: service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) + service_dict = process_depends_on(service_dict) + for field in ['dns', 'dns_search', 'tmpfs']: if field in service_dict: service_dict[field] = to_list(service_dict[field]) @@ -658,6 +660,14 @@ def process_service(service_config): return service_dict +def process_depends_on(service_dict): + if 'depends_on' in service_dict and not isinstance(service_dict['depends_on'], dict): + service_dict['depends_on'] = dict([ + (svc, {'condition': 'service_started'}) for svc in service_dict['depends_on'] + ]) + return service_dict + + def process_healthcheck(service_dict, service_name): if 'healthcheck' not in service_dict: return service_dict @@ -665,7 +675,7 @@ def process_healthcheck(service_dict, service_name): hc = {} raw = service_dict['healthcheck'] - if raw.get('disable'): + if raw.get('disable') or raw.get('disabled'): if len(raw) > 1: raise ConfigurationError( 'Service "{}" defines an invalid healthcheck: ' diff --git a/compose/config/validation.py b/compose/config/validation.py index 7452e984..3f23f0a7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -180,11 +180,13 @@ def validate_links(service_config, service_names): def validate_depends_on(service_config, service_names): - for dependency in service_config.config.get('depends_on', []): + deps = service_config.config.get('depends_on', {}) + for dependency in deps.keys(): if dependency not in service_names: raise ConfigurationError( "Service '{s.name}' depends on service '{dep}' which is " - "undefined.".format(s=service_config, dep=dependency)) + "undefined.".format(s=service_config, dep=dependency) + ) def get_unsupported_config_msg(path, error_key): @@ -201,7 +203,7 @@ def anglicize_json_type(json_type): def is_service_dict_schema(schema_id): - return schema_id in ('config_schema_v1.json', '#/properties/services') + return schema_id in ('config_schema_v1.json', '#/properties/services') def handle_error_for_schema_with_id(error, path): diff --git a/compose/errors.py b/compose/errors.py index 376cc555..415b41e7 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -10,3 +10,24 @@ class OperationFailedError(Exception): class StreamParseError(RuntimeError): def __init__(self, reason): self.msg = reason + + +class HealthCheckException(Exception): + def __init__(self, reason): + self.msg = reason + + +class HealthCheckFailed(HealthCheckException): + def __init__(self, container_id): + super(HealthCheckFailed, self).__init__( + 'Container "{}" is unhealthy.'.format(container_id) + ) + + +class NoHealthCheckConfigured(HealthCheckException): + def __init__(self, service_name): + super(NoHealthCheckConfigured, self).__init__( + 'Service "{}" is missing a healthcheck configuration'.format( + service_name + ) + ) diff --git a/compose/parallel.py b/compose/parallel.py index 26718872..b2654dcf 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -165,13 +165,14 @@ def feed_queue(objects, func, get_deps, results, state): for obj in pending: deps = get_deps(obj) - if any(dep in state.failed for dep in deps): + if any(dep[0] in state.failed for dep in deps): log.debug('{} has upstream errors - not processing'.format(obj)) results.put((obj, None, UpstreamError())) state.failed.add(obj) elif all( - dep not in objects or dep in state.finished - for dep in deps + dep not in objects or ( + dep in state.finished and (not ready_check or ready_check(dep)) + ) for dep, ready_check in deps ): log.debug('Starting producer thread for {}'.format(obj)) t = Thread(target=producer, args=(obj, func, results)) diff --git a/compose/project.py b/compose/project.py index 0178bbab..d99ef7c9 100644 --- a/compose/project.py +++ b/compose/project.py @@ -227,7 +227,10 @@ class Project(object): services = self.get_services(service_names) def get_deps(service): - return {self.get_service(dep) for dep in service.get_dependency_names()} + return { + (self.get_service(dep), config) + for dep, config in service.get_dependency_configs().items() + } parallel.parallel_execute( services, @@ -243,7 +246,7 @@ class Project(object): def get_deps(container): # actually returning inversed dependencies - return {other for other in containers + return {(other, None) for other in containers if container.service in self.get_service(other.service).get_dependency_names()} @@ -394,7 +397,10 @@ class Project(object): ) def get_deps(service): - return {self.get_service(dep) for dep in service.get_dependency_names()} + return { + (self.get_service(dep), config) + for dep, config in service.get_dependency_configs().items() + } results, errors = parallel.parallel_execute( services, diff --git a/compose/service.py b/compose/service.py index e3862b6e..1338f154 100644 --- a/compose/service.py +++ b/compose/service.py @@ -28,6 +28,8 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_VERSION from .container import Container +from .errors import HealthCheckFailed +from .errors import NoHealthCheckConfigured from .errors import OperationFailedError from .parallel import parallel_execute from .parallel import parallel_start @@ -69,6 +71,9 @@ DOCKER_START_KEYS = [ 'volumes_from', ] +CONDITION_STARTED = 'service_started' +CONDITION_HEALTHY = 'service_healthy' + class BuildError(Exception): def __init__(self, service, reason): @@ -533,10 +538,38 @@ class Service(object): def get_dependency_names(self): net_name = self.network_mode.service_name - return (self.get_linked_service_names() + - self.get_volumes_from_names() + - ([net_name] if net_name else []) + - self.options.get('depends_on', [])) + return ( + self.get_linked_service_names() + + self.get_volumes_from_names() + + ([net_name] if net_name else []) + + list(self.options.get('depends_on', {}).keys()) + ) + + def get_dependency_configs(self): + net_name = self.network_mode.service_name + configs = dict( + [(name, None) for name in self.get_linked_service_names()] + ) + configs.update(dict( + [(name, None) for name in self.get_volumes_from_names()] + )) + configs.update({net_name: None} if net_name else {}) + configs.update(self.options.get('depends_on', {})) + for svc, config in self.options.get('depends_on', {}).items(): + if config['condition'] == CONDITION_STARTED: + configs[svc] = lambda s: True + elif config['condition'] == CONDITION_HEALTHY: + configs[svc] = lambda s: s.is_healthy() + else: + # The config schema already prevents this, but it might be + # bypassed if Compose is called programmatically. + raise ValueError( + 'depends_on condition "{}" is invalid.'.format( + config['condition'] + ) + ) + + return configs def get_linked_service_names(self): return [service.name for (service, _) in self.links] @@ -871,6 +904,24 @@ class Service(object): else: log.error(six.text_type(e)) + def is_healthy(self): + """ Check that all containers for this service report healthy. + Returns false if at least one healthcheck is pending. + If an unhealthy container is detected, raise a HealthCheckFailed + exception. + """ + result = True + for ctnr in self.containers(): + ctnr.inspect() + status = ctnr.get('State.Health.Status') + if status is None: + raise NoHealthCheckConfigured(self.name) + elif status == 'starting': + result = False + elif status == 'unhealthy': + raise HealthCheckFailed(ctnr.short_id) + return result + def short_id_alias_exists(container, network): aliases = container.get( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b9766226..77d57840 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -928,7 +928,7 @@ class CLITestCase(DockerClientTestCase): assert foo_container.get('HostConfig.NetworkMode') == \ 'container:{}'.format(bar_container.id) - @v3_only() + @v2_1_only() def test_up_with_healthcheck(self): def wait_on_health_status(container, status): def condition(): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index de073239..855974de 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,8 @@ from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container +from compose.errors import HealthCheckFailed +from compose.errors import NoHealthCheckConfigured from compose.project import Project from compose.project import ProjectError from compose.service import ConvergenceStrategy @@ -1375,3 +1377,115 @@ class ProjectTest(DockerClientTestCase): ctnr for ctnr in project._labeled_containers() if ctnr.labels.get(LABEL_SERVICE) == 'service1' ]) == 0 + + @v2_1_only() + def test_project_up_healthy_dependency(self): + config_dict = { + 'version': '2.1', + 'services': { + 'svc1': { + 'image': 'busybox:latest', + 'command': 'top', + 'healthcheck': { + 'test': 'exit 0', + 'retries': 1, + 'timeout': '10s', + 'interval': '0.1s' + }, + }, + 'svc2': { + 'image': 'busybox:latest', + 'command': 'top', + 'depends_on': { + 'svc1': {'condition': 'service_healthy'}, + } + } + } + } + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + project.up() + containers = project.containers() + assert len(containers) == 2 + + svc1 = project.get_service('svc1') + svc2 = project.get_service('svc2') + assert 'svc1' in svc2.get_dependency_names() + assert svc1.is_healthy() + + @v2_1_only() + def test_project_up_unhealthy_dependency(self): + config_dict = { + 'version': '2.1', + 'services': { + 'svc1': { + 'image': 'busybox:latest', + 'command': 'top', + 'healthcheck': { + 'test': 'exit 1', + 'retries': 1, + 'timeout': '10s', + 'interval': '0.1s' + }, + }, + 'svc2': { + 'image': 'busybox:latest', + 'command': 'top', + 'depends_on': { + 'svc1': {'condition': 'service_healthy'}, + } + } + } + } + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + with pytest.raises(HealthCheckFailed): + project.up() + containers = project.containers() + assert len(containers) == 1 + + svc1 = project.get_service('svc1') + svc2 = project.get_service('svc2') + assert 'svc1' in svc2.get_dependency_names() + with pytest.raises(HealthCheckFailed): + svc1.is_healthy() + + @v2_1_only() + def test_project_up_no_healthcheck_dependency(self): + config_dict = { + 'version': '2.1', + 'services': { + 'svc1': { + 'image': 'busybox:latest', + 'command': 'top', + 'healthcheck': { + 'disabled': True + }, + }, + 'svc2': { + 'image': 'busybox:latest', + 'command': 'top', + 'depends_on': { + 'svc1': {'condition': 'service_healthy'}, + } + } + } + } + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + with pytest.raises(NoHealthCheckConfigured): + project.up() + containers = project.containers() + assert len(containers) == 1 + + svc1 = project.get_service('svc1') + svc2 = project.get_service('svc2') + assert 'svc1' in svc2.get_dependency_names() + with pytest.raises(NoHealthCheckConfigured): + svc1.is_healthy() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f7df3aee..7a68333f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -920,7 +920,10 @@ class ConfigTest(unittest.TestCase): 'build': {'context': os.path.abspath('/')}, 'image': 'example/web', 'volumes': [VolumeSpec.parse('/home/user/project:/code')], - 'depends_on': ['db', 'other'], + 'depends_on': { + 'db': {'condition': 'container_start'}, + 'other': {'condition': 'container_start'}, + }, }, { 'name': 'db', @@ -3055,7 +3058,9 @@ class ExtendsTest(unittest.TestCase): image: example """) services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) - assert service_sort(services)[2]['depends_on'] == ['other'] + assert service_sort(services)[2]['depends_on'] == { + 'other': {'condition': 'container_start'} + } @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 479c0f1d..2a50b718 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -25,7 +25,7 @@ deps = { def get_deps(obj): - return deps[obj] + return [(dep, None) for dep in deps[obj]] def test_parallel_execute(): From bef230853012d78e7ab58de65b653839401be974 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Dec 2016 20:39:16 -0800 Subject: [PATCH 531/580] Fix condition name in config tests Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 2 +- tests/unit/config/config_test.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 77d57840..b9766226 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -928,7 +928,7 @@ class CLITestCase(DockerClientTestCase): assert foo_container.get('HostConfig.NetworkMode') == \ 'container:{}'.format(bar_container.id) - @v2_1_only() + @v3_only() def test_up_with_healthcheck(self): def wait_on_health_status(container, status): def condition(): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7a68333f..31a888ed 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -921,8 +921,8 @@ class ConfigTest(unittest.TestCase): 'image': 'example/web', 'volumes': [VolumeSpec.parse('/home/user/project:/code')], 'depends_on': { - 'db': {'condition': 'container_start'}, - 'other': {'condition': 'container_start'}, + 'db': {'condition': 'service_started'}, + 'other': {'condition': 'service_started'}, }, }, { @@ -3059,7 +3059,7 @@ class ExtendsTest(unittest.TestCase): """) services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) assert service_sort(services)[2]['depends_on'] == { - 'other': {'condition': 'container_start'} + 'other': {'condition': 'service_started'} } From 8145429399346a8d800369aad17f5fe69237c2ad Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 13:14:23 -0800 Subject: [PATCH 532/580] Unify healthcheck spec definition in v2 and v3 Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- compose/config/config_schema_v2.1.json | 2 +- compose/config/config_schema_v3.0.json | 12 ++++++------ tests/integration/project_test.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 73a34017..fd935591 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -675,7 +675,7 @@ def process_healthcheck(service_dict, service_name): hc = {} raw = service_dict['healthcheck'] - if raw.get('disable') or raw.get('disabled'): + if raw.get('disable'): if len(raw) > 1: raise ConfigurationError( 'Service "{}" defines an invalid healthcheck: ' diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5ab9f71f..d0d5233a 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -258,7 +258,7 @@ "type": "object", "additionalProperties": false, "properties": { - "disabled": {"type": "boolean"}, + "disable": {"type": "boolean"}, "interval": {"type": "string"}, "retries": {"type": "number"}, "test": { diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 1f93347f..8d075d47 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -202,10 +202,11 @@ "healthcheck": { "id": "#/definitions/healthcheck", - "type": ["object", "null"], + "type": "object", + "additionalProperties": false, "properties": { - "interval": {"type":"string"}, - "timeout": {"type":"string"}, + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -213,9 +214,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "disable": {"type": "boolean"} - }, - "additionalProperties": false + "timeout": {"type": "string"} + } }, "deployment": { "id": "#/definitions/deployment", diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 855974de..c5e3cf50 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1463,7 +1463,7 @@ class ProjectTest(DockerClientTestCase): 'image': 'busybox:latest', 'command': 'top', 'healthcheck': { - 'disabled': True + 'disable': True }, }, 'svc2': { From 1be41f59c9119c72b3c39045e4e4031608fa18df Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 14:30:20 -0800 Subject: [PATCH 533/580] Add support for stop_grace_period in v2 Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 1 + compose/config/config_schema_v2.1.json | 1 + compose/config/config_schema_v3.0.json | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 76688916..77494715 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -192,6 +192,7 @@ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index d0d5233a..97ec5fa1 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -217,6 +217,7 @@ "shm_size": {"type": ["number", "string"]}, "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"}, diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 8d075d47..2b410446 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -169,8 +169,8 @@ "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, "stdin_open": {"type": "boolean"}, - "stop_signal": {"type": "string"}, "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { From 534b4ed820ec2b2e2f7b296e0065c42ebf3489cd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 15:26:11 -0800 Subject: [PATCH 534/580] Falsy values in COMPOSE_CONVERT_WINDOWS_PATHS are properly recognized Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- compose/config/environment.py | 11 ++++++++ tests/unit/config/environment_test.py | 40 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/unit/config/environment_test.py diff --git a/compose/config/config.py b/compose/config/config.py index fd935591..c11460fa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -712,7 +712,7 @@ def finalize_service(service_config, service_names, version, environment): if 'volumes' in service_dict: service_dict['volumes'] = [ VolumeSpec.parse( - v, environment.get('COMPOSE_CONVERT_WINDOWS_PATHS') + v, environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') ) for v in service_dict['volumes'] ] diff --git a/compose/config/environment.py b/compose/config/environment.py index 5d6b5af6..7b926930 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -105,3 +105,14 @@ class Environment(dict): super(Environment, self).get(key.upper(), *args, **kwargs) ) return super(Environment, self).get(key, *args, **kwargs) + + def get_boolean(self, key): + # Convert a value to a boolean using "common sense" rules. + # Unset, empty, "0" and "false" (i-case) yield False. + # All other values yield True. + value = self.get(key) + if not value: + return False + if value.lower() in ['0', 'false']: + return False + return True diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py new file mode 100644 index 00000000..20446d2b --- /dev/null +++ b/tests/unit/config/environment_test.py @@ -0,0 +1,40 @@ +# encoding: utf-8 +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +from compose.config.environment import Environment +from tests import unittest + + +class EnvironmentTest(unittest.TestCase): + def test_get_simple(self): + env = Environment({ + 'FOO': 'bar', + 'BAR': '1', + 'BAZ': '' + }) + + assert env.get('FOO') == 'bar' + assert env.get('BAR') == '1' + assert env.get('BAZ') == '' + + def test_get_undefined(self): + env = Environment({ + 'FOO': 'bar' + }) + assert env.get('FOOBAR') is None + + def test_get_boolean(self): + env = Environment({ + 'FOO': '', + 'BAR': '0', + 'BAZ': 'FALSE', + 'FOOBAR': 'true', + }) + + assert env.get_boolean('FOO') is False + assert env.get_boolean('BAR') is False + assert env.get_boolean('BAZ') is False + assert env.get_boolean('FOOBAR') is True + assert env.get_boolean('UNDEFINED') is False From e063c5739fedeb56450075920451e3fd8b57a826 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 5 Jan 2017 11:15:24 -0800 Subject: [PATCH 535/580] Fix config schemas (misplaced "additionalProperties") Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 6 +++--- compose/config/config_schema_v2.1.json | 6 +++--- compose/config/config_schema_v3.0.json | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 77494715..59c7b30c 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -276,9 +276,9 @@ "type": ["boolean", "object"], "properties": { "name": {"type": "string"} - } - }, - "additionalProperties": false + }, + "additionalProperties": false + } }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 97ec5fa1..d1ffff89 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -322,10 +322,10 @@ "type": ["boolean", "object"], "properties": { "name": {"type": "string"} - } + }, + "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "additionalProperties": false + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 2b410446..194fd8e6 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -328,7 +328,8 @@ "type": ["boolean", "object"], "properties": { "name": {"type": "string"} - } + }, + "additionalProperties": false } }, "labels": {"$ref": "#/definitions/list_or_dict"}, From 2c157e8fa9a94d71637643a6ec807db8b21a9d29 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 6 Jan 2017 17:45:57 -0800 Subject: [PATCH 536/580] Use docker SDK 2.0.1 Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index bae5d9ea..4b7c7b76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 colorama==0.3.7 -docker==2.0.0 +docker==2.0.1 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' diff --git a/setup.py b/setup.py index 8b4cf709..7954d92b 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ install_requires = [ 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.0.0, < 3.0', + 'docker >= 2.0.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 52792b7a963af9c593e61c78c7f0c7f62550a85b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jan 2017 14:57:32 -0800 Subject: [PATCH 537/580] Update setup.py extra_requires Signed-off-by: Joffrey F --- setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 0ceb2a22..2f2ba742 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,9 @@ if sys.version_info[:2] < (3, 4): tests_require.append('mock >= 1.0.1') extras_require = { - ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'] + ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], + ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5'], + ':python_version < "3.3"': ['ipaddress >= 1.0.16'], } @@ -64,8 +66,8 @@ try: install_requires.extend(value) except Exception: logging.getLogger(__name__).exception( - 'Something went wrong calculating platform specific dependencies, so ' - "you're getting them all!" + 'Failed to compute platform dependencies. All dependencies will be ' + 'installed as a result.' ) for key, value in extras_require.items(): if key.startswith(':'): From 19190ea0df43978d1a9c9f0fefd644ca5b08aee3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jan 2017 16:43:26 -0800 Subject: [PATCH 538/580] Fix docker image build script when using universal wheels Signed-off-by: Joffrey F --- Dockerfile.run | 5 +++-- script/build/image | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index c6852af1..de46e35e 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,5 +1,6 @@ FROM alpine:3.4 +ARG version RUN apk -U add \ python \ py-pip @@ -7,7 +8,7 @@ RUN apk -U add \ COPY requirements.txt /code/requirements.txt RUN pip install -r /code/requirements.txt -ADD dist/docker-compose-release-py2.py3-none-any.whl /code/docker-compose -RUN pip install --no-deps /code/docker-compose/docker-compose-* +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 ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/script/build/image b/script/build/image index 28aa2047..3590ce14 100755 --- a/script/build/image +++ b/script/build/image @@ -12,5 +12,4 @@ VERSION="$(python setup.py --version)" ./script/build/write-git-sha python setup.py sdist bdist_wheel -cp dist/docker-compose-$VERSION-py2.py3-none-any.whl dist/docker-compose-release-py2.py3-none-any.whl -docker build -t docker/compose:$TAG -f Dockerfile.run . +docker build --build-arg version=$VERSION -t docker/compose:$TAG -f Dockerfile.run . From 29b46d5b26055ade86a2e0e608342dd298e5a8c3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Jan 2017 15:39:48 -0800 Subject: [PATCH 539/580] Use correct wheel file name in twine upload command Signed-off-by: Joffrey F --- script/release/push-release | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/release/push-release b/script/release/push-release index d1a9e3f6..9db6f689 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -60,12 +60,13 @@ sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/maste ./script/build/write-git-sha python setup.py sdist bdist_wheel if [ "$(command -v twine 2> /dev/null)" ]; then - twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz ./dist/docker-compose-${VERSION/-/}-py2.py3-none-any.whl + twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz ./dist/docker_compose-${VERSION/-/}-py2.py3-none-any.whl else python setup.py upload fi echo "Testing pip package" +deactivate || true virtualenv venv-test source venv-test/bin/activate pip install docker-compose==$VERSION From 2df31bb13c9a6820aba1d9b5a827329eded2b9cd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Jan 2017 16:25:40 -0800 Subject: [PATCH 540/580] Provide valid serialization of depends_on when format is not 2.1 Signed-off-by: Joffrey F --- compose/config/serialize.py | 9 ++++++++- tests/unit/config/config_test.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 768f3d47..05ac0d60 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -56,9 +56,16 @@ def denormalize_service_dict(service_dict, version): service_dict = service_dict.copy() if 'restart' in service_dict: - service_dict['restart'] = types.serialize_restart_spec(service_dict['restart']) + service_dict['restart'] = types.serialize_restart_spec( + service_dict['restart'] + ) if version == V1 and 'network_mode' not in service_dict: service_dict['network_mode'] = 'bridge' + if 'depends_on' in service_dict and version != V2_1: + service_dict['depends_on'] = sorted([ + svc for svc in service_dict['depends_on'].keys() + ]) + return service_dict diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 31a888ed..ca7c6168 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -22,6 +22,7 @@ from compose.config.config import V3_0 from compose.config.environment import Environment from compose.config.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION +from compose.config.serialize import denormalize_service_dict from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds @@ -3269,3 +3270,33 @@ def get_config_filename_for_files(filenames, subdir=None): return os.path.basename(filename) finally: shutil.rmtree(project_dir) + + +class SerializeTest(unittest.TestCase): + def test_denormalize_depends_on_v3(self): + service_dict = { + 'image': 'busybox', + 'command': 'true', + 'depends_on': { + 'service2': {'condition': 'service_started'}, + 'service3': {'condition': 'service_started'}, + } + } + + assert denormalize_service_dict(service_dict, V3_0) == { + 'image': 'busybox', + 'command': 'true', + 'depends_on': ['service2', 'service3'] + } + + def test_denormalize_depends_on_v2_1(self): + service_dict = { + 'image': 'busybox', + 'command': 'true', + 'depends_on': { + 'service2': {'condition': 'service_started'}, + 'service3': {'condition': 'service_started'}, + } + } + + assert denormalize_service_dict(service_dict, V2_1) == service_dict From 931027c59828f242391de41cbeadfd6d1664588a Mon Sep 17 00:00:00 2001 From: muicoder Date: Mon, 16 Jan 2017 10:43:29 +0800 Subject: [PATCH 541/580] add IMAGE_EVENTS: load/save Signed-off-by: muicoder --- compose/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/const.py b/compose/const.py index 1b1be5c7..354c6d76 100644 --- a/compose/const.py +++ b/compose/const.py @@ -5,7 +5,7 @@ import sys DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = 60 -IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', 'tag', 'untag'] +IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag'] IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' From 56a1b02aac33d09ec7761729a8d6ddcb0fbdea0e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Jan 2017 13:22:16 -0800 Subject: [PATCH 542/580] Catch healthcheck exceptions in parallel_execute Signed-off-by: Joffrey F --- compose/parallel.py | 40 ++++++++++++++++++------------- tests/integration/project_test.py | 4 ++-- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index b2654dcf..e495410c 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -12,6 +12,8 @@ from six.moves.queue import Empty from six.moves.queue import Queue from compose.cli.signals import ShutdownException +from compose.errors import HealthCheckFailed +from compose.errors import NoHealthCheckConfigured from compose.errors import OperationFailedError from compose.utils import get_output_stream @@ -48,7 +50,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation writer.write(get_name(obj), 'error') - elif isinstance(exception, OperationFailedError): + elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): errors[get_name(obj)] = exception.msg writer.write(get_name(obj), 'error') elif isinstance(exception, UpstreamError): @@ -164,21 +166,27 @@ def feed_queue(objects, func, get_deps, results, state): for obj in pending: deps = get_deps(obj) - - if any(dep[0] in state.failed for dep in deps): - log.debug('{} has upstream errors - not processing'.format(obj)) - results.put((obj, None, UpstreamError())) - state.failed.add(obj) - elif all( - dep not in objects or ( - dep in state.finished and (not ready_check or ready_check(dep)) - ) for dep, ready_check in deps - ): - log.debug('Starting producer thread for {}'.format(obj)) - t = Thread(target=producer, args=(obj, func, results)) - t.daemon = True - t.start() - state.started.add(obj) + try: + if any(dep[0] in state.failed for dep in deps): + log.debug('{} has upstream errors - not processing'.format(obj)) + results.put((obj, None, UpstreamError())) + state.failed.add(obj) + elif all( + dep not in objects or ( + dep in state.finished and (not ready_check or ready_check(dep)) + ) for dep, ready_check in deps + ): + log.debug('Starting producer thread for {}'.format(obj)) + t = Thread(target=producer, args=(obj, func, results)) + t.daemon = True + t.start() + state.started.add(obj) + except (HealthCheckFailed, NoHealthCheckConfigured) as e: + log.debug( + 'Healthcheck for service(s) upstream of {} failed - ' + 'not processing'.format(obj) + ) + results.put((obj, None, e)) if state.is_done(): results.put(STOP) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index c5e3cf50..ee2b7817 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1443,7 +1443,7 @@ class ProjectTest(DockerClientTestCase): project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) - with pytest.raises(HealthCheckFailed): + with pytest.raises(ProjectError): project.up() containers = project.containers() assert len(containers) == 1 @@ -1479,7 +1479,7 @@ class ProjectTest(DockerClientTestCase): project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) - with pytest.raises(NoHealthCheckConfigured): + with pytest.raises(ProjectError): project.up() containers = project.containers() assert len(containers) == 1 From 1a02121ab55876f92bcceb62d1e81b7f114f0c79 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Jan 2017 17:52:03 -0800 Subject: [PATCH 543/580] depends_on merge now retains condition information when present Signed-off-by: Joffrey F --- compose/config/config.py | 6 +++++- tests/unit/config/config_test.py | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index c11460fa..7e77421e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -818,6 +818,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('ulimits', parse_ulimits) md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) + md.merge_mapping('depends_on', parse_depends_on) md.merge_sequence('links', ServiceLink.parse) for field in ['volumes', 'devices']: @@ -825,7 +826,7 @@ def merge_service_dicts(base, override, version): for field in [ 'ports', 'cap_add', 'cap_drop', 'expose', 'external_links', - 'security_opt', 'volumes_from', 'depends_on', + 'security_opt', 'volumes_from', ]: md.merge_field(field, merge_unique_items_lists, default=[]) @@ -920,6 +921,9 @@ parse_environment = functools.partial(parse_dict_or_list, split_env, 'environmen parse_labels = functools.partial(parse_dict_or_list, split_kv, 'labels') parse_networks = functools.partial(parse_dict_or_list, lambda k: (k, None), 'networks') parse_sysctls = functools.partial(parse_dict_or_list, split_kv, 'sysctls') +parse_depends_on = functools.partial( + parse_dict_or_list, lambda k: (k, {'condition': 'service_started'}), 'depends_on' +) def parse_ulimits(ulimits): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ca7c6168..ab8bfcfc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1713,6 +1713,40 @@ class ConfigTest(unittest.TestCase): } } + def test_merge_depends_on_no_override(self): + base = { + 'image': 'busybox', + 'depends_on': { + 'app1': {'condition': 'service_started'}, + 'app2': {'condition': 'service_healthy'} + } + } + override = {} + actual = config.merge_service_dicts(base, override, V2_1) + assert actual == base + + def test_merge_depends_on_mixed_syntax(self): + base = { + 'image': 'busybox', + 'depends_on': { + 'app1': {'condition': 'service_started'}, + 'app2': {'condition': 'service_healthy'} + } + } + override = { + 'depends_on': ['app3'] + } + + actual = config.merge_service_dicts(base, override, V2_1) + assert actual == { + 'image': 'busybox', + 'depends_on': { + 'app1': {'condition': 'service_started'}, + 'app2': {'condition': 'service_healthy'}, + 'app3': {'condition': 'service_started'} + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 169289c8b66dcab760cdc7e1534fc0bece326d44 Mon Sep 17 00:00:00 2001 From: "Aaron.L.Xu" Date: Fri, 20 Jan 2017 00:52:13 +0800 Subject: [PATCH 544/580] find a fishbone Signed-off-by: Aaron.L.Xu --- script/test/versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/test/versions.py b/script/test/versions.py index 45ead143..0c3b8162 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -5,7 +5,7 @@ version tags for recent releases, or the default release. The default release is the most recent non-RC version. -Recent is a list of unqiue major.minor versions, where each is the most +Recent is a list of unique major.minor versions, where each is the most recent version in the series. For example, if the list of versions is: From 1c46525c2baf8532434c320bf0443a520381431d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Jan 2017 14:47:31 -0800 Subject: [PATCH 545/580] 1.11.0dev Signed-off-by: Joffrey F --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c645ca2..6699f880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,54 @@ Change log ========== +1.10.0 (2017-01-18) +------------------- + +### New Features + +#### Compose file version 3.0 + +- Introduced version 3.0 of the `docker-compose.yml` specification. This + version requires to be used with Docker Engine 1.13 or above and is + specifically designed to work with the `docker stack` commands. + +#### Compose file version 2.1 and up + +- Healthcheck configuration can now be done in the service definition using + the `healthcheck` parameter + +- Containers dependencies can now be set up to wait on positive healthchecks + when declared using `depends_on`. See the documentation for the updated + syntax. + **Note:** This feature will not be ported to version 3 Compose files. + +- Added support for the `sysctls` parameter in service definitions + +- Added support for the `userns_mode` parameter in service definitions + +- Compose now adds identifying labels to networks and volumes it creates + +#### Compose file version 2.0 and up + +- Added support for the `stop_grace_period` option in service definitions. + +### Bugfixes + +- Colored output now works properly on Windows. + +- Fixed a bug where docker-compose run would fail to set up link aliases + in interactive mode on Windows. + +- Networks created by Compose are now always made attachable + (Compose files v2.1 and up). + +- Fixed a bug where falsy values of `COMPOSE_CONVERT_WINDOWS_PATHS` + (`0`, `false`, empty value) were being interpreted as true. + +- Fixed a bug where forward slashes in some .dockerignore patterns weren't + being parsed correctly on Windows + + 1.9.0 (2016-11-16) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index 6f05b282..38417836 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.10.0dev' +__version__ = '1.11.0dev' From 5c2165eaafbb625eb2058b199b571a228e86df03 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Jan 2017 15:41:31 -0800 Subject: [PATCH 546/580] Fix volume definition in v3 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v3.0.json | 4 ++-- tests/acceptance/cli_test.py | 8 +++++++- tests/fixtures/v3-full/docker-compose.yml | 4 ++++ tests/integration/testcases.py | 7 +++++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index ae4c0530..584b6ef5 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -330,9 +330,9 @@ "name": {"type": "string"} }, "additionalProperties": false - } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} }, - "labels": {"$ref": "#/definitions/list_or_dict"}, "additionalProperties": false }, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b9766226..ce31dd18 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -295,7 +295,13 @@ class CLITestCase(DockerClientTestCase): assert yaml.load(result.stdout) == { 'version': '3.0', 'networks': {}, - 'volumes': {}, + 'volumes': { + 'foobar': { + 'labels': { + 'com.docker.compose.test': 'true', + }, + }, + }, 'services': { 'web': { 'image': 'busybox', diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index b4d1b642..a1661ab9 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -35,3 +35,7 @@ services: retries: 5 stop_grace_period: 20s +volumes: + foobar: + labels: + com.docker.compose.test: 'true' diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index f6bc402b..230bd2d9 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -13,6 +13,7 @@ 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.const import API_VERSIONS from compose.const import LABEL_PROJECT @@ -36,13 +37,15 @@ def get_links(container): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V2_1 + return V3_0 version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 elif version_lt(version, '1.12'): return V2_0 - return V2_1 + elif version_lt(version, '1.13'): + return V2_1 + return V3_0 def build_version_required_decorator(ignored_versions): From d83d31889ea937524db798aa8260638036503764 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Jan 2017 16:05:13 -0800 Subject: [PATCH 547/580] Remove external_name from volume def in config output Signed-off-by: Joffrey F --- compose/config/serialize.py | 7 ++++++- tests/acceptance/cli_test.py | 14 ++++++++++++++ tests/fixtures/volumes/docker-compose.yml | 2 ++ tests/fixtures/volumes/external-volumes.yml | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/volumes/docker-compose.yml create mode 100644 tests/fixtures/volumes/external-volumes.yml diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 05ac0d60..9ea287a4 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -32,6 +32,11 @@ def denormalize_config(config): if 'external_name' in net_conf: del net_conf['external_name'] + volumes = config.volumes.copy() + for vol_name, vol_conf in volumes.items(): + if 'external_name' in vol_conf: + del vol_conf['external_name'] + version = config.version if version == V1: version = V2_1 @@ -40,7 +45,7 @@ def denormalize_config(config): 'version': version, 'services': services, 'networks': networks, - 'volumes': config.volumes, + 'volumes': volumes, } diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b9766226..287c043c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -262,6 +262,20 @@ class CLITestCase(DockerClientTestCase): } } + def test_config_external_volume(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True + }, + 'bar': { + 'external': {'name': 'some_bar'} + } + } + def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) diff --git a/tests/fixtures/volumes/docker-compose.yml b/tests/fixtures/volumes/docker-compose.yml new file mode 100644 index 00000000..da711ac4 --- /dev/null +++ b/tests/fixtures/volumes/docker-compose.yml @@ -0,0 +1,2 @@ +version: '2.1' +services: {} diff --git a/tests/fixtures/volumes/external-volumes.yml b/tests/fixtures/volumes/external-volumes.yml new file mode 100644 index 00000000..05c6c484 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes.yml @@ -0,0 +1,16 @@ +version: "2.1" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + bar: + external: + name: some_bar From 644e1716c33e0b3a3dcb4e5227b7dac0a289cffd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 20 Jan 2017 12:55:59 -0500 Subject: [PATCH 548/580] Add missing network.internal to v3 schema. Signed-off-by: Daniel Nephin --- compose/config/config_schema_v3.0.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 584b6ef5..fbcd8bb8 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -308,6 +308,7 @@ }, "additionalProperties": false }, + "internal": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false From 20d6f450b5e37e5c634fdf517df32d7484a2b3ff Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Jan 2017 15:05:53 -0800 Subject: [PATCH 549/580] Don't encode build context path on Windows Signed-off-by: Joffrey F --- compose/service.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 20a40c68..724e0565 100644 --- a/compose/service.py +++ b/compose/service.py @@ -22,6 +22,7 @@ from .config import DOCKER_CONFIG_KEYS from .config import merge_environment from .config.types import VolumeSpec from .const import DEFAULT_TIMEOUT +from .const import IS_WINDOWS_PLATFORM from .const import LABEL_CONFIG_HASH from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_ONE_OFF @@ -769,9 +770,9 @@ class Service(object): build_opts = self.options.get('build', {}) path = build_opts.get('context') - # python2 os.path() doesn't support unicode, so we need to encode it to - # a byte string - if not six.PY3: + # python2 os.stat() doesn't support unicode on some UNIX, so we + # encode it to a bytestring to be safe + if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') build_output = self.client.build( From e10d1140b95d33a589b1a971e0edda703bfb1e9b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Jan 2017 18:00:09 -0800 Subject: [PATCH 550/580] Convert time data back to string values when serializing config Signed-off-by: Joffrey F --- compose/config/serialize.py | 29 +++++++++++++++++++++++++ tests/acceptance/cli_test.py | 4 ++-- tests/unit/config/config_test.py | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 9ea287a4..3745de82 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -57,6 +57,25 @@ def serialize_config(config): width=80) +def serialize_ns_time_value(value): + result = (value, 'ns') + table = [ + (1000., 'us'), + (1000., 'ms'), + (1000., 's'), + (60., 'm'), + (60., 'h') + ] + for stage in table: + tmp = value / stage[0] + if tmp == int(value / stage[0]): + value = tmp + result = (int(value), stage[1]) + else: + break + return '{0}{1}'.format(*result) + + def denormalize_service_dict(service_dict, version): service_dict = service_dict.copy() @@ -73,4 +92,14 @@ def denormalize_service_dict(service_dict, version): svc for svc in service_dict['depends_on'].keys() ]) + if 'healthcheck' in service_dict: + if 'interval' in service_dict['healthcheck']: + service_dict['healthcheck']['interval'] = serialize_ns_time_value( + service_dict['healthcheck']['interval'] + ) + if 'timeout' in service_dict['healthcheck']: + service_dict['healthcheck']['timeout'] = serialize_ns_time_value( + service_dict['healthcheck']['timeout'] + ) + return service_dict diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 41a06c95..58160c80 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -353,8 +353,8 @@ class CLITestCase(DockerClientTestCase): 'healthcheck': { 'test': 'cat /etc/passwd', - 'interval': 10000000000, - 'timeout': 1000000000, + 'interval': '10s', + 'timeout': '1s', 'retries': 5, }, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ab8bfcfc..d7947a4e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -23,6 +23,7 @@ from compose.config.environment import Environment from compose.config.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION from compose.config.serialize import denormalize_service_dict +from compose.config.serialize import serialize_ns_time_value from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds @@ -3334,3 +3335,38 @@ class SerializeTest(unittest.TestCase): } assert denormalize_service_dict(service_dict, V2_1) == service_dict + + def test_serialize_time(self): + data = { + 9: '9ns', + 9000: '9us', + 9000000: '9ms', + 90000000: '90ms', + 900000000: '900ms', + 999999999: '999999999ns', + 1000000000: '1s', + 60000000000: '1m', + 60000000001: '60000000001ns', + 9000000000000: '150m', + 90000000000000: '25h', + } + + for k, v in data.items(): + assert serialize_ns_time_value(k) == v + + def test_denormalize_healthcheck(self): + service_dict = { + 'image': 'test', + 'healthcheck': { + 'test': 'exit 1', + 'interval': '1m40s', + 'timeout': '30s', + 'retries': 5 + } + } + processed_service = config.process_service(config.ServiceConfig( + '.', 'test', 'test', service_dict + )) + denormalized_service = denormalize_service_dict(processed_service, V2_1) + assert denormalized_service['healthcheck']['interval'] == '100s' + assert denormalized_service['healthcheck']['timeout'] == '30s' From 5895d8bbc9939524b449e296ac93d8e98aa70eb0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Jan 2017 15:02:27 -0800 Subject: [PATCH 551/580] Detect conflicting version of the docker python SDK and prevent execution until issue is fixed Signed-off-by: Joffrey F --- compose/cli/main.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index c25ccbfa..db068272 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,6 +14,30 @@ from distutils.spawn import find_executable from inspect import getdoc from operator import attrgetter + +# Attempt to detect https://github.com/docker/compose/issues/4344 +try: + # A regular import statement causes PyInstaller to freak out while + # trying to load pip. This way it is simply ignored. + pip = __import__('pip') + pip_packages = pip.get_installed_distributions() + if 'docker-py' in [pkg.project_name for pkg in pip_packages]: + from .colors import red + print( + red('ERROR:'), + "Dependency conflict: an older version of the 'docker-py' package " + "is polluting the namespace. " + "Run the following command to remedy the issue:\n" + "pip uninstall docker docker-py; pip install docker", + file=sys.stderr + ) + sys.exit(1) +except ImportError: + # pip is not available, which indicates it's probably the binary + # distribution of Compose which is not affected + pass + + from . import errors from . import signals from .. import __version__ From 22249add84831a02976f6c98020f05eb7418b287 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 6 Jun 2016 15:48:47 -0700 Subject: [PATCH 552/580] Use newer version of PyInstaller to fix prelinking issues Signed-off-by: Joffrey F --- requirements-build.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-build.txt b/requirements-build.txt index 3f1dbd75..27f610ca 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.1.1 +pyinstaller==3.2.1 From 2593366a3ef1fb0673049687f0ca6733a28cf03f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 16:26:35 -0800 Subject: [PATCH 553/580] Bump docker SDK version Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4b7c7b76..3b06bff4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ PyYAML==3.11 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.2.0 colorama==0.3.7 -docker==2.0.1 +docker==2.0.2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' diff --git a/setup.py b/setup.py index 2f2ba742..0b1d4e08 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ install_requires = [ 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.0.1, < 3.0', + 'docker >= 2.0.2, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From a82de8863ebdc586a45f54aef348cd17340089e3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Dec 2016 15:44:33 -0500 Subject: [PATCH 554/580] Add v3.1 with secrets. Signed-off-by: Daniel Nephin --- compose/config/config_schema_v3.1.json | 426 +++++++++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 compose/config/config_schema_v3.1.json diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json new file mode 100644 index 00000000..16616498 --- /dev/null +++ b/compose/config/config_schema_v3.1.json @@ -0,0 +1,426 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.1.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/secrets" + } + }, + "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"} + }, + "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": { + "type": ["string", "number"], + "format": "ports" + }, + "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": "number"}, + "gid": {"type": "number"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_signal": {"type": "string"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "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": {"type": "string"}, "uniqueItems": true}, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": ["object", "null"], + "properties": { + "interval": {"type":"string"}, + "timeout": {"type":"string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "disable": {"type": "boolean"} + }, + "additionalProperties": false + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "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 + }, + "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"} + } + } + }, + "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"] + } + } + } + } + } +} From add56ce8182328fefd7fbe4500360a929ea511df Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Jan 2017 15:58:14 -0500 Subject: [PATCH 555/580] Read service secrets as a type. Signed-off-by: Daniel Nephin --- compose/config/config.py | 13 +++++++++++-- compose/config/config_schema_v3.1.json | 6 +++--- compose/config/types.py | 23 +++++++++++++++++++++-- compose/const.py | 3 +++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7e77421e..3ca994a7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -12,10 +12,12 @@ import six import yaml from cached_property import cached_property +from . import types 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 parse_nanoseconds_int from ..utils import splitdrive @@ -82,6 +84,7 @@ DOCKER_CONFIG_KEYS = [ 'privileged', 'read_only', 'restart', + 'secrets', 'security_opt', 'shm_size', 'stdin_open', @@ -202,8 +205,11 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): def get_networks(self): return {} if self.version == V1 else self.config.get('networks', {}) + def get_secrets(self): + return {} if self.version < V3_1 else self.config.get('secrets', {}) -class Config(namedtuple('_Config', 'version services volumes networks')): + +class Config(namedtuple('_Config', 'version services volumes networks secrets')): """ :param version: configuration version :type version: int @@ -328,6 +334,8 @@ def load(config_details): networks = load_mapping( config_details.config_files, 'get_networks', 'Network' ) + secrets = load_mapping( + config_details.config_files, 'get_secrets', 'Secrets') service_dicts = load_services(config_details, main_file) if main_file.version != V1: @@ -342,7 +350,7 @@ def load(config_details): "`docker stack deploy` to deploy to a swarm." .format(", ".join(sorted(s['name'] for s in services_using_deploy)))) - return Config(main_file.version, service_dicts, volumes, networks) + return Config(main_file.version, service_dicts, volumes, networks, secrets) def load_mapping(config_files, get_func, entity_type): @@ -820,6 +828,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) md.merge_sequence('links', ServiceLink.parse) + md.merge_sequence('secrets', types.ServiceSecret.parse) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 16616498..c43f296b 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -46,7 +46,7 @@ "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secrets" + "$ref": "#/definitions/secret" } }, "additionalProperties": false @@ -188,8 +188,8 @@ "properties": { "source": {"type": "string"}, "target": {"type": "string"}, - "uid": {"type": "number"}, - "gid": {"type": "number"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, "mode": {"type": "number"} } } diff --git a/compose/config/types.py b/compose/config/types.py index 4c106747..17d5c8b3 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -10,8 +10,8 @@ from collections import namedtuple import six -from compose.config.config import V1 -from compose.config.errors import ConfigurationError +from ..const import COMPOSEFILE_V1 as V1 +from .errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM from compose.utils import splitdrive @@ -234,3 +234,22 @@ class ServiceLink(namedtuple('_ServiceLink', 'target alias')): @property def merge_field(self): return self.alias + + +class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')): + + @classmethod + def parse(cls, spec): + if isinstance(spec, six.string_types): + return cls(spec, None, None, None, None) + return cls( + spec.get('source'), + spec.get('target'), + spec.get('uid'), + spec.get('gid'), + spec.get('mode'), + ) + + @property + def merge_field(self): + return self.source diff --git a/compose/const.py b/compose/const.py index 1b1be5c7..0f2b00c4 100644 --- a/compose/const.py +++ b/compose/const.py @@ -20,12 +20,14 @@ COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' COMPOSEFILE_V2_1 = '2.1' COMPOSEFILE_V3_0 = '3.0' +COMPOSEFILE_V3_1 = '3.1' API_VERSIONS = { COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V3_0: '1.25', + COMPOSEFILE_V3_1: '1.25', } API_VERSION_TO_ENGINE_VERSION = { @@ -33,4 +35,5 @@ API_VERSION_TO_ENGINE_VERSION = { API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', + API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', } From e0c6397999464dfe94f7e738dc36b2225f88972f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Jan 2017 17:18:04 -0500 Subject: [PATCH 556/580] Implement secrets using bind mounts Signed-off-by: Daniel Nephin --- compose/config/config.py | 48 +++++++++++++++++++++++++++----------- compose/const.py | 2 ++ compose/project.py | 27 +++++++++++++++++++++ compose/service.py | 23 +++++++++++++++--- tests/unit/bundle_test.py | 3 ++- tests/unit/project_test.py | 12 ++++++++++ 6 files changed, 98 insertions(+), 17 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3ca994a7..0e8b52e7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -334,8 +334,7 @@ def load(config_details): networks = load_mapping( config_details.config_files, 'get_networks', 'Network' ) - secrets = load_mapping( - config_details.config_files, 'get_secrets', 'Secrets') + secrets = load_secrets(config_details.config_files, config_details.working_dir) service_dicts = load_services(config_details, main_file) if main_file.version != V1: @@ -364,22 +363,12 @@ def load_mapping(config_files, get_func, entity_type): external = config.get('external') if external: - if len(config.keys()) > 1: - raise ConfigurationError( - '{} {} declared as external but specifies' - ' additional attributes ({}). '.format( - entity_type, - name, - ', '.join([k for k in config.keys() if k != 'external']) - ) - ) + validate_external(entity_type, name, config) if isinstance(external, dict): config['external_name'] = external.get('name') else: config['external_name'] = name - mapping[name] = config - if 'driver_opts' in config: config['driver_opts'] = build_string_dict( config['driver_opts'] @@ -391,6 +380,39 @@ def load_mapping(config_files, get_func, entity_type): return mapping +def validate_external(entity_type, name, config): + if len(config.keys()) <= 1: + return + + raise ConfigurationError( + "{} {} declared as external but specifies additional attributes " + "({}).".format( + entity_type, name, ', '.join(k for k in config if k != 'external'))) + + +def load_secrets(config_files, working_dir): + mapping = {} + + for config_file in config_files: + for name, config in config_file.get_secrets().items(): + mapping[name] = config or {} + if not config: + continue + + external = config.get('external') + if external: + validate_external('Secret', name, config) + if isinstance(external, dict): + config['external_name'] = external.get('name') + else: + config['external_name'] = name + + if 'file' in config: + config['file'] = expand_path(working_dir, config['file']) + + return mapping + + def load_services(config_details, config_file): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( diff --git a/compose/const.py b/compose/const.py index 0f2b00c4..3f8f90ab 100644 --- a/compose/const.py +++ b/compose/const.py @@ -16,6 +16,8 @@ LABEL_VERSION = 'com.docker.compose.version' LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' +SECRETS_PATH = '/run/secrets' + COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' COMPOSEFILE_V2_1 = '2.1' diff --git a/compose/project.py b/compose/project.py index d99ef7c9..22576e86 100644 --- a/compose/project.py +++ b/compose/project.py @@ -104,6 +104,11 @@ class Project(object): for volume_spec in service_dict.get('volumes', []) ] + secrets = get_secrets( + service_dict['name'], + service_dict.get('secrets') or [], + config_data.secrets) + project.services.append( Service( service_dict.pop('name'), @@ -114,6 +119,7 @@ class Project(object): links=links, network_mode=network_mode, volumes_from=volumes_from, + secrets=secrets, **service_dict) ) @@ -553,6 +559,27 @@ def get_volumes_from(project, service_dict): return [build_volume_from(vf) for vf in volumes_from] +def get_secrets(service, service_secrets, secret_defs): + secrets = [] + + for secret in service_secrets: + secret_def = secret_defs.get(secret.source) + if not secret_def: + raise ConfigurationError( + "Service \"{service}\" uses an undefined secret \"{secret}\" " + .format(service=service, secret=secret.source)) + + if secret_def.get('external_name'): + log.warn("Service \"{service}\" uses secret \"{secret}\" which is external. " + "External secrets are not available to containers created by " + "docker-compose.".format(service=service, secret=secret.source)) + continue + + secrets.append({'secret': secret, 'file': secret_def.get('file')}) + + return secrets + + def warn_for_swarm_mode(client): info = client.info() if info.get('Swarm', {}).get('LocalNodeState') == 'active': diff --git a/compose/service.py b/compose/service.py index 724e0565..9f2fc68b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -17,6 +17,7 @@ from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from . import __version__ +from . import const from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment @@ -139,6 +140,7 @@ class Service(object): volumes_from=None, network_mode=None, networks=None, + secrets=None, **options ): self.name = name @@ -149,6 +151,7 @@ class Service(object): self.volumes_from = volumes_from or [] self.network_mode = network_mode or NetworkMode(None) self.networks = networks or {} + self.secrets = secrets or [] self.options = options def __repr__(self): @@ -692,9 +695,14 @@ class Service(object): override_options['binds'] = binds container_options['environment'].update(affinity) - if 'volumes' in container_options: - container_options['volumes'] = dict( - (v.internal, {}) for v in container_options['volumes']) + container_options['volumes'] = dict( + (v.internal, {}) for v in container_options.get('volumes') or {}) + + secret_volumes = self.get_secret_volumes() + if secret_volumes: + override_options['binds'].extend(v.repr() for v in secret_volumes) + container_options['volumes'].update( + (v.internal, {}) for v in secret_volumes) container_options['image'] = self.image_name @@ -765,6 +773,15 @@ class Service(object): return host_config + def get_secret_volumes(self): + def build_spec(secret): + target = '{}/{}'.format( + const.SECRETS_PATH, + secret['secret'].target or secret['secret'].source) + return VolumeSpec(secret['file'], target, 'ro') + + return [build_spec(secret) for secret in self.secrets] + def build(self, no_cache=False, pull=False, force_rm=False): log.info('Building %s' % self.name) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index a279cab0..21bdb31b 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -77,7 +77,8 @@ def test_to_bundle(): version=2, services=services, volumes={'special': {}}, - networks={'extra': {}}) + networks={'extra': {}}, + secrets={}) with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: output = bundle.to_bundle(config, image_digests) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 9a12438f..32d0adfa 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -36,6 +36,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ) project = Project.from_config( name='composetest', @@ -64,6 +65,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ) project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) @@ -170,6 +172,7 @@ class ProjectTest(unittest.TestCase): }], networks=None, volumes=None, + secrets=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] @@ -202,6 +205,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] @@ -227,6 +231,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ), ) with mock.patch.object(Service, 'containers') as mock_return: @@ -360,6 +365,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ), ) service = project.get_service('test') @@ -384,6 +390,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ), ) service = project.get_service('test') @@ -417,6 +424,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ), ) @@ -437,6 +445,7 @@ class ProjectTest(unittest.TestCase): ], networks=None, volumes=None, + secrets=None, ), ) @@ -457,6 +466,7 @@ class ProjectTest(unittest.TestCase): ], networks={'custom': {}}, volumes=None, + secrets=None, ), ) @@ -487,6 +497,7 @@ class ProjectTest(unittest.TestCase): }], networks=None, volumes=None, + secrets=None, ), ) self.assertEqual([c.id for c in project.containers()], ['1']) @@ -503,6 +514,7 @@ class ProjectTest(unittest.TestCase): }], networks={'default': {}}, volumes={'data': {}}, + secrets=None, ), ) self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops') From 4053adc7d356270143f8389d41f857a128d9febb Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Jan 2017 14:54:35 -0500 Subject: [PATCH 557/580] Add an integration test for secrets using bind mounts. Signed-off-by: Daniel Nephin --- compose/project.py | 2 +- tests/fixtures/secrets/default | 1 + tests/integration/project_test.py | 132 ++++++++++++++++++------------ tests/integration/testcases.py | 9 +- 4 files changed, 86 insertions(+), 58 deletions(-) create mode 100644 tests/fixtures/secrets/default diff --git a/compose/project.py b/compose/project.py index 22576e86..e522e2ec 100644 --- a/compose/project.py +++ b/compose/project.py @@ -106,7 +106,7 @@ class Project(object): secrets = get_secrets( service_dict['name'], - service_dict.get('secrets') or [], + service_dict.pop('secrets', None) or [], config_data.secrets) project.services.append( diff --git a/tests/fixtures/secrets/default b/tests/fixtures/secrets/default new file mode 100644 index 00000000..f9dc2014 --- /dev/null +++ b/tests/fixtures/secrets/default @@ -0,0 +1 @@ +This is the secret diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ee2b7817..30b107e8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import os.path import random import py @@ -8,12 +9,14 @@ import pytest from docker.errors import NotFound from .. import mock -from ..helpers import build_config +from ..helpers import build_config as load_config from .testcases import DockerClientTestCase from compose.config import config from compose.config import ConfigurationError +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 VolumeSpec from compose.const import LABEL_PROJECT @@ -26,6 +29,16 @@ from compose.project import ProjectError from compose.service import ConvergenceStrategy from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only +from tests.integration.testcases import v3_only + + +def build_config(**kwargs): + return config.Config( + version=kwargs.get('version'), + services=kwargs.get('services'), + volumes=kwargs.get('volumes'), + networks=kwargs.get('networks'), + secrets=kwargs.get('secrets')) class ProjectTest(DockerClientTestCase): @@ -70,7 +83,7 @@ class ProjectTest(DockerClientTestCase): def test_volumes_from_service(self): project = Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'data': { 'image': 'busybox:latest', 'volumes': ['/var/data'], @@ -96,7 +109,7 @@ class ProjectTest(DockerClientTestCase): ) project = Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'db': { 'image': 'busybox:latest', 'volumes_from': ['composetest_data_container'], @@ -112,7 +125,7 @@ class ProjectTest(DockerClientTestCase): project = Project.from_config( name='composetest', client=self.client, - config_data=build_config({ + config_data=load_config({ 'version': V2_0, 'services': { 'net': { @@ -139,7 +152,7 @@ class ProjectTest(DockerClientTestCase): def get_project(): return Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'version': V2_0, 'services': { 'web': { @@ -174,7 +187,7 @@ class ProjectTest(DockerClientTestCase): def test_net_from_service_v1(self): project = Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'net': { 'image': 'busybox:latest', 'command': ["top"] @@ -198,7 +211,7 @@ class ProjectTest(DockerClientTestCase): def get_project(): return Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'web': { 'image': 'busybox:latest', 'net': 'container:composetest_net_container' @@ -469,7 +482,7 @@ class ProjectTest(DockerClientTestCase): def test_project_up_starts_depends(self): project = Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'console': { 'image': 'busybox:latest', 'command': ["top"], @@ -504,7 +517,7 @@ class ProjectTest(DockerClientTestCase): def test_project_up_with_no_deps(self): project = Project.from_config( name='composetest', - config_data=build_config({ + config_data=load_config({ 'console': { 'image': 'busybox:latest', 'command': ["top"], @@ -564,7 +577,7 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_project_up_networks(self): - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -576,7 +589,6 @@ class ProjectTest(DockerClientTestCase): 'baz': {'aliases': ['extra']}, }, }], - volumes={}, networks={ 'foo': {'driver': 'bridge'}, 'bar': {'driver': None}, @@ -610,14 +622,13 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_up_with_ipam_config(self): - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', 'networks': {'front': None}, }], - volumes={}, networks={ 'front': { 'driver': 'bridge', @@ -671,7 +682,7 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_up_with_network_static_addresses(self): - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -684,7 +695,6 @@ class ProjectTest(DockerClientTestCase): } }, }], - volumes={}, networks={ 'static_test': { 'driver': 'bridge', @@ -726,7 +736,7 @@ class ProjectTest(DockerClientTestCase): @v2_1_only() def test_up_with_enable_ipv6(self): self.require_api_version('1.23') - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -738,7 +748,6 @@ class ProjectTest(DockerClientTestCase): } }, }], - volumes={}, networks={ 'static_test': { 'driver': 'bridge', @@ -770,7 +779,7 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_up_with_network_static_addresses_missing_subnet(self): - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -782,7 +791,6 @@ class ProjectTest(DockerClientTestCase): } }, }], - volumes={}, networks={ 'static_test': { 'driver': 'bridge', @@ -807,7 +815,7 @@ class ProjectTest(DockerClientTestCase): @v2_1_only() def test_up_with_network_link_local_ips(self): - config_data = config.Config( + config_data = build_config( version=V2_1, services=[{ 'name': 'web', @@ -818,7 +826,6 @@ class ProjectTest(DockerClientTestCase): } } }], - volumes={}, networks={ 'linklocaltest': {'driver': 'bridge'} } @@ -844,15 +851,13 @@ class ProjectTest(DockerClientTestCase): @v2_1_only() def test_up_with_isolation(self): self.require_api_version('1.24') - config_data = config.Config( + config_data = build_config( version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', 'isolation': 'default' }], - volumes={}, - networks={} ) project = Project.from_config( client=self.client, @@ -866,15 +871,13 @@ class ProjectTest(DockerClientTestCase): @v2_1_only() def test_up_with_invalid_isolation(self): self.require_api_version('1.24') - config_data = config.Config( + config_data = build_config( version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', 'isolation': 'foobar' }], - volumes={}, - networks={} ) project = Project.from_config( client=self.client, @@ -887,14 +890,13 @@ class ProjectTest(DockerClientTestCase): @v2_only() def test_project_up_with_network_internal(self): self.require_api_version('1.23') - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', 'networks': {'internal': None}, }], - volumes={}, networks={ 'internal': {'driver': 'bridge', 'internal': True}, }, @@ -917,14 +919,13 @@ class ProjectTest(DockerClientTestCase): network_name = 'network_with_label' - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', 'networks': {network_name: None} }], - volumes={}, networks={ network_name: {'labels': {'label_key': 'label_val'}} } @@ -951,7 +952,7 @@ class ProjectTest(DockerClientTestCase): def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -959,7 +960,6 @@ class ProjectTest(DockerClientTestCase): 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, - networks={}, ) project = Project.from_config( @@ -979,7 +979,7 @@ class ProjectTest(DockerClientTestCase): volume_name = 'volume_with_label' - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -993,7 +993,6 @@ class ProjectTest(DockerClientTestCase): } } }, - networks={}, ) project = Project.from_config( @@ -1106,7 +1105,7 @@ class ProjectTest(DockerClientTestCase): def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1114,7 +1113,6 @@ class ProjectTest(DockerClientTestCase): 'command': 'top' }], volumes={vol_name: {}}, - networks={}, ) project = Project.from_config( @@ -1124,14 +1122,14 @@ class ProjectTest(DockerClientTestCase): project.volumes.initialize() volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) - self.assertEqual(volume_data['Driver'], 'local') + assert volume_data['Name'] == full_vol_name + assert volume_data['Driver'] == 'local' @v2_only() def test_project_up_implicit_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1139,7 +1137,6 @@ class ProjectTest(DockerClientTestCase): 'command': 'top' }], volumes={vol_name: {}}, - networks={}, ) project = Project.from_config( @@ -1152,11 +1149,45 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') + @v3_only() + def test_project_up_with_secrets(self): + config_data = build_config( + version=V3_1, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'cat /run/secrets/special', + 'secrets': [ + types.ServiceSecret.parse({'source': 'super', 'target': 'special'}), + ], + }], + secrets={ + 'super': { + 'file': os.path.abspath('tests/fixtures/secrets/default'), + }, + }, + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + project.up() + project.stop() + + containers = project.containers(stopped=True) + assert len(containers) == 1 + container, = containers + + output = container.logs() + assert output == "This is the secret\n" + @v2_only() def test_initialize_volumes_invalid_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1164,7 +1195,6 @@ class ProjectTest(DockerClientTestCase): 'command': 'top' }], volumes={vol_name: {'driver': 'foobar'}}, - networks={}, ) project = Project.from_config( @@ -1179,7 +1209,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1187,7 +1217,6 @@ class ProjectTest(DockerClientTestCase): 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, - networks={}, ) project = Project.from_config( name='composetest', @@ -1218,7 +1247,7 @@ class ProjectTest(DockerClientTestCase): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1226,7 +1255,6 @@ class ProjectTest(DockerClientTestCase): 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, - networks={}, ) project = Project.from_config( name='composetest', @@ -1257,7 +1285,7 @@ class ProjectTest(DockerClientTestCase): vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) self.client.create_volume(vol_name) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1267,7 +1295,6 @@ class ProjectTest(DockerClientTestCase): volumes={ vol_name: {'external': True, 'external_name': vol_name} }, - networks=None, ) project = Project.from_config( name='composetest', @@ -1282,7 +1309,7 @@ class ProjectTest(DockerClientTestCase): def test_initialize_volumes_inexistent_external_volume(self): vol_name = '{0:x}'.format(random.getrandbits(32)) - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1292,7 +1319,6 @@ class ProjectTest(DockerClientTestCase): volumes={ vol_name: {'external': True, 'external_name': vol_name} }, - networks=None, ) project = Project.from_config( name='composetest', @@ -1349,7 +1375,7 @@ class ProjectTest(DockerClientTestCase): } } - config_data = build_config(config_dict) + config_data = load_config(config_dict) project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) @@ -1357,7 +1383,7 @@ class ProjectTest(DockerClientTestCase): config_dict['service2'] = config_dict['service1'] del config_dict['service1'] - config_data = build_config(config_dict) + config_data = load_config(config_dict) project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 230bd2d9..efc1551b 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -41,9 +41,9 @@ def engine_max_version(): version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 - elif version_lt(version, '1.12'): + if version_lt(version, '1.12'): return V2_0 - elif version_lt(version, '1.13'): + if version_lt(version, '1.13'): return V2_1 return V3_0 @@ -52,8 +52,9 @@ def build_version_required_decorator(ignored_versions): def decorator(f): @functools.wraps(f) def wrapper(self, *args, **kwargs): - if engine_max_version() in ignored_versions: - skip("Engine version is too low") + max_version = engine_max_version() + if max_version in ignored_versions: + skip("Engine version %s is too low" % max_version) return return f(self, *args, **kwargs) return wrapper From 0d609b68acd12948f181200b3dac85b24c9e1441 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Jan 2017 15:00:33 -0500 Subject: [PATCH 558/580] Add a warning for unsupported secret fields. Signed-off-by: Daniel Nephin --- compose/project.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/project.py b/compose/project.py index e522e2ec..0330ab80 100644 --- a/compose/project.py +++ b/compose/project.py @@ -575,6 +575,12 @@ def get_secrets(service, service_secrets, secret_defs): "docker-compose.".format(service=service, secret=secret.source)) continue + if secret.uid or secret.gid or secret.mode: + log.warn("Service \"{service}\" uses secret \"{secret}\" with uid, " + "gid, or mode. These fields are not supported by this " + "implementation of the Compose file".format( + service=service, secret=secret.source)) + secrets.append({'secret': secret, 'file': secret_def.get('file')}) return secrets From 3a2735abb933fc8f067e888e6009eac9e2be3132 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 20 Jan 2017 17:10:23 -0500 Subject: [PATCH 559/580] Rebase compose v3.1 on the latest v3 Signed-off-by: Daniel Nephin --- compose/config/config_schema_v3.1.json | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index c43f296b..b7037485 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -198,8 +198,8 @@ }, "sysctls": {"$ref": "#/definitions/list_or_dict"}, "stdin_open": {"type": "boolean"}, - "stop_signal": {"type": "string"}, "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { @@ -231,10 +231,11 @@ "healthcheck": { "id": "#/definitions/healthcheck", - "type": ["object", "null"], + "type": "object", + "additionalProperties": false, "properties": { - "interval": {"type":"string"}, - "timeout": {"type":"string"}, + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -242,9 +243,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "disable": {"type": "boolean"} - }, - "additionalProperties": false + "timeout": {"type": "string"} + } }, "deployment": { "id": "#/definitions/deployment", @@ -337,6 +337,7 @@ }, "additionalProperties": false }, + "internal": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false @@ -357,10 +358,11 @@ "type": ["boolean", "object"], "properties": { "name": {"type": "string"} - } - } + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} }, - "labels": {"$ref": "#/definitions/list_or_dict"}, "additionalProperties": false }, @@ -374,9 +376,9 @@ "properties": { "name": {"type": "string"} } - } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} }, - "labels": {"$ref": "#/definitions/list_or_dict"}, "additionalProperties": false }, From 59d1847d9bc88f9b4248267e93fe0435ce973da9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Jan 2017 12:51:05 -0500 Subject: [PATCH 560/580] Fix some test failures. Signed-off-by: Daniel Nephin --- tests/integration/project_test.py | 37 +++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 30b107e8..28762cd2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1151,6 +1151,8 @@ class ProjectTest(DockerClientTestCase): @v3_only() def test_project_up_with_secrets(self): + create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) + config_data = build_config( version=V3_1, services=[{ @@ -1181,7 +1183,7 @@ class ProjectTest(DockerClientTestCase): container, = containers output = container.logs() - assert output == "This is the secret\n" + assert output == b"This is the secret\n" @v2_only() def test_initialize_volumes_invalid_volume_driver(self): @@ -1428,7 +1430,7 @@ class ProjectTest(DockerClientTestCase): } } } - config_data = build_config(config_dict) + config_data = load_config(config_dict) project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) @@ -1465,7 +1467,7 @@ class ProjectTest(DockerClientTestCase): } } } - config_data = build_config(config_dict) + config_data = load_config(config_dict) project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) @@ -1501,7 +1503,7 @@ class ProjectTest(DockerClientTestCase): } } } - config_data = build_config(config_dict) + config_data = load_config(config_dict) project = Project.from_config( name='composetest', config_data=config_data, client=self.client ) @@ -1515,3 +1517,30 @@ class ProjectTest(DockerClientTestCase): assert 'svc1' in svc2.get_dependency_names() with pytest.raises(NoHealthCheckConfigured): 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) From 8efb7e6e8bbd1542db768fc1b90c6c7282f0944b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 31 Jan 2017 12:51:46 -0800 Subject: [PATCH 561/580] Don't strip ANSI color codes when output is not a TTY Signed-off-by: Joffrey F --- compose/cli/colors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/colors.py b/compose/cli/colors.py index 6677a376..f1251e43 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -33,7 +33,7 @@ def make_color_fn(code): return lambda s: ansi_color(code, s) -colorama.init() +colorama.init(strip=False) for (name, code) in get_pairs(): globals()[name] = make_color_fn(code) From 84774cacd210bb176c2daf73106c7dc849a6a0d7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 15:16:09 -0800 Subject: [PATCH 562/580] Upgrade python and pip versions in Dockerfile Add libbz2 dependency Signed-off-by: Joffrey F --- Dockerfile | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 63fac3eb..a03e1510 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN set -ex; \ ca-certificates \ curl \ libsqlite3-dev \ + libbz2-dev \ ; \ rm -rf /var/lib/apt/lists/* @@ -20,40 +21,32 @@ RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.3 \ -o /usr/local/bin/docker && \ chmod +x /usr/local/bin/docker -# Build Python 2.7.9 from source +# Build Python 2.7.13 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz | tar -xz; \ - cd Python-2.7.9; \ + 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.9 + 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.3/Python-3.4.3.tgz | tar -xz; \ - cd Python-3.4.3; \ + 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.3 + rm -rf /Python-3.4.6 # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib -# Install setuptools -RUN set -ex; \ - curl -L https://bootstrap.pypa.io/ez_setup.py | python - # Install pip RUN set -ex; \ - curl -L https://pypi.python.org/packages/source/p/pip/pip-8.1.1.tar.gz | tar -xz; \ - cd pip-8.1.1; \ - python setup.py install; \ - cd ..; \ - rm -rf pip-8.1.1 + 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 From a67500ee5728032aa902a640014bc35b6ab4d715 Mon Sep 17 00:00:00 2001 From: Peter Urda Date: Fri, 14 Oct 2016 00:44:52 -0700 Subject: [PATCH 563/580] Added `top` to `docker-compose` to display running processes. This commit allows `docker-compose` to access `top` for containers much like running `docker top` directly on a given container. This commit includes: * `docker-compose` CLI changes to expose `top` * Completions for `bash` and `zsh` * Required testing for the new `top` command Signed-off-by: Peter Urda --- compose/cli/main.py | 28 ++++++++++++++++++++++++++ contrib/completion/bash/docker-compose | 13 ++++++++++++ contrib/completion/zsh/_docker-compose | 5 +++++ tests/acceptance/cli_test.py | 20 ++++++++++++++++++ tests/fixtures/top/docker-compose.yml | 6 ++++++ 5 files changed, 72 insertions(+) create mode 100644 tests/fixtures/top/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index db068272..e2ebce48 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -215,6 +215,7 @@ class TopLevelCommand(object): scale Set number of containers for a service start Start services stop Stop services + top Display the running processes unpause Unpause services up Create and start containers version Show the Docker-Compose version information @@ -800,6 +801,33 @@ class TopLevelCommand(object): containers = self.project.restart(service_names=options['SERVICE'], timeout=timeout) exit_if(not containers, 'No containers to restart', 1) + def top(self, options): + """ + Display the running processes + + Usage: top [SERVICE...] + + """ + containers = sorted( + self.project.containers(service_names=options['SERVICE'], stopped=False) + + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), + key=attrgetter('name') + ) + + for idx, container in enumerate(containers): + if idx > 0: + print() + + top_data = self.project.client.top(container.name) + headers = top_data.get("Titles") + rows = [] + + for process in top_data.get("Processes", []): + rows.append(process) + + print(container.name) + print(Formatter().table(headers, rows)) + def unpause(self, options): """ Unpause services. diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 991f6572..77d02b42 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -434,6 +434,18 @@ _docker_compose_stop() { } +_docker_compose_top() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + ;; + *) + __docker_compose_services_running + ;; + esac +} + + _docker_compose_unpause() { case "$cur" in -*) @@ -499,6 +511,7 @@ _docker_compose() { scale start stop + top unpause up version diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index ceb7d0f5..66d924f7 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -341,6 +341,11 @@ __docker-compose_subcommand() { $opts_timeout \ '*:running services:__docker-compose_runningservices' && ret=0 ;; + (top) + _arguments \ + $opts_help \ + '*:running services:__docker-compose_runningservices' && ret=0 + ;; (unpause) _arguments \ $opts_help \ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 58160c80..160e1913 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1907,3 +1907,23 @@ class CLITestCase(DockerClientTestCase): "BAZ=2", ]) self.assertTrue(expected_env <= set(web.get('Config.Env'))) + + def test_top_services_not_running(self): + self.base_dir = 'tests/fixtures/top' + result = self.dispatch(['top']) + assert len(result.stdout) == 0 + + def test_top_services_running(self): + self.base_dir = 'tests/fixtures/top' + self.dispatch(['up', '-d']) + result = self.dispatch(['top']) + + self.assertIn('top_service_a', result.stdout) + self.assertIn('top_service_b', result.stdout) + self.assertNotIn('top_not_a_service', result.stdout) + + def test_top_processes_running(self): + self.base_dir = 'tests/fixtures/top' + self.dispatch(['up', '-d']) + result = self.dispatch(['top']) + assert result.stdout.count("top") == 4 diff --git a/tests/fixtures/top/docker-compose.yml b/tests/fixtures/top/docker-compose.yml new file mode 100644 index 00000000..d632a836 --- /dev/null +++ b/tests/fixtures/top/docker-compose.yml @@ -0,0 +1,6 @@ +service_a: + image: busybox:latest + command: top +service_b: + image: busybox:latest + command: top From 7e8958e6cab8edbfabd732e29a1c01b375a8bc02 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Feb 2017 15:43:20 -0800 Subject: [PATCH 564/580] Add missing comma in DOCKER_CONFIG_KEYS list Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0e8b52e7..746f63d5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -78,7 +78,7 @@ DOCKER_CONFIG_KEYS = [ 'memswap_limit', 'mem_swappiness', 'net', - 'oom_score_adj' + 'oom_score_adj', 'pid', 'ports', 'privileged', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d7947a4e..860e5835 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1748,6 +1748,24 @@ class ConfigTest(unittest.TestCase): } } + def test_merge_pid(self): + # Regression: https://github.com/docker/compose/issues/4184 + base = { + 'image': 'busybox', + 'pid': 'host' + } + + override = { + 'labels': {'com.docker.compose.test': 'yes'} + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual == { + 'image': 'busybox', + 'pid': 'host', + 'labels': {'com.docker.compose.test': 'yes'} + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From cf43e6edf7c734c3a98306bf9b4a01eb7f516005 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Feb 2017 14:22:50 -0800 Subject: [PATCH 565/580] Don't re-parse healthcheck values coming from extended services Signed-off-by: Joffrey F --- compose/config/config.py | 10 ++++++++-- tests/fixtures/extends/healthcheck-1.yml | 9 +++++++++ tests/fixtures/extends/healthcheck-2.yml | 6 ++++++ tests/unit/config/config_test.py | 13 +++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/extends/healthcheck-1.yml create mode 100644 tests/fixtures/extends/healthcheck-2.yml diff --git a/compose/config/config.py b/compose/config/config.py index 0e8b52e7..63ee25ab 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -716,9 +716,15 @@ def process_healthcheck(service_dict, service_name): hc['test'] = raw['test'] if 'interval' in raw: - hc['interval'] = parse_nanoseconds_int(raw['interval']) + if not isinstance(raw['interval'], six.integer_types): + hc['interval'] = parse_nanoseconds_int(raw['interval']) + else: # Conversion has been done previously + hc['interval'] = raw['interval'] if 'timeout' in raw: - hc['timeout'] = parse_nanoseconds_int(raw['timeout']) + if not isinstance(raw['timeout'], six.integer_types): + hc['timeout'] = parse_nanoseconds_int(raw['timeout']) + else: # Conversion has been done previously + hc['timeout'] = raw['timeout'] if 'retries' in raw: hc['retries'] = raw['retries'] diff --git a/tests/fixtures/extends/healthcheck-1.yml b/tests/fixtures/extends/healthcheck-1.yml new file mode 100644 index 00000000..4c311e62 --- /dev/null +++ b/tests/fixtures/extends/healthcheck-1.yml @@ -0,0 +1,9 @@ +version: '2.1' +services: + demo: + image: foobar:latest + healthcheck: + test: ["CMD", "/health.sh"] + interval: 10s + timeout: 5s + retries: 36 diff --git a/tests/fixtures/extends/healthcheck-2.yml b/tests/fixtures/extends/healthcheck-2.yml new file mode 100644 index 00000000..11bc9f09 --- /dev/null +++ b/tests/fixtures/extends/healthcheck-2.yml @@ -0,0 +1,6 @@ +version: '2.1' +services: + demo: + extends: + file: healthcheck-1.yml + service: demo diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d7947a4e..a3be6df8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3098,6 +3098,19 @@ class ExtendsTest(unittest.TestCase): 'other': {'condition': 'service_started'} } + def test_extends_with_healthcheck(self): + service_dicts = load_from_filename('tests/fixtures/extends/healthcheck-2.yml') + assert service_sort(service_dicts) == [{ + 'name': 'demo', + 'image': 'foobar:latest', + 'healthcheck': { + 'test': ['CMD', '/health.sh'], + 'interval': 10000000000, + 'timeout': 5000000000, + 'retries': 36, + } + }] + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From a3a9d8944a413897ae1f0305d16d6d1071487ad6 Mon Sep 17 00:00:00 2001 From: Kevin Jing Qiu Date: Thu, 26 Jan 2017 14:23:12 -0500 Subject: [PATCH 566/580] Close the open file handle using context manager Signed-off-by: Kevin Jing Qiu --- compose/config/environment.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 7b926930..4ba228c8 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import codecs +import contextlib import logging import os @@ -31,11 +32,12 @@ def env_vars_from_file(filename): elif not os.path.isfile(filename): raise ConfigurationError("%s is not a file." % (filename)) env = {} - for line in codecs.open(filename, 'r', 'utf-8'): - line = line.strip() - if line and not line.startswith('#'): - k, v = split_env(line) - env[k] = v + with contextlib.closing(codecs.open(filename, 'r', 'utf-8')) as fileobj: + for line in fileobj: + line = line.strip() + if line and not line.startswith('#'): + k, v = split_env(line) + env[k] = v return env From b392b6e12ed724ce39e0e65fd5582b70c47803af Mon Sep 17 00:00:00 2001 From: fate-grand-order Date: Tue, 7 Feb 2017 15:59:34 +0800 Subject: [PATCH 567/580] fix typo in CHANGELOG.md Signed-off-by: fate-grand-order --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c645ca2..e969b453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -525,7 +525,7 @@ Bug Fixes: if at least one container is using the network. - 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. Previously a container was only recreated if it's dependencies already From f0835268296111cac54faa8701b7aba751c0a239 Mon Sep 17 00:00:00 2001 From: "Aaron.L.Xu" Date: Wed, 8 Feb 2017 18:50:14 +0800 Subject: [PATCH 568/580] referencing right segment of code Signed-off-by: Aaron.L.Xu --- compose/bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/bundle.py b/compose/bundle.py index 854cc799..505ce91f 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -202,7 +202,7 @@ def convert_service_to_bundle(name, service_dict, image_digest): 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): if isinstance(entrypoint, six.string_types): entrypoint = split_command(entrypoint) From 979a0d53f7e989f13dc77865c7d1f4775f97319e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Feb 2017 15:26:34 -0800 Subject: [PATCH 569/580] Bump 1.11.0-rc1 Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/__init__.py b/compose/__init__.py index 38417836..ae8d759d 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.11.0dev' +__version__ = '1.11.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 5872b081..9de11d5f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0" +VERSION="1.11.0-rc1" IMAGE="docker/compose:$VERSION" From 01d1895a350c445eab9deb37d1cd8b6fe3328b47 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Feb 2017 11:30:29 -0800 Subject: [PATCH 570/580] Bump 1.11.0 Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/__init__.py b/compose/__init__.py index ae8d759d..d7468af2 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.11.0-rc1' +__version__ = '1.11.0' diff --git a/script/run/run.sh b/script/run/run.sh index 9de11d5f..b45630f0 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.11.0-rc1" +VERSION="1.11.0" IMAGE="docker/compose:$VERSION" From 2cd6cb9a47b2d00cfad7d49a26641020f7f8a66a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 16:20:59 -0800 Subject: [PATCH 571/580] Bump 1.10.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6699f880..d0681e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,53 @@ Change log ========== +1.11.0 (2017-02-08) +------------------- + +### New Features + +#### Compose file version 3.1 + +- Introduced version 3.1 of the `docker-compose.yml` specification. This + version requires Docker Engine 1.13.0 or above. It introduces support + for secrets. See the documentation for more information + +#### Compose file version 2.0 and up + +- Introduced the `docker-compose top` command that displays processes running + for the different services managed by Compose. + +### Bugfixes + +- Fixed a bug where extending a service defining a healthcheck dictionary + would cause `docker-compose` to error out. + +- Fixed an issue where the `pid` entry in a service definition was being + ignored when using multiple Compose files. + +1.10.1 (2017-02-01) +------------------ + +### Bugfixes + +- Fixed an issue where presence of older versions of the docker-py + package would cause unexpected crashes while running Compose + +- Fixed an issue where healthcheck dependencies would be lost when + using multiple compose files for a project + +- Fixed a few issues that made the output of the `config` command + invalid + +- Fixed an issue where adding volume labels to v3 Compose files would + result in an error + +- Fixed an issue on Windows where build context paths containing unicode + characters were being improperly encoded + +- Fixed a bug where Compose would occasionally crash while streaming logs + when containers would stop or restart + 1.10.0 (2017-01-18) ------------------- From fc7b74d7f900d6e94ebd46a05cdcadcfd1f7d407 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Feb 2017 13:47:08 -0800 Subject: [PATCH 572/580] Bump to next dev version Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/__init__.py b/compose/__init__.py index d7468af2..b2ca86f8 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.11.0' +__version__ = '1.12.0dev' diff --git a/script/run/run.sh b/script/run/run.sh index b45630f0..4e173894 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.11.0" +VERSION="1.12.0dev" IMAGE="docker/compose:$VERSION" From 47e4442722373ce43f7878ee98fe9aceb1b9f177 Mon Sep 17 00:00:00 2001 From: kevinetc123 Date: Thu, 9 Feb 2017 19:10:26 +0800 Subject: [PATCH 573/580] fix typo in project.py Signed-off-by: kevinetc123 --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 0330ab80..133071e7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -365,7 +365,7 @@ class Project(object): # TODO: get labels from the API v1.22 , see github issue 2618 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']) except APIError: continue From c092fa37de820e7d6dd20b30d2c4dec28f214dd3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 9 Feb 2017 10:27:06 -0500 Subject: [PATCH 574/580] Fix version 3.1 Signed-off-by: Daniel Nephin --- compose/config/config.py | 11 ++++------- docker-compose.spec | 5 +++++ tests/unit/config/config_test.py | 4 ++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ae85674b..09a717be 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -186,11 +186,6 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): if version == '3': version = V3_0 - if version not in (V2_0, V2_1, V3_0): - raise ConfigurationError( - 'Version in "{}" is unsupported. {}' - .format(self.filename, VERSION_EXPLANATION)) - return version def get_service(self, name): @@ -479,7 +474,7 @@ def process_config_file(config_file, environment, service_name=None): 'service', environment) - if config_file.version in (V2_0, V2_1, V3_0): + if config_file.version in (V2_0, V2_1, V3_0, V3_1): processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( @@ -495,7 +490,9 @@ def process_config_file(config_file, environment, service_name=None): elif config_file.version == V1: processed_config = services else: - raise Exception("Unsupported version: {}".format(repr(config_file.version))) + raise ConfigurationError( + 'Version in "{}" is unsupported. {}' + .format(config_file.filename, VERSION_EXPLANATION)) config_file = config_file._replace(config=processed_config) validate_against_config_schema(config_file) diff --git a/docker-compose.spec b/docker-compose.spec index ec5a2039..ef0e2593 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -37,6 +37,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.0.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.1.json', + 'compose/config/config_schema_v3.1.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 666b21f2..ef57bb57 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -19,6 +19,7 @@ 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.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION @@ -168,6 +169,9 @@ class ConfigTest(unittest.TestCase): cfg = config.load(build_config_details({'version': version})) assert cfg.version == V3_0 + cfg = config.load(build_config_details({'version': '3.1'})) + assert cfg.version == V3_1 + def test_v1_file_version(self): cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) assert cfg.version == V1 From dc5b3f3b3eb53ce747003089c8ae5ff21f4b1f70 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 10 Feb 2017 17:05:33 -0500 Subject: [PATCH 575/580] Fix secrets config. Signed-off-by: Daniel Nephin --- compose/config/config.py | 5 ++ compose/config/types.py | 8 +++ setup.py | 10 ++-- tests/unit/config/config_test.py | 86 ++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 09a717be..4c9cf423 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -763,6 +763,11 @@ def finalize_service(service_config, service_names, version, environment): if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) + if 'secrets' in service_dict: + service_dict['secrets'] = [ + types.ServiceSecret.parse(s) for s in service_dict['secrets'] + ] + normalize_build(service_dict, service_config.working_dir, environment) service_dict['name'] = service_config.name diff --git a/compose/config/types.py b/compose/config/types.py index 17d5c8b3..f86c0319 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -253,3 +253,11 @@ class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')): @property def merge_field(self): return self.source + + def repr(self): + return dict( + source=self.source, + target=self.target, + uid=self.uid, + gid=self.gid, + mode=self.mode) diff --git a/setup.py b/setup.py index 0b1d4e08..eafbc356 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,10 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import absolute_import +from __future__ import print_function from __future__ import unicode_literals import codecs -import logging import os import re import sys @@ -64,11 +64,9 @@ try: for key, value in extras_require.items(): if key.startswith(':') and pkg_resources.evaluate_marker(key[1:]): install_requires.extend(value) -except Exception: - logging.getLogger(__name__).exception( - 'Failed to compute platform dependencies. All dependencies will be ' - 'installed as a result.' - ) +except Exception as e: + print("Failed to compute platform dependencies: {}. ".format(e) + + "All dependencies will be installed as a result.", file=sys.stderr) for key, value in extras_require.items(): if key.startswith(':'): install_requires.extend(value) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ef57bb57..d4d1ad2c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -13,6 +13,7 @@ import pytest from ...helpers import build_config_details from compose.config import config +from compose.config import types from compose.config.config import resolve_build_args from compose.config.config import resolve_environment from compose.config.config import V1 @@ -1849,6 +1850,91 @@ class ConfigTest(unittest.TestCase): config.load(config_details) assert 'has neither an image nor a build context' in exc.exconly() + def test_load_secrets(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.1', + 'services': { + 'web': { + 'image': 'example/web', + 'secrets': [ + 'one', + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + }, + ], + }, + }, + 'secrets': { + 'one': {'file': 'secret.txt'}, + }, + }) + details = config.ConfigDetails('.', [base_file]) + service_dicts = config.load(details).services + expected = [ + { + 'name': 'web', + 'image': 'example/web', + 'secrets': [ + types.ServiceSecret('one', None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 0o777), + ], + }, + ] + assert service_sort(service_dicts) == service_sort(expected) + + def test_load_secrets_multi_file(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.1', + 'services': { + 'web': { + 'image': 'example/web', + 'secrets': ['one'], + }, + }, + 'secrets': { + 'one': {'file': 'secret.txt'}, + }, + }) + override_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.1', + 'services': { + 'web': { + 'secrets': [ + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + }, + ], + }, + }, + }) + details = config.ConfigDetails('.', [base_file, override_file]) + service_dicts = config.load(details).services + expected = [ + { + 'name': 'web', + 'image': 'example/web', + 'secrets': [ + types.ServiceSecret('one', None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 0o777), + ], + }, + ] + assert service_sort(service_dicts) == service_sort(expected) + class NetworkModeTest(unittest.TestCase): def test_network_mode_standard(self): From 252699c1d124ba365e4b9078f4a44f3acbcb08be Mon Sep 17 00:00:00 2001 From: Petr Karmashev Date: Sun, 12 Feb 2017 02:03:04 +0300 Subject: [PATCH 576/580] Compose file reference link fix in README.md Signed-off-by: Petr Karmashev --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5cf69b05..35a10b90 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file.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: From abce83ef25528fb36979208888ba0033c89f47a3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 13 Feb 2017 16:04:06 -0800 Subject: [PATCH 577/580] Fix `config` command output with service.secrets section Signed-off-by: Joffrey F --- compose/config/serialize.py | 3 ++ compose/config/types.py | 7 ++-- tests/unit/config/config_test.py | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 3745de82..46d283f0 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -102,4 +102,7 @@ def denormalize_service_dict(service_dict, version): service_dict['healthcheck']['timeout'] ) + if 'secrets' in service_dict: + service_dict['secrets'] = map(lambda s: s.repr(), service_dict['secrets']) + return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index f86c0319..811e6c1f 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -256,8 +256,5 @@ class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')): def repr(self): return dict( - source=self.source, - target=self.target, - uid=self.uid, - gid=self.gid, - mode=self.mode) + [(k, v) for k, v in self._asdict().items() if v is not None] + ) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d4d1ad2c..c26272d9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -54,6 +54,10 @@ def service_sort(services): return sorted(services, key=itemgetter('name')) +def secret_sort(secrets): + return sorted(secrets, key=itemgetter('source')) + + class ConfigTest(unittest.TestCase): def test_load(self): service_dicts = config.load( @@ -1771,6 +1775,38 @@ class ConfigTest(unittest.TestCase): 'labels': {'com.docker.compose.test': 'yes'} } + def test_merge_different_secrets(self): + base = { + 'image': 'busybox', + 'secrets': [ + {'source': 'src.txt'} + ] + } + override = {'secrets': ['other-src.txt']} + + actual = config.merge_service_dicts(base, override, V3_1) + assert secret_sort(actual['secrets']) == secret_sort([ + {'source': 'src.txt'}, + {'source': 'other-src.txt'} + ]) + + def test_merge_secrets_override(self): + base = { + 'image': 'busybox', + 'secrets': ['src.txt'], + } + override = { + 'secrets': [ + { + 'source': 'src.txt', + 'target': 'data.txt', + 'mode': 0o400 + } + ] + } + actual = config.merge_service_dicts(base, override, V3_1) + assert actual['secrets'] == override['secrets'] + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', @@ -3491,3 +3527,24 @@ class SerializeTest(unittest.TestCase): denormalized_service = denormalize_service_dict(processed_service, V2_1) assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' + + def test_denormalize_secrets(self): + service_dict = { + 'name': 'web', + 'image': 'example/web', + 'secrets': [ + types.ServiceSecret('one', None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 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, + }, + ]) From 66f4a795a2ded1a26b6cf8474edb423727dd585d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 15 Feb 2017 16:07:08 -0800 Subject: [PATCH 578/580] Don't import pip inside Compose Signed-off-by: Joffrey F --- compose/cli/__init__.py | 37 +++++++++++++++++++++++++++++++++++++ compose/cli/main.py | 24 ------------------------ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index e69de29b..c5db4455 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -0,0 +1,37 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import subprocess +import sys + +# Attempt to detect https://github.com/docker/compose/issues/4344 +try: + # We don't try importing pip because it messes with package imports + # on some Linux distros (Ubuntu, Fedora) + # https://github.com/docker/compose/issues/4425 + # https://github.com/docker/compose/issues/4481 + # https://github.com/pypa/pip/blob/master/pip/_vendor/__init__.py + s_cmd = subprocess.Popen( + ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE + ) + packages = s_cmd.communicate()[0].splitlines() + dockerpy_installed = len( + list(filter(lambda p: p.startswith(b'docker-py=='), packages)) + ) > 0 + if dockerpy_installed: + from .colors import red + print( + red('ERROR:'), + "Dependency conflict: an older version of the 'docker-py' package " + "is polluting the namespace. " + "Run the following command to remedy the issue:\n" + "pip uninstall docker docker-py; pip install docker", + file=sys.stderr + ) + sys.exit(1) + +except OSError: + # pip command is not available, which indicates it's probably the binary + # distribution of Compose which is not affected + pass diff --git a/compose/cli/main.py b/compose/cli/main.py index e2ebce48..51ba36a0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,30 +14,6 @@ from distutils.spawn import find_executable from inspect import getdoc from operator import attrgetter - -# Attempt to detect https://github.com/docker/compose/issues/4344 -try: - # A regular import statement causes PyInstaller to freak out while - # trying to load pip. This way it is simply ignored. - pip = __import__('pip') - pip_packages = pip.get_installed_distributions() - if 'docker-py' in [pkg.project_name for pkg in pip_packages]: - from .colors import red - print( - red('ERROR:'), - "Dependency conflict: an older version of the 'docker-py' package " - "is polluting the namespace. " - "Run the following command to remedy the issue:\n" - "pip uninstall docker docker-py; pip install docker", - file=sys.stderr - ) - sys.exit(1) -except ImportError: - # pip is not available, which indicates it's probably the binary - # distribution of Compose which is not affected - pass - - from . import errors from . import signals from .. import __version__ From 27297fd1af64aae4e48fce254bace122014977fd Mon Sep 17 00:00:00 2001 From: "Aaron.L.Xu" Date: Thu, 16 Feb 2017 11:14:25 +0800 Subject: [PATCH 579/580] fix a typo in script/release/utils.sh Signed-off-by: Aaron.L.Xu --- script/release/utils.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/utils.sh b/script/release/utils.sh index b4e5a2e6..321c1fb7 100644 --- a/script/release/utils.sh +++ b/script/release/utils.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Util functions for release scritps +# Util functions for release scripts # set -e From d20e3f334215a55cbebc27d8dde82108a58a0bae Mon Sep 17 00:00:00 2001 From: "Aaron.L.Xu" Date: Thu, 16 Feb 2017 15:25:04 +0800 Subject: [PATCH 580/580] function-name-modification for tests/* Signed-off-by: Aaron.L.Xu --- tests/acceptance/cli_test.py | 10 +++++----- tests/unit/cli_test.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 160e1913..8366ca75 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1234,7 +1234,7 @@ class CLITestCase(DockerClientTestCase): container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] 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' self.base_dir = 'tests/fixtures/environment-composefile' self.dispatch([ @@ -1246,9 +1246,9 @@ class CLITestCase(DockerClientTestCase): ]) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - # env overriden + # env overridden self.assertEqual('notbar', container.environment['foo']) - # keep environement from yaml + # keep environment from yaml self.assertEqual('world', container.environment['hello']) # added option from command line self.assertEqual('beta', container.environment['alpha']) @@ -1293,7 +1293,7 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(port_range[0], "0.0.0.0:49153") 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 self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple']) @@ -1310,7 +1310,7 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(port_short, "0.0.0.0:30000") 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 self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch([ diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index f9b60bff..317650cb 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -184,7 +184,7 @@ class CLITestCase(unittest.TestCase): 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( name='composetest', client=None,